Skip to main content

logicaffeine_lsp/
flycheck.rs

1//! The rustc flycheck: on save, compile the document through the AOT
2//! backend's mapped codegen and run `cargo check` over it, translating every
3//! finding back to English with real user-source spans — the borrow checker,
4//! speaking LOGOS.
5//!
6//! Never on the interactive path: saves trigger it, a per-document
7//! generation guard drops stale results (a newer save always wins), edits
8//! clear findings outright (their positions would lie), and a machine
9//! without cargo degrades to interactive-only diagnostics.
10
11use 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
22/// The diagnostic source label — distinct from interactive `logicaffeine`
23/// diagnostics so editors and users can tell the two engines apart.
24pub const FLYCHECK_SOURCE: &str = "logicaffeine (rustc)";
25
26/// One translated rustc finding.
27pub struct FlycheckFinding {
28    pub message: String,
29    pub suggestion: Option<String>,
30    /// User-source span from the mapped codegen; `None` = whole file.
31    pub span: Option<Span>,
32}
33
34/// The check engine seam. The real runner shells out to cargo; tests inject
35/// mocks so the server's merge/staleness/dedup behavior is provable without
36/// a toolchain.
37pub trait FlycheckRunner: Send + Sync {
38    /// `None` = the toolchain is unavailable (degrade silently);
39    /// `Some(findings)` = the check ran.
40    fn check(&self, source: &str, workspace_key: &str) -> Option<Vec<FlycheckFinding>>;
41}
42
43/// Per-document flycheck state: save generations and the last published
44/// findings (already converted to diagnostics against the checked text).
45pub 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    /// A save happened: invalidate any in-flight run and return the new
61    /// generation this one must still hold when it finishes.
62    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    /// Edits invalidate findings outright — their positions would lie.
78    pub fn clear(&self, uri: &Url) {
79        self.results.remove(uri);
80        // Bump so an in-flight save result from before the edit is dropped.
81        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    /// The last completed run's diagnostics, for merging into any publish.
92    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    /// Run the checker (blocking — call inside `spawn_blocking`).
100    pub fn run(&self, source: &str, workspace_key: &str) -> Option<Vec<FlycheckFinding>> {
101        self.runner.check(source, workspace_key)
102    }
103
104    /// Store a completed run's findings if its generation still holds.
105    /// Returns the stored diagnostics, or `None` when the run went stale.
106    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
123/// Convert findings to LSP diagnostics against the text that was checked,
124/// dropping any that overlap an interactive ERROR — rustc's value is what
125/// the local checkers MISS, not an echo of what they already said.
126fn 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
168// ---------------------------------------------------------------------------
169// The real runner
170// ---------------------------------------------------------------------------
171
172/// Shells out to `cargo check` through
173/// [`logicaffeine_compile::compile::rustc_check`], one persistent cache
174/// directory per workspace (warm incremental runs), single-flight per
175/// workspace so two saves never race one cargo target dir.
176pub 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            // The interactive pipeline already reports parse problems.
236            Err(logicaffeine_compile::compile::CompileError::Parse(_)) => Some(Vec::new()),
237            // Generated code that fails rustc WITHOUT a LOGOS translation is
238            // a compiler bug worth surfacing, not hiding.
239            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}