Skip to main content

lattice_lsp/
logging.rs

1//! LSP logging facade -- the producer side of the
2//! `*lsp*` / `*lsp:<server>*` / `*lsp:<server>:trace*`
3//! buffer views (Phase 4.1.f).
4//!
5//! ## Inspiration
6//!
7//! emacs's `lsp-mode` puts subsystem events in `*lsp-log*` and
8//! per-server stderr in `*<server> stderr*`. eglot's
9//! `*EGLOT (project/lang) events*` captures the JSON-RPC trace
10//! when toggled. lattice combines both ideas under
11//! everything-is-a-buffer (§5.9):
12//!
13//! - **Subsystem buffer** (`*lsp*`) -- supervisor events
14//!   (spawn / handshake / crash / restart) plus cross-server
15//!   messages.
16//! - **Per-server buffer** (`*lsp:<server-id>*`) -- stderr +
17//!   `window/logMessage` + `window/showMessage` + lifecycle
18//!   for one server.
19//! - **Per-server trace** (`*lsp:<server-id>:trace*`) -- every
20//!   inbound / outbound JSON-RPC message. Off by default;
21//!   toggle with `:lsp-trace <server>`.
22//!
23//! ## Producer / consumer split
24//!
25//! This module is the **producer**: actors and the supervisor
26//! emit `LogRecord`s through [`LspLogger::log`]. Records land
27//! in bounded `LogRing`s held inside the logger.
28//!
29//! The **consumer** -- buffer-backed log views in
30//! `lattice-ui-tui` -- snapshots a ring on demand via
31//! [`LspLogger::snapshot_global`] /
32//! [`LspLogger::snapshot_server`] and renders the records.
33//! Auto-scroll-to-tail and live tail-follow are buffer-side
34//! concerns; the producer just appends.
35//!
36//! ## Tracing crate fan-out
37//!
38//! Every [`LspLogger::log`] call also emits a `tracing::*` event
39//! at the matching level so users who prefer
40//! `RUST_LOG=lattice_lsp=debug ./lattice` still see
41//! everything. The two paths are independent: the in-memory
42//! rings survive even when no `tracing` subscriber is
43//! installed, and `tracing` users see the same events whether
44//! or not the buffer views are open.
45
46use std::collections::{HashMap, HashSet, VecDeque};
47use std::path::Path;
48use std::sync::Arc;
49use std::time::SystemTime;
50
51use std::sync::Mutex;
52
53use crate::events::LspLogPushed;
54
55/// Composite key for the per-instance log ring -- the
56/// `(server_id, workspace_root)` pair that the supervisor uses
57/// to key actors. Two `rust-analyzer` processes against
58/// different workspaces stay distinct in the logger and in
59/// the buffer name. Cheap to clone (two `Arc`s).
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub struct InstanceKey {
62    pub server_id: Arc<str>,
63    pub workspace: Arc<Path>,
64}
65
66impl InstanceKey {
67    /// Construct an instance key from owned server id + workspace
68    /// path. Accepts anything that converts into `Arc<str>` /
69    /// `Arc<Path>` so callers don't need to pre-Arc.
70    pub fn new(server_id: impl Into<Arc<str>>, workspace: impl Into<Arc<Path>>) -> Self {
71        Self {
72            server_id: server_id.into(),
73            workspace: workspace.into(),
74        }
75    }
76}
77
78/// Format one log record line for append to a synthetic LSP log
79/// buffer (B'.3: hoisted out of `lattice_ui_tui::app::lsp_log_buffers`
80/// so log modes in this crate can reuse it from a tokio task).
81/// Shape: `HH:MM:SS.mmm [<server>] <level> <source>: <message>`.
82/// Trailing newline is the caller's responsibility (the drain
83/// batches many records into one buffer-append).
84pub fn format_log_event_line(
85    server_id: Option<&str>,
86    level: &str,
87    source: &str,
88    message: &str,
89) -> String {
90    let elapsed = SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok();
91    let secs = elapsed.map(|d| d.as_secs()).unwrap_or(0);
92    let ms = elapsed.map(|d| d.subsec_millis()).unwrap_or(0);
93    let hh = (secs / 3600) % 24;
94    let mm = (secs / 60) % 60;
95    let ss = secs % 60;
96    let prefix = server_id.map(|id| format!("[{id}] ")).unwrap_or_default();
97    let msg = one_line(message);
98    format!("{hh:02}:{mm:02}:{ss:02}.{ms:03} {prefix}{level} {source:>6}: {msg}")
99}
100
101/// Collapse newlines / carriage returns / tabs into spaces so the
102/// formatted record fits on one buffer line.
103fn one_line(s: &str) -> String {
104    s.replace(['\n', '\r', '\t'], " ")
105}
106
107/// Closure invoked on every successful append. Wired by the App
108/// (or test harness) to publish [`LspLogPushed`] onto the runtime
109/// event bus via `EventBus::publish_typed`, which lets log
110/// buffers refresh live as records arrive. Optional:
111/// `LspLogger::with_defaults()` starts with no publisher;
112/// `set_event_publisher` installs one.
113pub type LogEventPublisher = Arc<dyn Fn(LspLogPushed) + Send + Sync>;
114
115/// Compact severity tag for [`LspLogPushed`]. Mirrors
116/// [`LogSource::tag`]'s shape for the level discriminator.
117pub fn level_tag(l: LogLevel) -> &'static str {
118    match l {
119        LogLevel::Trace => "trace",
120        LogLevel::Debug => "debug",
121        LogLevel::Info => "info",
122        LogLevel::Warn => "warn",
123        LogLevel::Error => "error",
124    }
125}
126
127/// Internal helper: lock with consistent expect-message. Logger
128/// mutexes are never held across `.await` and never panic in
129/// the critical section, so `PoisonError` is unreachable in
130/// safe usage.
131fn lock<'a, T>(m: &'a Mutex<T>) -> std::sync::MutexGuard<'a, T> {
132    m.lock().expect("LspLogger mutex poisoned")
133}
134
135/// Severity levels. Ordered low-to-high so the derived
136/// `PartialOrd` matches "Error > Warn > ... > Trace". Per-server
137/// min level filters records BELOW it (i.e. `level < min`
138/// drops; `level >= min` keeps).
139#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
140pub enum LogLevel {
141    /// Wire trace -- per-message in/out at the codec boundary.
142    /// Only emitted when trace mode is on for the relevant
143    /// server.
144    Trace,
145    /// Per-message, non-trace detail (mailbox commands, debounce
146    /// flushes, capability gating decisions).
147    Debug,
148    /// Lifecycle milestones (handshake done, server attached, ...).
149    Info,
150    /// Recoverable problems / unexpected protocol behaviour.
151    Warn,
152    /// Unrecoverable failures.
153    Error,
154}
155
156impl LogLevel {
157    /// Parse a string like `"info"` / `"debug"` (case-insensitive).
158    /// Used by config loaders.
159    pub fn parse(s: &str) -> Option<Self> {
160        match s.trim().to_ascii_lowercase().as_str() {
161            "error" => Some(LogLevel::Error),
162            "warn" | "warning" => Some(LogLevel::Warn),
163            "info" => Some(LogLevel::Info),
164            "debug" => Some(LogLevel::Debug),
165            "trace" => Some(LogLevel::Trace),
166            _ => None,
167        }
168    }
169
170    /// Iterate over all levels, low to high.
171    pub fn all() -> &'static [LogLevel] {
172        &[
173            LogLevel::Trace,
174            LogLevel::Debug,
175            LogLevel::Info,
176            LogLevel::Warn,
177            LogLevel::Error,
178        ]
179    }
180
181    /// Single-letter compact form for log rendering.
182    pub fn short(self) -> char {
183        match self {
184            LogLevel::Error => 'E',
185            LogLevel::Warn => 'W',
186            LogLevel::Info => 'I',
187            LogLevel::Debug => 'D',
188            LogLevel::Trace => 'T',
189        }
190    }
191}
192
193/// Where a log record originated.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
195pub enum LogSource {
196    /// LSP `telemetry/event` notification (spec §3.16). Routed
197    /// here in 4.4.a so plugin subscribers can filter on
198    /// `source == "telemetry"` rather than parsing free-form
199    /// log-message text. Payload still goes through the
200    /// shared `LspLogPushed.message` String -- plugin code
201    /// that needs structured JSON can parse the suffix.
202    Telemetry,
203    /// Lattice-side (handshake start / done, supervisor restart,
204    /// shutdown sequence, decode failures, capability gating).
205    Client,
206    /// One line from the server's stderr stream.
207    Stderr,
208    /// `window/logMessage` notification.
209    LspMessage,
210    /// `window/showMessage` notification (also surfaced to
211    /// the editor's notification slot).
212    LspShowMessage,
213    /// JSON-RPC wire trace -- one inbound or outbound message.
214    Trace,
215}
216
217impl LogSource {
218    /// Compact tag used in log rendering.
219    pub fn tag(self) -> &'static str {
220        match self {
221            LogSource::Client => "client",
222            LogSource::Stderr => "stderr",
223            LogSource::LspMessage => "log",
224            LogSource::LspShowMessage => "show",
225            LogSource::Trace => "trace",
226            LogSource::Telemetry => "telemetry",
227        }
228    }
229}
230
231/// One log entry. Cheap to clone (`Arc<str>` for server id,
232/// `Arc<Path>` for workspace; the message is a plain `String`).
233#[derive(Debug, Clone)]
234pub struct LogRecord {
235    /// Wall-clock time of emission. Used for buffer rendering;
236    /// not load-bearing for ordering (records preserve insertion
237    /// order in their ring).
238    pub timestamp: SystemTime,
239    /// `None` for subsystem-wide records (supervisor events).
240    /// Pre-B'.2 records may have `server_id` without `workspace`;
241    /// post-B'.2 the two travel together (both `Some` => per-instance,
242    /// both `None` => subsystem-wide). Mixed states route to global.
243    pub server_id: Option<Arc<str>>,
244    /// `None` for subsystem-wide records. The workspace root the
245    /// `(server_id, workspace)` actor was spawned against. Two
246    /// `rust-analyzer` instances on different workspaces stay
247    /// distinct via this field.
248    pub workspace: Option<Arc<Path>>,
249    pub level: LogLevel,
250    pub source: LogSource,
251    pub message: String,
252}
253
254/// Bounded ring of log records. Append-only; oldest is evicted
255/// when capacity is reached.
256#[derive(Debug)]
257pub struct LogRing {
258    buf: VecDeque<LogRecord>,
259    capacity: usize,
260}
261
262impl LogRing {
263    /// Construct with capacity. `capacity` of 0 produces a ring
264    /// that drops everything (useful for "logging disabled").
265    pub fn new(capacity: usize) -> Self {
266        Self {
267            buf: VecDeque::with_capacity(capacity.min(1024)),
268            capacity,
269        }
270    }
271
272    /// Append a record, evicting the oldest if at capacity.
273    pub fn push(&mut self, record: LogRecord) {
274        if self.capacity == 0 {
275            return;
276        }
277        while self.buf.len() >= self.capacity {
278            self.buf.pop_front();
279        }
280        self.buf.push_back(record);
281    }
282
283    /// Number of records currently stored.
284    pub fn len(&self) -> usize {
285        self.buf.len()
286    }
287
288    pub fn is_empty(&self) -> bool {
289        self.buf.is_empty()
290    }
291
292    /// Snapshot every record. Cheap clone (each `LogRecord`'s
293    /// heavy field is the message `String`; we're optimising
294    /// for correctness here, not for log-view paint cost).
295    pub fn snapshot(&self) -> Vec<LogRecord> {
296        self.buf.iter().cloned().collect()
297    }
298
299    /// Drop everything. Used by `:lsp-log clear` (4.1.g).
300    pub fn clear(&mut self) {
301        self.buf.clear();
302    }
303
304    /// Capacity setter. Used when the user adjusts
305    /// `lsp.log_capacity` at runtime.
306    pub fn set_capacity(&mut self, capacity: usize) {
307        self.capacity = capacity;
308        while self.buf.len() > self.capacity {
309            self.buf.pop_front();
310        }
311    }
312}
313
314/// Producer-side log facade. One per LSP subsystem; each actor
315/// gets a clone (cheap -- internal state is `Arc<Mutex<...>>`).
316///
317/// Cheap-by-design: append is O(1) amortized, level gating is
318/// one HashMap lookup, trace gating is one HashSet lookup.
319/// Logging is **not** a hot path -- per-keystroke cost is the
320/// `didChange` encode + send, not the log emission.
321#[derive(Clone)]
322pub struct LspLogger {
323    state: Arc<LoggerState>,
324}
325
326struct LoggerState {
327    /// Subsystem-wide ring (records where both server_id and
328    /// workspace are None).
329    global: Mutex<LogRing>,
330    /// Per-instance rings, keyed by `(server_id, workspace)`.
331    /// One `rust-analyzer` against `/path/A` is a different ring
332    /// than `rust-analyzer` against `/path/B`.
333    per_instance: Mutex<HashMap<InstanceKey, LogRing>>,
334    /// Optional publisher fired on every successful append.
335    /// Wired by the App at boot to feed the runtime event bus.
336    /// `None` -> no events emitted (test paths, or pre-wire).
337    event_publisher: Mutex<Option<LogEventPublisher>>,
338    /// Default capacity for new per-instance rings.
339    default_capacity: Mutex<usize>,
340    /// Default min level (records below are dropped).
341    default_min_level: Mutex<LogLevel>,
342    /// Per-instance overrides for min level. Falls back to the
343    /// default when absent.
344    instance_levels: Mutex<HashMap<InstanceKey, LogLevel>>,
345    /// Per-instance trace toggle. When absent / false, Trace-level
346    /// records for that instance are dropped before the ring
347    /// lookup.
348    instance_trace: Mutex<HashSet<InstanceKey>>,
349}
350
351impl LspLogger {
352    /// Construct a logger with the given default min level and
353    /// default per-server ring capacity (e.g. `LogLevel::Info`,
354    /// 10_000). Conservative defaults: Info filters out trace
355    /// noise; 10k records ≈ a few MB of memory at most.
356    pub fn new(default_min_level: LogLevel, default_capacity: usize) -> Self {
357        Self {
358            state: Arc::new(LoggerState {
359                global: Mutex::new(LogRing::new(default_capacity)),
360                per_instance: Mutex::new(HashMap::new()),
361                default_capacity: Mutex::new(default_capacity),
362                default_min_level: Mutex::new(default_min_level),
363                instance_levels: Mutex::new(HashMap::new()),
364                instance_trace: Mutex::new(HashSet::new()),
365                event_publisher: Mutex::new(None),
366            }),
367        }
368    }
369
370    /// Install / replace the event publisher. Subsequent `log`
371    /// calls fire the closure with [`LspLogPushed`] after the
372    /// record lands in its ring. The App wires this at boot so
373    /// the runtime event bus sees every append; subscribers
374    /// (live log views) drain the bus on tick.
375    pub fn set_event_publisher(&self, publisher: LogEventPublisher) {
376        *lock(&self.state.event_publisher) = Some(publisher);
377    }
378
379    /// Sensible defaults: Info level, 10k records / ring.
380    pub fn with_defaults() -> Self {
381        Self::new(LogLevel::Info, 10_000)
382    }
383
384    /// Append a record. Gated by per-instance min level (or the
385    /// default). Records with `instance = None` route to the
386    /// subsystem-wide ring (`*lsp*`) and use the default min level.
387    /// Trace-level records are additionally gated by the
388    /// per-instance trace toggle.
389    pub fn log(
390        &self,
391        instance: Option<&InstanceKey>,
392        level: LogLevel,
393        source: LogSource,
394        message: impl Into<String>,
395    ) {
396        // Trace gating: per-instance trace toggle decides whether
397        // Trace records reach a ring at all. When the toggle is
398        // ON, Trace records bypass the per-instance min-level
399        // filter (the user opted in deliberately; the default
400        // Info filter would otherwise drop them on the floor).
401        // When the toggle is OFF, Trace records for that instance
402        // are dropped here and the level filter never sees them.
403        let trace_bypass = if level == LogLevel::Trace {
404            match instance {
405                Some(key) if self.is_tracing(key) => true,
406                Some(_) => return, // Trace, toggle off -> drop.
407                None => false,     // Subsystem-wide trace honours level filter.
408            }
409        } else {
410            false
411        };
412
413        if !trace_bypass {
414            let min = self.effective_min_level(instance);
415            if level < min {
416                return;
417            }
418        }
419
420        let message = message.into();
421
422        // tracing fan-out -- always fires, regardless of buffer
423        // views being open. RUST_LOG users see the same events.
424        let id_disp = instance.map(|key| key.server_id.to_string());
425        let ws_disp = instance.map(|key| key.workspace.display().to_string());
426        match level {
427            LogLevel::Error => tracing::error!(
428                server_id = id_disp.as_deref(),
429                workspace = ws_disp.as_deref(),
430                source = source.tag(),
431                "{}",
432                message
433            ),
434            LogLevel::Warn => tracing::warn!(
435                server_id = id_disp.as_deref(),
436                workspace = ws_disp.as_deref(),
437                source = source.tag(),
438                "{}",
439                message
440            ),
441            LogLevel::Info => tracing::info!(
442                server_id = id_disp.as_deref(),
443                workspace = ws_disp.as_deref(),
444                source = source.tag(),
445                "{}",
446                message
447            ),
448            LogLevel::Debug => tracing::debug!(
449                server_id = id_disp.as_deref(),
450                workspace = ws_disp.as_deref(),
451                source = source.tag(),
452                "{}",
453                message
454            ),
455            LogLevel::Trace => tracing::trace!(
456                server_id = id_disp.as_deref(),
457                workspace = ws_disp.as_deref(),
458                source = source.tag(),
459                "{}",
460                message
461            ),
462        }
463
464        let record = LogRecord {
465            timestamp: SystemTime::now(),
466            server_id: instance.map(|k| Arc::clone(&k.server_id)),
467            workspace: instance.map(|k| Arc::clone(&k.workspace)),
468            level,
469            source,
470            message,
471        };
472
473        // Fan out to the runtime event bus before / after the
474        // ring push. M.5.3.b: payload is a typed `LspLogPushed`;
475        // post-B'.2 it carries the workspace alongside the
476        // server_id so subscribers can route to the correct
477        // per-instance buffer.
478        let publish_payload = LspLogPushed {
479            server_id: record.server_id.clone(),
480            workspace: record.workspace.clone(),
481            level: level_tag(record.level).to_string(),
482            source: record.source.tag().to_string(),
483            message: record.message.clone(),
484        };
485
486        match instance {
487            None => {
488                lock(&self.state.global).push(record);
489            }
490            Some(key) => {
491                let cap = *lock(&self.state.default_capacity);
492                let mut per = lock(&self.state.per_instance);
493                per.entry(key.clone())
494                    .or_insert_with(|| LogRing::new(cap))
495                    .push(record);
496            }
497        }
498
499        // Snapshot the publisher under the mutex, drop the lock,
500        // then call -- the bus's internal mutex is independent of
501        // ours and we never hold both at once.
502        let publisher = lock(&self.state.event_publisher).clone();
503        if let Some(p) = publisher {
504            p(publish_payload);
505        }
506    }
507
508    /// Resolve the min level for an instance (or subsystem-wide).
509    fn effective_min_level(&self, instance: Option<&InstanceKey>) -> LogLevel {
510        if let Some(key) = instance
511            && let Some(level) = lock(&self.state.instance_levels).get(key).copied()
512        {
513            return level;
514        }
515        *lock(&self.state.default_min_level)
516    }
517
518    /// True iff trace mode is enabled for the instance. Cheap;
519    /// the trace interceptors call this once per message and
520    /// short-circuit the record build when false.
521    pub fn is_tracing(&self, instance: &InstanceKey) -> bool {
522        lock(&self.state.instance_trace).contains(instance)
523    }
524
525    /// Enable JSON-RPC trace for an instance. Trace records start
526    /// landing in the per-instance ring on the next emission.
527    pub fn enable_trace(&self, instance: InstanceKey) {
528        lock(&self.state.instance_trace).insert(instance);
529    }
530
531    /// Disable JSON-RPC trace for an instance.
532    pub fn disable_trace(&self, instance: &InstanceKey) {
533        lock(&self.state.instance_trace).remove(instance);
534    }
535
536    /// Toggle JSON-RPC trace for an instance; returns the new
537    /// state (true = on).
538    pub fn toggle_trace(&self, instance: InstanceKey) -> bool {
539        let mut guard = lock(&self.state.instance_trace);
540        if guard.contains(&instance) {
541            guard.remove(&instance);
542            false
543        } else {
544            guard.insert(instance);
545            true
546        }
547    }
548
549    /// Set per-instance min level. `None` removes the override
550    /// and reverts to the default.
551    pub fn set_instance_level(&self, instance: InstanceKey, level: Option<LogLevel>) {
552        let mut guard = lock(&self.state.instance_levels);
553        match level {
554            Some(l) => {
555                guard.insert(instance, l);
556            }
557            None => {
558                guard.remove(&instance);
559            }
560        }
561    }
562
563    /// Set the default min level (applies to subsystem-wide
564    /// records and to instances without an override).
565    pub fn set_default_level(&self, level: LogLevel) {
566        *lock(&self.state.default_min_level) = level;
567    }
568
569    /// Set the default ring capacity. Existing rings are
570    /// resized; future per-instance rings inherit the new value.
571    pub fn set_default_capacity(&self, capacity: usize) {
572        *lock(&self.state.default_capacity) = capacity;
573        lock(&self.state.global).set_capacity(capacity);
574        for (_, ring) in lock(&self.state.per_instance).iter_mut() {
575            ring.set_capacity(capacity);
576        }
577    }
578
579    /// Snapshot the subsystem-wide ring. Used by the `*lsp*`
580    /// buffer view to populate its body.
581    pub fn snapshot_global(&self) -> Vec<LogRecord> {
582        lock(&self.state.global).snapshot()
583    }
584
585    /// Snapshot an instance's ring (empty if the instance has
586    /// never logged anything).
587    pub fn snapshot_instance(&self, instance: &InstanceKey) -> Vec<LogRecord> {
588        lock(&self.state.per_instance)
589            .get(instance)
590            .map(LogRing::snapshot)
591            .unwrap_or_default()
592    }
593
594    /// List every instance with a per-instance ring. Useful for
595    /// `:lsp-status` and the `*lsp*` buffer's "servers" header.
596    pub fn known_instances(&self) -> Vec<InstanceKey> {
597        lock(&self.state.per_instance).keys().cloned().collect()
598    }
599
600    /// Drop the subsystem-wide ring's contents.
601    pub fn clear_global(&self) {
602        lock(&self.state.global).clear();
603    }
604
605    /// Drop an instance's ring contents.
606    pub fn clear_instance(&self, instance: &InstanceKey) {
607        if let Some(ring) = lock(&self.state.per_instance).get_mut(instance) {
608            ring.clear();
609        }
610    }
611}
612
613impl Default for LspLogger {
614    fn default() -> Self {
615        Self::with_defaults()
616    }
617}
618
619impl std::fmt::Debug for LspLogger {
620    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621        let global_len = lock(&self.state.global).len();
622        let n_instances = lock(&self.state.per_instance).len();
623        f.debug_struct("LspLogger")
624            .field("global_records", &global_len)
625            .field("instance_count", &n_instances)
626            .finish_non_exhaustive()
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use std::path::PathBuf;
634
635    fn key(server: &str, workspace: &str) -> InstanceKey {
636        InstanceKey::new(
637            Arc::<str>::from(server),
638            Arc::<Path>::from(PathBuf::from(workspace).as_path()),
639        )
640    }
641
642    #[test]
643    fn log_level_parse_round_trips() {
644        assert_eq!(LogLevel::parse("error"), Some(LogLevel::Error));
645        assert_eq!(LogLevel::parse("WARN"), Some(LogLevel::Warn));
646        assert_eq!(LogLevel::parse("warning"), Some(LogLevel::Warn));
647        assert_eq!(LogLevel::parse("Info"), Some(LogLevel::Info));
648        assert_eq!(LogLevel::parse("debug"), Some(LogLevel::Debug));
649        assert_eq!(LogLevel::parse("trace"), Some(LogLevel::Trace));
650        assert_eq!(LogLevel::parse("nope"), None);
651    }
652
653    #[test]
654    fn log_level_ordering_matches_severity() {
655        // Error is the highest -- a min-level filter of Error
656        // means "only show errors".
657        assert!(LogLevel::Error > LogLevel::Warn);
658        assert!(LogLevel::Warn > LogLevel::Info);
659        assert!(LogLevel::Info > LogLevel::Debug);
660        assert!(LogLevel::Debug > LogLevel::Trace);
661    }
662
663    #[test]
664    fn ring_evicts_oldest_at_capacity() {
665        let mut ring = LogRing::new(3);
666        for i in 0..5 {
667            ring.push(LogRecord {
668                timestamp: SystemTime::now(),
669                server_id: None,
670                workspace: None,
671                level: LogLevel::Info,
672                source: LogSource::Client,
673                message: format!("msg {i}"),
674            });
675        }
676        assert_eq!(ring.len(), 3);
677        let snap = ring.snapshot();
678        assert_eq!(snap[0].message, "msg 2");
679        assert_eq!(snap[1].message, "msg 3");
680        assert_eq!(snap[2].message, "msg 4");
681    }
682
683    #[test]
684    fn ring_zero_capacity_drops_everything() {
685        let mut ring = LogRing::new(0);
686        ring.push(LogRecord {
687            timestamp: SystemTime::now(),
688            server_id: None,
689            workspace: None,
690            level: LogLevel::Info,
691            source: LogSource::Client,
692            message: "lost".into(),
693        });
694        assert_eq!(ring.len(), 0);
695    }
696
697    #[test]
698    fn log_routes_to_correct_ring() {
699        let logger = LspLogger::with_defaults();
700        let rust = key("rust", "/work/A");
701        let py = key("python", "/work/A");
702
703        logger.log(None, LogLevel::Info, LogSource::Client, "subsys event");
704        logger.log(
705            Some(&rust),
706            LogLevel::Info,
707            LogSource::LspMessage,
708            "rust evt",
709        );
710        logger.log(Some(&py), LogLevel::Warn, LogSource::Stderr, "python evt");
711
712        let g = logger.snapshot_global();
713        assert_eq!(g.len(), 1);
714        assert_eq!(g[0].message, "subsys event");
715
716        let r = logger.snapshot_instance(&rust);
717        assert_eq!(r.len(), 1);
718        assert_eq!(r[0].message, "rust evt");
719
720        let p = logger.snapshot_instance(&py);
721        assert_eq!(p.len(), 1);
722        assert_eq!(p[0].message, "python evt");
723
724        // Snapshotting an unknown instance returns empty, not a
725        // panic.
726        let unknown = key("zzz", "/work/A");
727        assert!(logger.snapshot_instance(&unknown).is_empty());
728    }
729
730    #[test]
731    fn same_server_id_different_workspaces_stay_distinct() {
732        // B'.2: two `rust-analyzer` instances against different
733        // workspaces must NOT share a ring -- otherwise
734        // `*lsp:rust-analyzer:/path/A*` and
735        // `*lsp:rust-analyzer:/path/B*` would surface each other's
736        // records.
737        let logger = LspLogger::with_defaults();
738        let rust_a = key("rust", "/work/A");
739        let rust_b = key("rust", "/work/B");
740        logger.log(
741            Some(&rust_a),
742            LogLevel::Info,
743            LogSource::Client,
744            "msg from A",
745        );
746        logger.log(
747            Some(&rust_b),
748            LogLevel::Info,
749            LogSource::Client,
750            "msg from B",
751        );
752        let a = logger.snapshot_instance(&rust_a);
753        let b = logger.snapshot_instance(&rust_b);
754        assert_eq!(a.len(), 1, "instance A has its own record");
755        assert_eq!(a[0].message, "msg from A");
756        assert_eq!(b.len(), 1, "instance B has its own record");
757        assert_eq!(b[0].message, "msg from B");
758        // The record carries the workspace so renderers can verify
759        // which instance produced it.
760        assert_eq!(a[0].workspace.as_deref(), Some(Path::new("/work/A")));
761        assert_eq!(b[0].workspace.as_deref(), Some(Path::new("/work/B")));
762    }
763
764    #[test]
765    fn log_below_min_level_is_dropped() {
766        let logger = LspLogger::new(LogLevel::Warn, 100);
767        let id = key("rust", "/work/A");
768        logger.log(Some(&id), LogLevel::Info, LogSource::Client, "below");
769        logger.log(Some(&id), LogLevel::Warn, LogSource::Client, "at");
770        logger.log(Some(&id), LogLevel::Error, LogSource::Client, "above");
771        let snap = logger.snapshot_instance(&id);
772        assert_eq!(snap.len(), 2);
773        assert_eq!(snap[0].message, "at");
774        assert_eq!(snap[1].message, "above");
775    }
776
777    #[test]
778    fn per_instance_level_overrides_default() {
779        let logger = LspLogger::new(LogLevel::Info, 100);
780        let rust = key("rust", "/work/A");
781        let py = key("python", "/work/A");
782        logger.set_instance_level(rust.clone(), Some(LogLevel::Debug));
783
784        logger.log(Some(&rust), LogLevel::Debug, LogSource::Client, "rust dbg");
785        logger.log(Some(&py), LogLevel::Debug, LogSource::Client, "py dbg");
786
787        // rust override: Debug accepted.
788        assert_eq!(logger.snapshot_instance(&rust).len(), 1);
789        // python default: Debug below Info, dropped.
790        assert_eq!(logger.snapshot_instance(&py).len(), 0);
791    }
792
793    #[test]
794    fn trace_records_gated_by_per_instance_toggle() {
795        let logger = LspLogger::new(LogLevel::Trace, 100); // Trace-permissive default
796        let id = key("rust", "/work/A");
797        // Trace toggle off by default -- record dropped.
798        logger.log(Some(&id), LogLevel::Trace, LogSource::Trace, "t1");
799        assert_eq!(logger.snapshot_instance(&id).len(), 0);
800        // Enable trace, emit, observe.
801        logger.enable_trace(id.clone());
802        assert!(logger.is_tracing(&id));
803        logger.log(Some(&id), LogLevel::Trace, LogSource::Trace, "t2");
804        let snap = logger.snapshot_instance(&id);
805        assert_eq!(snap.len(), 1);
806        assert_eq!(snap[0].message, "t2");
807        // Disable.
808        logger.disable_trace(&id);
809        assert!(!logger.is_tracing(&id));
810        logger.log(Some(&id), LogLevel::Trace, LogSource::Trace, "t3");
811        assert_eq!(logger.snapshot_instance(&id).len(), 1, "no new records");
812    }
813
814    #[test]
815    fn toggle_trace_returns_new_state() {
816        let logger = LspLogger::with_defaults();
817        let id = key("rust", "/work/A");
818        assert!(logger.toggle_trace(id.clone()));
819        assert!(!logger.toggle_trace(id.clone()));
820    }
821
822    #[test]
823    fn known_instances_lists_only_seen_instances() {
824        let logger = LspLogger::with_defaults();
825        let rust = key("rust", "/work/A");
826        let py = key("python", "/work/A");
827        logger.log(Some(&rust), LogLevel::Info, LogSource::Client, "x");
828        logger.log(Some(&py), LogLevel::Info, LogSource::Client, "y");
829        let mut known: Vec<String> = logger
830            .known_instances()
831            .into_iter()
832            .map(|k| k.server_id.to_string())
833            .collect();
834        known.sort();
835        assert_eq!(known, vec!["python".to_string(), "rust".to_string()]);
836    }
837
838    #[test]
839    fn clearing_drops_records() {
840        let logger = LspLogger::with_defaults();
841        let id = key("rust", "/work/A");
842        logger.log(None, LogLevel::Info, LogSource::Client, "a");
843        logger.log(Some(&id), LogLevel::Info, LogSource::Client, "b");
844        logger.clear_global();
845        assert!(logger.snapshot_global().is_empty());
846        // Per-instance ring still has the entry until we clear it.
847        assert_eq!(logger.snapshot_instance(&id).len(), 1);
848        logger.clear_instance(&id);
849        assert!(logger.snapshot_instance(&id).is_empty());
850    }
851
852    #[test]
853    fn set_default_capacity_resizes_existing_rings() {
854        let logger = LspLogger::new(LogLevel::Info, 100);
855        let id = key("rust", "/work/A");
856        for i in 0..50 {
857            logger.log(
858                Some(&id),
859                LogLevel::Info,
860                LogSource::Client,
861                format!("r{i}"),
862            );
863        }
864        assert_eq!(logger.snapshot_instance(&id).len(), 50);
865        // Shrink to 10 -- the most recent 10 survive.
866        logger.set_default_capacity(10);
867        let snap = logger.snapshot_instance(&id);
868        assert_eq!(snap.len(), 10);
869        assert_eq!(snap[0].message, "r40");
870        assert_eq!(snap[9].message, "r49");
871    }
872
873    #[test]
874    fn cheap_clone_shares_state() {
875        let a = LspLogger::with_defaults();
876        let b = a.clone();
877        a.log(None, LogLevel::Info, LogSource::Client, "from a");
878        // Clone sees the same ring.
879        assert_eq!(b.snapshot_global().len(), 1);
880    }
881}