Skip to main content

logicaffeine_web/ui/components/
problems.rs

1//! The language server's diagnostics as the Studio's editor shows them: a squiggle under the
2//! words each one names, and the problem at the top of the hover — VS Code's Problems, in the
3//! page, from the same analysis (`logicaffeine_lsp`) the server publishes.
4
5use logicaffeine_lsp::document::DocumentState;
6
7use super::hover_hint::{escape, markdown_to_html};
8
9/// How loud a problem is, quietest first.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
11pub enum Severity {
12    Hint,
13    Info,
14    Warning,
15    Error,
16}
17
18/// One diagnostic, placed in the source by byte offsets.
19#[derive(Clone, Debug, PartialEq)]
20pub struct Problem {
21    /// Never empty, and always covers at least one visible character, so there is always
22    /// something to draw the squiggle under.
23    pub start: usize,
24    pub end: usize,
25    pub severity: Severity,
26    /// Code that does nothing (an unused binding): drawn faded instead of squiggled.
27    pub unnecessary: bool,
28    pub message: String,
29    pub source: Option<String>,
30    pub code: Option<String>,
31    /// Where the code is explained.
32    pub docs: Option<String>,
33}
34
35/// Every problem the language server finds in the document.
36pub fn problems(doc: &DocumentState) -> Vec<Problem> {
37    use logicaffeine_lsp::lsp_types::{DiagnosticSeverity, DiagnosticTag, NumberOrString};
38    doc.diagnostics
39        .iter()
40        .map(|d| {
41            let (start, end) = word_span(
42                &doc.source,
43                doc.line_index.offset(d.range.start),
44                doc.line_index.offset(d.range.end),
45            );
46            Problem {
47                start,
48                end,
49                // A diagnostic without a severity is an error — VS Code reads it the same way.
50                severity: match d.severity {
51                    Some(DiagnosticSeverity::WARNING) => Severity::Warning,
52                    Some(DiagnosticSeverity::INFORMATION) => Severity::Info,
53                    Some(DiagnosticSeverity::HINT) => Severity::Hint,
54                    _ => Severity::Error,
55                },
56                unnecessary: d.tags.as_ref().is_some_and(|t| t.contains(&DiagnosticTag::UNNECESSARY)),
57                message: d.message.clone(),
58                source: d.source.clone(),
59                code: d.code.as_ref().map(|c| match c {
60                    NumberOrString::String(s) => s.clone(),
61                    NumberOrString::Number(n) => n.to_string(),
62                }),
63                docs: d.code_description.as_ref().map(|c| c.href.to_string()),
64            }
65        })
66        .collect()
67}
68
69/// The span a problem is drawn under: its own range when that shows anything, otherwise the
70/// word it touches — the one at its start, or else the nearest one before it, or after it.
71fn word_span(source: &str, start: usize, end: usize) -> (usize, usize) {
72    let floor = |mut i: usize| {
73        i = i.min(source.len());
74        while !source.is_char_boundary(i) {
75            i -= 1;
76        }
77        i
78    };
79    let ceil = |mut i: usize| {
80        i = i.min(source.len());
81        while !source.is_char_boundary(i) {
82            i += 1;
83        }
84        i
85    };
86    let empty = end <= start;
87    let start = floor(start);
88    let end = if empty { start } else { ceil(end) };
89    if !source[start..end].trim().is_empty() {
90        return (start, end);
91    }
92    let word = |at: usize| {
93        let from = source[..at].rfind(char::is_whitespace).map_or(0, |i| i + source[i..].chars().next().map_or(1, char::len_utf8));
94        let to = source[at..].find(char::is_whitespace).map_or(source.len(), |i| at + i);
95        (from, to)
96    };
97    let visible_at = |i: usize| source[i..].chars().next().is_some_and(|c| !c.is_whitespace());
98    if visible_at(start) {
99        return word(start);
100    }
101    if let Some((i, _)) = source[..start].char_indices().rev().find(|(_, c)| !c.is_whitespace()) {
102        return word(i);
103    }
104    if let Some((i, _)) = source[start..].char_indices().find(|(_, c)| !c.is_whitespace()) {
105        return word(start + i);
106    }
107    let next = source[start..].chars().next().map_or(0, char::len_utf8);
108    (start, start + next)
109}
110
111/// The classes for the text in `start..end`: the most severe squiggle over it, and a fade
112/// when any code there does nothing.
113pub fn squiggle_class(problems: &[Problem], start: usize, end: usize) -> String {
114    let over: Vec<&Problem> = problems.iter().filter(|p| p.start < end && start < p.end).collect();
115    // Unnecessary code is shown by its fade; only its hints go without a squiggle.
116    let squiggle = over
117        .iter()
118        .filter(|p| !(p.unnecessary && p.severity == Severity::Hint))
119        .map(|p| p.severity)
120        .max()
121        .map(|s| match s {
122            Severity::Error => "sq-error",
123            Severity::Warning => "sq-warning",
124            Severity::Info => "sq-info",
125            Severity::Hint => "sq-hint",
126        });
127    let fade = over.iter().any(|p| p.unnecessary).then_some("sq-unnecessary");
128    [squiggle, fade].into_iter().flatten().collect::<Vec<_>>().join(" ")
129}
130
131/// The problems at `offset` as the top of a hover: each message, then where it comes from,
132/// its code linked to the explanation.
133pub fn problems_html(problems: &[Problem], offset: usize) -> Option<String> {
134    let mut here: Vec<&Problem> = problems.iter().filter(|p| p.start <= offset && offset < p.end).collect();
135    if here.is_empty() {
136        return None;
137    }
138    here.sort_by(|a, b| b.severity.cmp(&a.severity));
139    Some(
140        here.iter()
141            .map(|p| {
142                let code = p.code.as_deref().map(|code| match p.docs.as_deref() {
143                    Some(url) if url.starts_with("https://") || url.starts_with("http://") => format!(
144                        "<a href=\"{}\" target=\"_blank\" rel=\"noopener noreferrer\">{}</a>",
145                        escape(url),
146                        escape(code)
147                    ),
148                    _ => escape(code),
149                });
150                let origin = match (p.source.as_deref().map(escape), code) {
151                    (Some(source), Some(code)) => format!("{source}({code})"),
152                    (Some(only), None) | (None, Some(only)) => only,
153                    (None, None) => String::new(),
154                };
155                let origin = if origin.is_empty() { origin } else { format!("<p class=\"hint-source\">{origin}</p>") };
156                format!("<div class=\"hint-problem\">{}{origin}</div>", markdown_to_html(&p.message))
157            })
158            .collect(),
159    )
160}
161
162/// Problems found in `old`, carried into `new` until it is analysed afresh: the text before
163/// and after an edit keeps its problems, moved with it; a problem the edit touched is dropped.
164pub fn rebase(problems: &[Problem], old: &str, new: &str) -> Vec<Problem> {
165    let mut prefix = old.bytes().zip(new.bytes()).take_while(|(a, b)| a == b).count();
166    while !old.is_char_boundary(prefix) {
167        prefix -= 1;
168    }
169    let room = old.len().min(new.len()) - prefix;
170    let mut suffix = old.bytes().rev().zip(new.bytes().rev()).take_while(|(a, b)| a == b).count().min(room);
171    while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix) {
172        suffix -= 1;
173    }
174    let edited_end = old.len() - suffix;
175    let moved_by = new.len() as isize - old.len() as isize;
176    problems
177        .iter()
178        .filter_map(|p| {
179            if p.end <= prefix {
180                Some(p.clone())
181            } else if p.start >= edited_end {
182                let shift = |i: usize| (i as isize + moved_by) as usize;
183                Some(Problem { start: shift(p.start), end: shift(p.end), ..p.clone() })
184            } else {
185                None
186            }
187        })
188        .collect()
189}
190
191#[cfg(test)]
192#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/components/problems/tests.rs"]
193mod tests;