Skip to main content

logicaffeine_compile/
diagnostic.rs

1//! Diagnostic bridge for translating Rust errors to LOGOS.
2//!
3//! Translates Rust borrow checker errors into friendly LOGOS error messages
4//! using Socratic phrasing. Parses rustc JSON output and maps errors back
5//! to LOGOS source locations using the [`SourceMap`].
6//!
7//! # Supported Error Codes
8//!
9//! | Rust Error | LOGOS Translation |
10//! |------------|-------------------|
11//! | E0382 | "Cannot use 'x' after giving it away" |
12//! | E0505 | "Cannot borrow 'x' while it's borrowed elsewhere" |
13//! | E0597 | "Reference 'x' cannot escape zone" |
14//!
15//! # Translation Flow
16//!
17//! ```text
18//! rustc --error-format=json
19//!           │
20//!           ▼
21//! ┌─────────────────────┐
22//! │ parse_rustc_json()  │ Parse JSON diagnostics
23//! └──────────┬──────────┘
24//!            ▼
25//! ┌─────────────────────┐
26//! │ translate_diagnostics│ Map to LOGOS source
27//! └──────────┬──────────┘
28//!            ▼
29//!    LogosError with friendly message
30
31use crate::intern::Interner;
32use crate::sourcemap::{OwnershipRole, SourceMap};
33use crate::style::Style;
34use crate::token::Span;
35use serde::Deserialize;
36
37/// A translated error message for LOGOS users.
38#[derive(Debug, Clone)]
39pub struct LogosError {
40    pub title: String,
41    pub explanation: String,
42    pub logos_span: Option<Span>,
43    pub suggestion: Option<String>,
44}
45
46impl std::fmt::Display for LogosError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        writeln!(f, "{}: {}", Style::bold_red("ownership error"), self.title)?;
49        writeln!(f)?;
50        writeln!(f, "{}", self.explanation)?;
51        if let Some(suggestion) = &self.suggestion {
52            writeln!(f)?;
53            writeln!(f, "{}: {}", Style::cyan("suggestion"), suggestion)?;
54        }
55        Ok(())
56    }
57}
58
59// =============================================================================
60// Rustc JSON Diagnostic Types
61// =============================================================================
62
63/// Rustc JSON diagnostic message (subset of fields we need).
64#[derive(Debug, Deserialize)]
65pub struct RustcDiagnostic {
66    pub message: String,
67    pub code: Option<RustcCode>,
68    pub level: String,
69    pub spans: Vec<RustcSpan>,
70    #[serde(default)]
71    pub children: Vec<RustcDiagnostic>,
72}
73
74/// Error code from rustc (e.g., "E0382").
75#[derive(Debug, Deserialize)]
76pub struct RustcCode {
77    /// The error code string (e.g., "E0382" for use-after-move).
78    pub code: String,
79}
80
81/// Source location information from a rustc diagnostic.
82///
83/// Describes where in the generated Rust source an error occurred,
84/// which is then mapped back to LOGOS source using the [`SourceMap`].
85#[derive(Debug, Deserialize)]
86pub struct RustcSpan {
87    /// Path to the file containing the error.
88    pub file_name: String,
89    /// Starting line number (1-based).
90    pub line_start: u32,
91    /// Ending line number (1-based).
92    pub line_end: u32,
93    /// Starting column number (1-based).
94    pub column_start: u32,
95    /// Ending column number (1-based).
96    pub column_end: u32,
97    /// Whether this is the primary error location.
98    pub is_primary: bool,
99    /// Optional diagnostic label for this span.
100    pub label: Option<String>,
101    /// Source text lines with highlighting information.
102    #[serde(default)]
103    pub text: Vec<RustcSpanText>,
104}
105
106/// Source text with highlight range from a rustc diagnostic.
107#[derive(Debug, Deserialize)]
108pub struct RustcSpanText {
109    /// The actual source text line.
110    pub text: String,
111    /// Column where highlighting starts (1-based).
112    pub highlight_start: u32,
113    /// Column where highlighting ends (1-based).
114    pub highlight_end: u32,
115}
116
117/// Parsed rustc output: either a diagnostic or artifact info.
118#[derive(Debug, Deserialize)]
119#[serde(tag = "reason")]
120#[serde(rename_all = "kebab-case")]
121pub enum RustcMessage {
122    CompilerMessage { message: RustcDiagnostic },
123    #[serde(other)]
124    Other,
125}
126
127// =============================================================================
128// JSON Parsing
129// =============================================================================
130
131/// Parse rustc stderr output from `cargo build --message-format=json`.
132pub fn parse_rustc_json(stderr: &str) -> Vec<RustcDiagnostic> {
133    let mut diagnostics = Vec::new();
134
135    for line in stderr.lines() {
136        // Skip empty lines and non-JSON output
137        if !line.starts_with('{') {
138            continue;
139        }
140
141        match serde_json::from_str::<RustcMessage>(line) {
142            Ok(RustcMessage::CompilerMessage { message }) => {
143                if message.level == "error" {
144                    diagnostics.push(message);
145                }
146            }
147            Ok(RustcMessage::Other) => {} // Ignore artifacts, build-finished, etc.
148            Err(_) => {} // Ignore malformed lines
149        }
150    }
151
152    diagnostics
153}
154
155/// Extracts the error code (e.g., "E0382") from a diagnostic.
156///
157/// Returns `None` if the diagnostic has no associated error code.
158pub fn get_error_code(diag: &RustcDiagnostic) -> Option<&str> {
159    diag.code.as_ref().map(|c| c.code.as_str())
160}
161
162/// Extracts the primary source span from a diagnostic.
163///
164/// Diagnostics may have multiple spans; this returns the one marked
165/// as primary (the main error location).
166pub fn get_primary_span(diag: &RustcDiagnostic) -> Option<&RustcSpan> {
167    diag.spans.iter().find(|s| s.is_primary)
168}
169
170/// Extract variable name from rustc error message.
171/// Example: "use of moved value: `data`" -> "data"
172fn extract_var_from_message(message: &str, prefix: &str, suffix: &str) -> Option<String> {
173    let start = message.find(prefix)?;
174    let after_prefix = &message[start + prefix.len()..];
175    let end = after_prefix.find(suffix)?;
176    Some(after_prefix[..end].to_string())
177}
178
179// =============================================================================
180// Diagnostic Bridge
181// =============================================================================
182
183/// Translates rustc diagnostics into user-friendly LOGOS error messages.
184///
185/// Uses the source map to map Rust source locations back to LOGOS source,
186/// and applies Socratic phrasing to explain ownership errors in terms of
187/// LOGOS semantics (Give, Show, Zone).
188pub struct DiagnosticBridge<'a> {
189    /// Source map for translating Rust locations to LOGOS locations.
190    source_map: &'a SourceMap,
191    /// Interner for resolving symbol names.
192    interner: &'a Interner,
193}
194
195impl<'a> DiagnosticBridge<'a> {
196    pub fn new(source_map: &'a SourceMap, interner: &'a Interner) -> Self {
197        Self { source_map, interner }
198    }
199
200    /// Translate a rustc diagnostic into a LOGOS error.
201    pub fn translate(&self, diag: &RustcDiagnostic) -> Option<LogosError> {
202        let code = get_error_code(diag)?;
203        let span = get_primary_span(diag);
204
205        match code {
206            "E0382" => self.translate_use_after_move(diag, span),
207            "E0505" => self.translate_move_while_borrowed(diag, span),
208            "E0597" => self.translate_lifetime_error(diag, span),
209            _ => self.translate_generic(diag, span),
210        }
211    }
212
213    /// E0382: "use of moved value: `x`"
214    /// LOGOS: "You already gave X away - you can't use it anymore"
215    fn translate_use_after_move(&self, diag: &RustcDiagnostic, span: Option<&RustcSpan>) -> Option<LogosError> {
216        let var_name = extract_var_from_message(&diag.message, "value: `", "`")
217            .or_else(|| extract_var_from_message(&diag.message, "value `", "`"))?;
218
219        let logos_span = span.and_then(|s| self.source_map.find_nearest_span(s.line_start));
220
221        // Look up variable origin if available
222        let (logos_name, role) = if let Some(origin) = self.source_map.get_var_origin(&var_name) {
223            (self.interner.resolve(origin.logos_name).to_string(), Some(origin.role))
224        } else {
225            (var_name.clone(), None)
226        };
227
228        let explanation = match role {
229            Some(OwnershipRole::GiveObject) => format!(
230                "You gave '{}' away with a Give statement, so you can't use it anymore.\n\
231                In LOGOS, 'Give X to Y' transfers ownership - X moves to Y and leaves your hands.\n\
232                This is like handing someone a physical object: once given, you no longer have it.",
233                logos_name
234            ),
235            Some(OwnershipRole::LetBinding) | None => format!(
236                "The value '{}' was moved somewhere else and can't be used again.\n\
237                Check if you used 'Give' or passed it to a function that took ownership.",
238                logos_name
239            ),
240            _ => format!(
241                "The value '{}' has been moved and is no longer available.",
242                logos_name
243            ),
244        };
245
246        let suggestion = Some(format!(
247            "If you need to use '{}' after giving it away, either:\n\
248             1. Use 'Show {} to Y' instead (this borrows, keeping ownership)\n\
249             2. Use 'a copy of {}' before the Give",
250            logos_name, logos_name, logos_name
251        ));
252
253        Some(LogosError {
254            title: format!("Cannot use '{}' after giving it away", logos_name),
255            explanation,
256            logos_span,
257            suggestion,
258        })
259    }
260
261    /// E0505: "cannot move out of `x` because it is borrowed"
262    /// LOGOS: "You're trying to give X away while someone is still looking at it"
263    fn translate_move_while_borrowed(&self, diag: &RustcDiagnostic, span: Option<&RustcSpan>) -> Option<LogosError> {
264        let var_name = extract_var_from_message(&diag.message, "out of `", "`")
265            .or_else(|| extract_var_from_message(&diag.message, "move out of `", "`"))?;
266
267        let logos_span = span.and_then(|s| self.source_map.find_nearest_span(s.line_start));
268
269        let logos_name = if let Some(origin) = self.source_map.get_var_origin(&var_name) {
270            self.interner.resolve(origin.logos_name).to_string()
271        } else {
272            var_name.clone()
273        };
274
275        let explanation = format!(
276            "You showed '{}' to someone (creating a temporary view),\n\
277            but then tried to give it away before they finished looking.\n\
278            In LOGOS, 'Show' creates a promise that the data won't change or disappear\n\
279            while being viewed. You can't break that promise by giving it away.",
280            logos_name
281        );
282
283        let suggestion = Some(format!(
284            "Make sure all 'Show' usages of '{}' complete before any 'Give'.\n\
285            Alternatively, give away a copy: 'Give a copy of {} to Y'",
286            logos_name, logos_name
287        ));
288
289        Some(LogosError {
290            title: format!("Cannot give '{}' while it's being shown", logos_name),
291            explanation,
292            logos_span,
293            suggestion,
294        })
295    }
296
297    /// E0597: "borrowed value does not live long enough"
298    /// LOGOS: "You can't take a reference outside its zone" (Hotel California)
299    fn translate_lifetime_error(&self, diag: &RustcDiagnostic, span: Option<&RustcSpan>) -> Option<LogosError> {
300        let logos_span = span.and_then(|s| self.source_map.find_nearest_span(s.line_start));
301
302        // Check if this is zone-related by looking at the message and children
303        let is_zone_related = diag.message.contains("borrowed")
304            || diag.children.iter().any(|c| c.message.contains("dropped"));
305
306        let explanation = if is_zone_related {
307            "A value created inside a Zone cannot be referenced from outside.\n\
308            Zones are memory arenas - when the Zone ends, everything inside it is released.\n\
309            This is the 'Hotel California' rule: data can check in (be created),\n\
310            but references can't check out (escape the Zone).".to_string()
311        } else {
312            "A borrowed reference is being used after the original value has gone away.\n\
313            References are temporary views - they can't outlive what they're viewing.".to_string()
314        };
315
316        let suggestion = Some(
317            "If you need the data after the Zone ends, either:\n\
318             1. Move the data out with 'Give' before the Zone closes\n\
319             2. Copy the data: 'Let result be a copy of zone_data'\n\
320             3. Restructure so the computation completes inside the Zone".to_string()
321        );
322
323        Some(LogosError {
324            title: "Reference cannot outlive its data".to_string(),
325            explanation,
326            logos_span,
327            suggestion,
328        })
329    }
330
331    /// Fallback for other errors - provide the raw message with context.
332    fn translate_generic(&self, diag: &RustcDiagnostic, span: Option<&RustcSpan>) -> Option<LogosError> {
333        let logos_span = span.and_then(|s| self.source_map.find_nearest_span(s.line_start));
334
335        // Try to extract any variable name
336        let var_hint = if let Some(start) = diag.message.find('`') {
337            if let Some(end) = diag.message[start + 1..].find('`') {
338                Some(&diag.message[start + 1..start + 1 + end])
339            } else {
340                None
341            }
342        } else {
343            None
344        };
345
346        let explanation = if let Some(var) = var_hint {
347            format!(
348                "The Rust compiler reported an error involving '{}':\n{}",
349                var, diag.message
350            )
351        } else {
352            format!("The Rust compiler reported an error:\n{}", diag.message)
353        };
354
355        Some(LogosError {
356            title: "Compilation error".to_string(),
357            explanation,
358            logos_span,
359            suggestion: None,
360        })
361    }
362}
363
364/// Translates rustc diagnostics to LOGOS errors.
365///
366/// Returns the first successfully translated error — the CLI's
367/// one-error-at-a-time contract. IDE surfaces want
368/// [`translate_diagnostics_all`].
369pub fn translate_diagnostics(
370    diagnostics: &[RustcDiagnostic],
371    source_map: &SourceMap,
372    interner: &Interner,
373) -> Option<LogosError> {
374    translate_diagnostics_all(diagnostics, source_map, interner)
375        .into_iter()
376        .next()
377}
378
379/// Translates EVERY rustc diagnostic that has a LOGOS reading.
380///
381/// Diagnostics the bridge cannot translate (rustc internals with no LOGOS
382/// counterpart) are skipped, not fabricated.
383pub fn translate_diagnostics_all(
384    diagnostics: &[RustcDiagnostic],
385    source_map: &SourceMap,
386    interner: &Interner,
387) -> Vec<LogosError> {
388    let bridge = DiagnosticBridge::new(source_map, interner);
389    diagnostics
390        .iter()
391        .filter_map(|diag| bridge.translate(diag))
392        .collect()
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn parse_rustc_json_extracts_errors() {
401        let json_output = r#"{"reason":"compiler-message","message":{"message":"use of moved value: `x`","code":{"code":"E0382"},"level":"error","spans":[{"file_name":"src/main.rs","line_start":5,"line_end":5,"column_start":10,"column_end":11,"is_primary":true,"label":null,"text":[]}],"children":[]}}
402{"reason":"build-finished","success":false}"#;
403
404        let diagnostics = parse_rustc_json(json_output);
405        assert_eq!(diagnostics.len(), 1);
406        assert_eq!(diagnostics[0].message, "use of moved value: `x`");
407        assert_eq!(get_error_code(&diagnostics[0]), Some("E0382"));
408    }
409
410    #[test]
411    fn extract_var_from_message_works() {
412        assert_eq!(
413            extract_var_from_message("use of moved value: `data`", "value: `", "`"),
414            Some("data".to_string())
415        );
416        assert_eq!(
417            extract_var_from_message("cannot move out of `x` because", "out of `", "`"),
418            Some("x".to_string())
419        );
420    }
421
422    #[test]
423    fn translate_e0382_creates_friendly_error() {
424        let interner = Interner::new();
425        let source_map = SourceMap::new("Let data be 5.\nGive data to processor.".to_string());
426
427        let diag = RustcDiagnostic {
428            message: "use of moved value: `data`".to_string(),
429            code: Some(RustcCode { code: "E0382".to_string() }),
430            level: "error".to_string(),
431            spans: vec![RustcSpan {
432                file_name: "src/main.rs".to_string(),
433                line_start: 3,
434                line_end: 3,
435                column_start: 10,
436                column_end: 14,
437                is_primary: true,
438                label: None,
439                text: vec![],
440            }],
441            children: vec![],
442        };
443
444        let bridge = DiagnosticBridge::new(&source_map, &interner);
445        let error = bridge.translate(&diag).expect("Should translate");
446
447        assert!(error.title.contains("data"));
448        assert!(error.title.contains("giving it away"));
449        assert!(error.explanation.contains("moved"));
450        assert!(error.suggestion.is_some());
451    }
452
453    #[test]
454    fn translate_e0597_creates_hotel_california_error() {
455        let interner = Interner::new();
456        let source_map = SourceMap::new("Inside a zone:\n    Let x be 5.".to_string());
457
458        let diag = RustcDiagnostic {
459            message: "borrowed value does not live long enough".to_string(),
460            code: Some(RustcCode { code: "E0597".to_string() }),
461            level: "error".to_string(),
462            spans: vec![RustcSpan {
463                file_name: "src/main.rs".to_string(),
464                line_start: 5,
465                line_end: 5,
466                column_start: 1,
467                column_end: 10,
468                is_primary: true,
469                label: None,
470                text: vec![],
471            }],
472            children: vec![],
473        };
474
475        let bridge = DiagnosticBridge::new(&source_map, &interner);
476        let error = bridge.translate(&diag).expect("Should translate");
477
478        assert!(error.title.contains("outlive"));
479        assert!(error.explanation.contains("Zone") || error.explanation.contains("borrowed"));
480    }
481}