logicaffeine_lsp/
flycheck.rs1use std::hash::{Hash, Hasher};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Mutex, OnceLock};
14
15use dashmap::DashMap;
16use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Range, Url};
17
18use logicaffeine_language::token::Span;
19
20use crate::line_index::LineIndex;
21
22pub const FLYCHECK_SOURCE: &str = "logicaffeine (rustc)";
25
26pub struct FlycheckFinding {
28 pub message: String,
29 pub suggestion: Option<String>,
30 pub span: Option<Span>,
32}
33
34pub trait FlycheckRunner: Send + Sync {
38 fn check(&self, source: &str, workspace_key: &str) -> Option<Vec<FlycheckFinding>>;
41}
42
43pub struct Flycheck {
46 runner: Box<dyn FlycheckRunner>,
47 generations: DashMap<Url, AtomicU64>,
48 results: DashMap<Url, Vec<Diagnostic>>,
49}
50
51impl Flycheck {
52 pub fn new(runner: Box<dyn FlycheckRunner>) -> Self {
53 Flycheck {
54 runner,
55 generations: DashMap::new(),
56 results: DashMap::new(),
57 }
58 }
59
60 pub fn begin_save(&self, uri: &Url) -> u64 {
63 self.generations
64 .entry(uri.clone())
65 .or_insert_with(|| AtomicU64::new(0))
66 .fetch_add(1, Ordering::SeqCst)
67 + 1
68 }
69
70 pub fn is_current(&self, uri: &Url, generation: u64) -> bool {
71 self.generations
72 .get(uri)
73 .map(|g| g.load(Ordering::SeqCst) == generation)
74 .unwrap_or(false)
75 }
76
77 pub fn clear(&self, uri: &Url) {
79 self.results.remove(uri);
80 if let Some(generation) = self.generations.get(uri) {
82 generation.fetch_add(1, Ordering::SeqCst);
83 }
84 }
85
86 pub fn forget(&self, uri: &Url) {
87 self.results.remove(uri);
88 self.generations.remove(uri);
89 }
90
91 pub fn diagnostics_for(&self, uri: &Url) -> Vec<Diagnostic> {
93 self.results
94 .get(uri)
95 .map(|entry| entry.clone())
96 .unwrap_or_default()
97 }
98
99 pub fn run(&self, source: &str, workspace_key: &str) -> Option<Vec<FlycheckFinding>> {
101 self.runner.check(source, workspace_key)
102 }
103
104 pub fn complete(
107 &self,
108 uri: &Url,
109 generation: u64,
110 findings: Vec<FlycheckFinding>,
111 checked_text: &str,
112 interactive: &[Diagnostic],
113 ) -> Option<Vec<Diagnostic>> {
114 if !self.is_current(uri, generation) {
115 return None;
116 }
117 let diagnostics = to_diagnostics(findings, checked_text, interactive);
118 self.results.insert(uri.clone(), diagnostics.clone());
119 Some(diagnostics)
120 }
121}
122
123fn to_diagnostics(
127 findings: Vec<FlycheckFinding>,
128 checked_text: &str,
129 interactive: &[Diagnostic],
130) -> Vec<Diagnostic> {
131 let line_index = LineIndex::new(checked_text);
132 findings
133 .into_iter()
134 .map(|finding| {
135 let range = match finding.span {
136 Some(span) => Range {
137 start: line_index.position(span.start),
138 end: line_index.position(span.end),
139 },
140 None => Range::default(),
141 };
142 let mut message = finding.message;
143 if let Some(suggestion) = finding.suggestion {
144 message.push_str("\n\n");
145 message.push_str(&suggestion);
146 }
147 Diagnostic {
148 range,
149 severity: Some(DiagnosticSeverity::ERROR),
150 source: Some(FLYCHECK_SOURCE.to_string()),
151 message,
152 ..Default::default()
153 }
154 })
155 .filter(|diagnostic| {
156 !interactive.iter().any(|existing| {
157 existing.severity == Some(DiagnosticSeverity::ERROR)
158 && ranges_intersect(&existing.range, &diagnostic.range)
159 })
160 })
161 .collect()
162}
163
164fn ranges_intersect(a: &Range, b: &Range) -> bool {
165 !(a.end < b.start || b.end < a.start)
166}
167
168pub struct CargoFlycheck {
177 locks: DashMap<String, std::sync::Arc<Mutex<()>>>,
178}
179
180impl CargoFlycheck {
181 pub fn new() -> Self {
182 CargoFlycheck {
183 locks: DashMap::new(),
184 }
185 }
186}
187
188impl Default for CargoFlycheck {
189 fn default() -> Self {
190 Self::new()
191 }
192}
193
194fn cargo_available() -> bool {
195 static AVAILABLE: OnceLock<bool> = OnceLock::new();
196 *AVAILABLE.get_or_init(|| {
197 std::process::Command::new("cargo")
198 .arg("--version")
199 .output()
200 .map(|o| o.status.success())
201 .unwrap_or(false)
202 })
203}
204
205fn cache_dir_for(workspace_key: &str) -> std::path::PathBuf {
206 let mut hasher = std::collections::hash_map::DefaultHasher::new();
207 workspace_key.hash(&mut hasher);
208 std::env::temp_dir().join(format!("logicaffeine-flycheck-{:016x}", hasher.finish()))
209}
210
211impl FlycheckRunner for CargoFlycheck {
212 fn check(&self, source: &str, workspace_key: &str) -> Option<Vec<FlycheckFinding>> {
213 if !cargo_available() {
214 return None;
215 }
216 let lock = self
217 .locks
218 .entry(workspace_key.to_string())
219 .or_insert_with(|| std::sync::Arc::new(Mutex::new(())))
220 .clone();
221 let _guard = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
222
223 let dir = cache_dir_for(workspace_key);
224 match logicaffeine_compile::compile::rustc_check(source, &dir) {
225 Ok(errors) => Some(
226 errors
227 .into_iter()
228 .map(|e| FlycheckFinding {
229 message: format!("{}\n\n{}", e.title, e.explanation),
230 suggestion: e.suggestion,
231 span: e.logos_span,
232 })
233 .collect(),
234 ),
235 Err(logicaffeine_compile::compile::CompileError::Parse(_)) => Some(Vec::new()),
237 Err(logicaffeine_compile::compile::CompileError::Build(stderr)) => {
240 let first_error = stderr
241 .lines()
242 .find(|l| l.contains("error"))
243 .unwrap_or("cargo check failed")
244 .to_string();
245 Some(vec![FlycheckFinding {
246 message: format!(
247 "The generated Rust failed to compile — likely a LOGOS compiler bug, \
248 not an error in your program.\n\n{first_error}"
249 ),
250 suggestion: None,
251 span: None,
252 }])
253 }
254 Err(other) => {
255 log::warn!("flycheck could not run: {other:?}");
256 None
257 }
258 }
259 }
260}