Skip to main content

lattice_lsp/
help_views.rs

1//! LSP-specific help-buffer factories (DESIGN.md §5.11).
2//!
3//! These produce [`lattice_help::HelpContent`] from LSP runtime state
4//! (the diagnostics layer, supervisor, logger, etc.). They live in
5//! `lattice-lsp` rather than `lattice-help` because they read LSP
6//! types -- the help crate has no awareness of LSP and should stay
7//! that way (one extension model, one substrate). Callers in the
8//! editor App invoke these directly when servicing
9//! `:diagnostics` / `:lsp-status` / `:lsp-log` / `:references`.
10//!
11//! Each function returns an unsyntaxed [`HelpContent`]; help buffers
12//! receive their markdown syntax and link styling from the live
13//! cells-worker `DisplayMatrix` once displayed.
14
15use lattice_help::{HelpContent, one_line};
16
17use crate::{
18    Capabilities, DiagnosticSeverity, DiagnosticsLayer, LogRecord, LogSource, LspLogger,
19    LspSupervisor, LspSupervisorHandle, actor::uri_to_path,
20};
21
22/// Build a help buffer listing every workspace diagnostic
23/// (Phase 4.1.d.iv). Each diagnostic renders as one
24/// `[severity] [path:line:col message](file:path:line)` row -- the
25/// markdown link is parsed by `extract_links_and_clean` into a
26/// `HelpLinkTarget::Source` that the existing `do_help_follow_link`
27/// path knows how to dispatch (jumps to the file at the given line).
28///
29/// URIs sort alphabetically; diagnostics within a URI sort by
30/// (line, column). Empty layer renders an explicit "no diagnostics"
31/// message so the buffer is always useful as a status read.
32pub fn diagnostics_help(layer: &DiagnosticsLayer) -> HelpContent {
33    let snapshot = layer.snapshot();
34    let counts = layer.severity_counts();
35    let mut lines: Vec<String> = Vec::new();
36    if snapshot.is_empty() {
37        lines.push("# Workspace diagnostics".to_string());
38        lines.push(String::new());
39        lines.push("(none)".to_string());
40        return HelpContent::from_lines("diagnostics", lines);
41    }
42    lines.push(format!(
43        "# Workspace diagnostics ({} total: {} errors, {} warnings, {} info, {} hints)",
44        counts.total(),
45        counts.errors,
46        counts.warnings,
47        counts.info,
48        counts.hints
49    ));
50    lines.push(String::new());
51    for (uri, diags) in snapshot {
52        let path = uri_to_path(&uri)
53            .map(|p| p.display().to_string())
54            .unwrap_or_else(|| uri.as_str().to_string());
55        lines.push(format!("## {} ({})", path, diags.len()));
56        lines.push(String::new());
57        for d in diags {
58            let sev = match d.severity {
59                Some(DiagnosticSeverity::ERROR) => "E",
60                Some(DiagnosticSeverity::WARNING) => "W",
61                Some(DiagnosticSeverity::INFORMATION) => "I",
62                Some(DiagnosticSeverity::HINT) => "H",
63                _ => "?",
64            };
65            let line0 = d.range.start.line;
66            let col0 = d.range.start.character;
67            let label = format!(
68                "{}:{}:{} {}",
69                path,
70                line0 + 1,
71                col0 + 1,
72                one_line(&d.message)
73            );
74            // Escaped: a compiler message routinely contains `]` or `)`
75            // (`expected [u8; 4]`, `fn(i32)`), and unescaped either one
76            // ended the link early and left its tail as literal markdown.
77            let label = lattice_help::escape_link_text(&label);
78            let url = lattice_help::escape_link_text(&format!("{path}:{line0}"));
79            lines.push(format!("[{sev}] [{label}](file:{url})"));
80        }
81        lines.push(String::new());
82    }
83    HelpContent::from_lines("diagnostics", lines)
84}
85
86/// Build the `*lsp*` subsystem-wide log view (Phase 4.1.g).
87/// Snapshots `logger.snapshot_global()` and renders one row per
88/// record: `<timestamp> <level> <source> <message>`.
89pub fn lsp_global_log_help(logger: &LspLogger) -> HelpContent {
90    let records = logger.snapshot_global();
91    let mut lines: Vec<String> = Vec::new();
92    lines.push(format!(
93        "# *lsp* (subsystem-wide, {} records)",
94        records.len()
95    ));
96    lines.push(String::new());
97    if records.is_empty() {
98        lines.push("(no records)".to_string());
99    } else {
100        for r in records {
101            lines.push(format_log_record(&r));
102        }
103    }
104    HelpContent::from_lines("lsp", lines)
105}
106
107/// Build a per-instance log view (`*lsp:<server>:<workspace>*`).
108/// Filters out trace records (those land in `lsp_server_trace_help`).
109pub fn lsp_server_log_help(
110    logger: &LspLogger,
111    instance: &crate::logging::InstanceKey,
112) -> HelpContent {
113    let records = logger.snapshot_instance(instance);
114    let body: Vec<&LogRecord> = records
115        .iter()
116        .filter(|r| r.source != LogSource::Trace)
117        .collect();
118    let mut lines: Vec<String> = Vec::new();
119    lines.push(format!(
120        "# *lsp:{}:{}* ({} records, trace excluded)",
121        instance.server_id,
122        instance.workspace.display(),
123        body.len()
124    ));
125    lines.push(String::new());
126    if body.is_empty() {
127        lines.push("(no records)".to_string());
128    } else {
129        for r in body {
130            lines.push(format_log_record(r));
131        }
132    }
133    HelpContent::from_lines(
134        format!(
135            "lsp:{}:{}",
136            instance.server_id,
137            instance.workspace.display()
138        ),
139        lines,
140    )
141}
142
143/// Build the JSON-RPC trace view
144/// (`*lsp:<server>:<workspace>:trace*`). Filters to
145/// `LogSource::Trace` records only. Empty when trace mode hasn't
146/// been on.
147pub fn lsp_server_trace_help(
148    logger: &LspLogger,
149    instance: &crate::logging::InstanceKey,
150) -> HelpContent {
151    let records = logger.snapshot_instance(instance);
152    let body: Vec<&LogRecord> = records
153        .iter()
154        .filter(|r| r.source == LogSource::Trace)
155        .collect();
156    let mut lines: Vec<String> = Vec::new();
157    let trace_on = logger.is_tracing(instance);
158    lines.push(format!(
159        "# *lsp:{}:{}:trace* ({} records, trace currently {})",
160        instance.server_id,
161        instance.workspace.display(),
162        body.len(),
163        if trace_on { "ON" } else { "OFF" }
164    ));
165    lines.push(String::new());
166    if body.is_empty() {
167        lines.push("(no trace records; toggle with `:lsp-trace <server>`)".to_string());
168    } else {
169        for r in body {
170            lines.push(format_log_record(r));
171        }
172    }
173    HelpContent::from_lines(
174        format!(
175            "lsp:{}:{}:trace",
176            instance.server_id,
177            instance.workspace.display()
178        ),
179        lines,
180    )
181}
182
183/// Build the `:lsp-server-log` picker -- one row per running actor
184/// with workspace root + buffer count + capability summary in the
185/// margin, each row carrying an `exec:` link to its log + trace.
186/// Use `/query` (vim regex search) to filter; press `<CR>` on a
187/// link to open. A real fuzzy picker arrives with the bundled
188/// fuzzy-finder plugin (Phase 8b); for now this listing keeps
189/// everything reachable through the existing help-buffer machinery.
190pub fn lsp_server_log_listing_help(supervisor: &LspSupervisor) -> HelpContent {
191    let mut actors = supervisor.running_actors();
192    actors.sort_by(|a, b| a.0.1.cmp(&b.0.1).then_with(|| a.0.0.cmp(&b.0.0)));
193    let mut lines: Vec<String> = Vec::new();
194    lines.push(format!(
195        "# :lsp-server-log ({} server actor(s) running)",
196        actors.len()
197    ));
198    lines.push(String::new());
199    if actors.is_empty() {
200        lines.push(
201            "(no LSP servers running; open a file with a matching language to attach. \
202             see :lsp-status for the full overview.)"
203                .to_string(),
204        );
205        return HelpContent::from_lines("lsp-server-log", lines);
206    }
207    lines.push(
208        "Each row links to the per-server log (`*lsp:<server>*`) and trace \
209         (`*lsp:<server>:trace*`). Press `<CR>` on a link to open. Use \
210         `/query` to filter rows by id or workspace path."
211            .to_string(),
212    );
213    lines.push(String::new());
214    for ((workspace, server_id), handle) in &actors {
215        let buffer_count = supervisor.buffer_count_for(&(workspace.clone(), server_id.clone()));
216        let caps = handle.capabilities();
217        let cap_summary = summarise_capabilities(&caps);
218        lines.push(format!("## [{server_id}](exec:lsp-log {server_id})"));
219        lines.push(format!("- workspace:    `{}`", workspace.display()));
220        lines.push(format!("- buffers:      {buffer_count} attached"));
221        lines.push(format!("- capabilities: {cap_summary}"));
222        lines.push(format!(
223            "- trace:        [open / toggle](exec:lsp-trace {server_id})"
224        ));
225        lines.push(String::new());
226    }
227    HelpContent::from_lines("lsp-server-log", lines)
228}
229
230/// Build the `:lsp-status` view -- one row per running actor (id,
231/// workspace root, server-side capability summary).
232pub fn lsp_status_help(supervisor: &LspSupervisorHandle) -> HelpContent {
233    let actors = supervisor.running_actors();
234    let mut lines: Vec<String> = Vec::new();
235    lines.push(format!(
236        "# :lsp-status ({} server(s), {} attached buffer(s))",
237        actors.len(),
238        supervisor.attached_buffer_count()
239    ));
240    lines.push(String::new());
241    if actors.is_empty() {
242        lines.push(
243            "(no LSP servers running; open a file with a matching language to attach)".to_string(),
244        );
245    } else {
246        for ((workspace, server_id), handle) in actors {
247            let caps = handle.capabilities();
248            lines.push(format!("## {server_id}"));
249            lines.push(format!("- workspace root: `{}`", workspace.display()));
250            lines.push(format!("- position encoding: {:?}", caps.position_encoding));
251            lines.push(format!("- supports hover: {}", caps.supports_hover()));
252            lines.push(format!(
253                "- supports definition: {}",
254                caps.supports_definition()
255            ));
256            lines.push(format!(
257                "- diagnostics subscribers: {}",
258                handle.diagnostics_subscriber_count()
259            ));
260            lines.push(String::new());
261        }
262    }
263    HelpContent::from_lines("lsp-status", lines)
264}
265
266/// One-line summary of a server's negotiated capabilities. Used in
267/// the `:lsp-server-log` picker margin so a glance tells the user
268/// "this server has hover + completion but not references" without
269/// having to dig into `:lsp-status`.
270pub fn summarise_capabilities(caps: &Capabilities) -> String {
271    let mut parts: Vec<&str> = Vec::new();
272    if caps.supports_hover() {
273        parts.push("hover");
274    }
275    if caps.supports_definition() {
276        parts.push("definition");
277    }
278    if caps.supports_references() {
279        parts.push("references");
280    }
281    if caps.supports_document_symbol() {
282        parts.push("document-symbol");
283    }
284    if caps.supports_workspace_symbol() {
285        parts.push("workspace-symbol");
286    }
287    if caps.supports_completion() {
288        parts.push("completion");
289    }
290    if parts.is_empty() {
291        "(none advertised)".into()
292    } else {
293        parts.join(", ")
294    }
295}
296
297/// Render one log record as a one-line entry:
298/// `HH:MM:SS.mmm <level> <source>: <message>`. Used by the four log
299/// buffer builders above (Phase 4.1.g).
300fn format_log_record(r: &LogRecord) -> String {
301    use std::time::SystemTime;
302    let elapsed = r.timestamp.duration_since(SystemTime::UNIX_EPOCH).ok();
303    let secs = elapsed.map(|d| d.as_secs()).unwrap_or(0);
304    let ms = elapsed.map(|d| d.subsec_millis()).unwrap_or(0);
305    let hh = (secs / 3600) % 24;
306    let mm = (secs / 60) % 60;
307    let ss = secs % 60;
308    format!(
309        "{:02}:{:02}:{:02}.{:03} {} {:>6}: {}",
310        hh,
311        mm,
312        ss,
313        ms,
314        r.level.short(),
315        r.source.tag(),
316        one_line(&r.message)
317    )
318}
319
320#[cfg(test)]
321mod tests {
322    #![allow(clippy::unwrap_used, clippy::panic)]
323    use super::*;
324    use crate::{DiagnosticEvent, LspLogger};
325    use lsp_types::{Diagnostic, Position, Range, Uri};
326    use std::str::FromStr;
327    use std::sync::Arc;
328
329    /// A compiler message routinely carries `]` and `)` — this one is
330    /// rust-analyzer's shape. Unescaped, the `]` ended the link's label and
331    /// the row rendered its URL as literal text with no jump target.
332    #[test]
333    fn a_message_with_brackets_keeps_its_row_a_single_link() {
334        let layer = DiagnosticsLayer::new(LspLogger::with_defaults());
335        let message = "expected `[u8; 4]`, found `fn(i32)`";
336        layer.apply(DiagnosticEvent {
337            server_id: Arc::from("rust"),
338            uri: Uri::from_str("file:///x.rs").unwrap(),
339            version: Some(1),
340            diagnostics: Arc::from(
341                vec![Diagnostic {
342                    range: Range {
343                        start: Position {
344                            line: 2,
345                            character: 4,
346                        },
347                        end: Position {
348                            line: 2,
349                            character: 9,
350                        },
351                    },
352                    severity: Some(DiagnosticSeverity::ERROR),
353                    message: message.into(),
354                    ..Default::default()
355                }]
356                .into_boxed_slice(),
357            ),
358        });
359
360        let content = diagnostics_help(&layer);
361        let text = content.buffer.content.as_string();
362        let row = text
363            .lines()
364            .find(|l| l.starts_with("[E]"))
365            .unwrap_or_else(|| panic!("an error row:\n{text}"));
366        assert_eq!(
367            row,
368            format!("[E] /x.rs:3:5 {message}"),
369            "the whole label renders, with no markdown left over"
370        );
371        assert!(
372            content
373                .metadata
374                .links
375                .iter()
376                .any(|l| matches!(&l.target, lattice_help::HelpLinkTarget::Source { .. })),
377            "and the row still jumps to the file: {:?}",
378            content.metadata.links
379        );
380    }
381}