logicaffeine_web/ui/components/
problems.rs1use logicaffeine_lsp::document::DocumentState;
6
7use super::hover_hint::{escape, markdown_to_html};
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
11pub enum Severity {
12 Hint,
13 Info,
14 Warning,
15 Error,
16}
17
18#[derive(Clone, Debug, PartialEq)]
20pub struct Problem {
21 pub start: usize,
24 pub end: usize,
25 pub severity: Severity,
26 pub unnecessary: bool,
28 pub message: String,
29 pub source: Option<String>,
30 pub code: Option<String>,
31 pub docs: Option<String>,
33}
34
35pub 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 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
69fn 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
111pub 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 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
131pub 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
162pub 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;