logicaffeine_language/
error.rs

1//! Error types and display formatting for parse errors.
2//!
3//! This module provides structured error types for the lexer and parser,
4//! with rich diagnostic output including:
5//!
6//! - Source location with line/column numbers
7//! - Syntax-highlighted error messages
8//! - Socratic explanations for common mistakes
9//! - Spelling suggestions for unknown words
10//!
11//! # Error Display
12//!
13//! Errors can be displayed with source context using [`ParseError::display_with_source`],
14//! which produces rustc-style error output with underlined spans.
15
16use logicaffeine_base::Interner;
17use crate::style::Style;
18use crate::suggest::{find_similar, KNOWN_WORDS};
19use crate::token::{Span, TokenType};
20
21/// A parse error with location information.
22#[derive(Debug, Clone)]
23pub struct ParseError {
24    pub kind: ParseErrorKind,
25    pub span: Span,
26}
27
28impl ParseError {
29    pub fn display_with_source(&self, source: &str) -> String {
30        let (line_num, line_start, line_content) = self.find_context(source);
31        let col = self.span.start.saturating_sub(line_start);
32        let len = (self.span.end - self.span.start).max(1);
33        let underline = format!("{}{}", " ".repeat(col), "^".repeat(len));
34
35        let error_label = Style::bold_red("error");
36        let kind_str = format!("{:?}", self.kind);
37        let line_num_str = Style::blue(&format!("{:4}", line_num));
38        let pipe = Style::blue("|");
39        let underline_colored = Style::red(&underline);
40
41        let mut result = format!(
42            "{}: {}\n\n{} {} {}\n     {} {}",
43            error_label, kind_str, line_num_str, pipe, line_content, pipe, underline_colored
44        );
45
46        if let Some(word) = self.extract_word(source) {
47            if let Some(suggestion) = find_similar(&word, KNOWN_WORDS, 2) {
48                let hint = Style::cyan("help");
49                result.push_str(&format!("\n     {} {}: did you mean '{}'?", pipe, hint, Style::green(suggestion)));
50            }
51        }
52
53        result
54    }
55
56    fn extract_word<'a>(&self, source: &'a str) -> Option<&'a str> {
57        if self.span.start < source.len() && self.span.end <= source.len() {
58            let word = &source[self.span.start..self.span.end];
59            if !word.is_empty() && word.chars().all(|c| c.is_alphabetic()) {
60                return Some(word);
61            }
62        }
63        None
64    }
65
66    fn find_context<'a>(&self, source: &'a str) -> (usize, usize, &'a str) {
67        let mut line_num = 1;
68        let mut line_start = 0;
69
70        for (i, c) in source.char_indices() {
71            if i >= self.span.start {
72                break;
73            }
74            if c == '\n' {
75                line_num += 1;
76                line_start = i + 1;
77            }
78        }
79
80        let line_end = source[line_start..]
81            .find('\n')
82            .map(|off| line_start + off)
83            .unwrap_or(source.len());
84
85        (line_num, line_start, &source[line_start..line_end])
86    }
87}
88
89#[derive(Debug, Clone)]
90pub enum ParseErrorKind {
91    UnexpectedToken {
92        expected: TokenType,
93        found: TokenType,
94    },
95    ExpectedContentWord {
96        found: TokenType,
97    },
98    ExpectedCopula,
99    UnknownQuantifier {
100        found: TokenType,
101    },
102    UnknownModal {
103        found: TokenType,
104    },
105    ExpectedVerb {
106        found: TokenType,
107    },
108    ExpectedTemporalAdverb,
109    ExpectedPresuppositionTrigger,
110    ExpectedFocusParticle,
111    ExpectedScopalAdverb,
112    ExpectedSuperlativeAdjective,
113    ExpectedComparativeAdjective,
114    ExpectedThan,
115    ExpectedNumber,
116    EmptyRestriction,
117    GappingResolutionFailed,
118    StativeProgressiveConflict,
119    UndefinedVariable {
120        name: String,
121    },
122    UseAfterMove {
123        name: String,
124    },
125    IsValueEquality {
126        variable: String,
127        value: String,
128    },
129    ZeroIndex,
130    ExpectedStatement,
131    ExpectedKeyword { keyword: String },
132    ExpectedExpression,
133    ExpectedIdentifier,
134    /// Subject and object lists have different lengths in a "respectively" construction.
135    RespectivelyLengthMismatch {
136        subject_count: usize,
137        object_count: usize,
138    },
139    /// Type mismatch during static type checking.
140    TypeMismatch {
141        expected: String,
142        found: String,
143    },
144    /// Invalid refinement predicate in a dependent type.
145    InvalidRefinementPredicate,
146    /// Grammar error (e.g., "its" vs "it's").
147    GrammarError(String),
148    /// DRS scope violation (pronoun trapped in negation, disjunction, etc.).
149    ScopeViolation(String),
150    /// Unresolved pronoun in discourse mode - no accessible antecedent found.
151    UnresolvedPronoun {
152        gender: crate::drs::Gender,
153        number: crate::drs::Number,
154    },
155    /// Custom error message (used for escape analysis, zone errors, etc.).
156    Custom(String),
157}
158
159#[cold]
160pub fn socratic_explanation(error: &ParseError, _interner: &Interner) -> String {
161    let pos = error.span.start;
162    match &error.kind {
163        ParseErrorKind::UnexpectedToken { expected, found } => {
164            format!(
165                "I was following your logic, but I stumbled at position {}. \
166                I expected {:?}, but found {:?}. Perhaps you meant to use a different word here?",
167                pos, expected, found
168            )
169        }
170        ParseErrorKind::ExpectedContentWord { found } => {
171            format!(
172                "I was looking for a noun, verb, or adjective at position {}, \
173                but found {:?} instead. The logic needs a content word to ground it.",
174                pos, found
175            )
176        }
177        ParseErrorKind::ExpectedCopula => {
178            format!(
179                "At position {}, I expected 'is' or 'are' to link the subject and predicate. \
180                Without it, the sentence structure is incomplete.",
181                pos
182            )
183        }
184        ParseErrorKind::UnknownQuantifier { found } => {
185            format!(
186                "At position {}, I found {:?} where I expected a quantifier like 'all', 'some', or 'no'. \
187                These words tell me how many things we're talking about.",
188                pos, found
189            )
190        }
191        ParseErrorKind::UnknownModal { found } => {
192            format!(
193                "At position {}, I found {:?} where I expected a modal like 'must', 'can', or 'should'. \
194                Modals express possibility, necessity, or obligation.",
195                pos, found
196            )
197        }
198        ParseErrorKind::ExpectedVerb { found } => {
199            format!(
200                "At position {}, I expected a verb to describe an action or state, \
201                but found {:?}. Every sentence needs a verb.",
202                pos, found
203            )
204        }
205        ParseErrorKind::ExpectedTemporalAdverb => {
206            format!(
207                "At position {}, I expected a temporal adverb like 'yesterday' or 'tomorrow' \
208                to anchor the sentence in time.",
209                pos
210            )
211        }
212        ParseErrorKind::ExpectedPresuppositionTrigger => {
213            format!(
214                "At position {}, I expected a presupposition trigger like 'stopped', 'realized', or 'regrets'. \
215                These words carry hidden assumptions.",
216                pos
217            )
218        }
219        ParseErrorKind::ExpectedFocusParticle => {
220            format!(
221                "At position {}, I expected a focus particle like 'only', 'even', or 'just'. \
222                These words highlight what's important in the sentence.",
223                pos
224            )
225        }
226        ParseErrorKind::ExpectedScopalAdverb => {
227            format!(
228                "At position {}, I expected a scopal adverb that modifies the entire proposition.",
229                pos
230            )
231        }
232        ParseErrorKind::ExpectedSuperlativeAdjective => {
233            format!(
234                "At position {}, I expected a superlative adjective like 'tallest' or 'fastest'. \
235                These words compare one thing to all others.",
236                pos
237            )
238        }
239        ParseErrorKind::ExpectedComparativeAdjective => {
240            format!(
241                "At position {}, I expected a comparative adjective like 'taller' or 'faster'. \
242                These words compare two things.",
243                pos
244            )
245        }
246        ParseErrorKind::ExpectedThan => {
247            format!(
248                "At position {}, I expected 'than' after the comparative. \
249                Comparisons need 'than' to introduce the thing being compared to.",
250                pos
251            )
252        }
253        ParseErrorKind::ExpectedNumber => {
254            format!(
255                "At position {}, I expected a numeric value like '2', '3.14', or 'aleph_0'. \
256                Measure phrases require a number.",
257                pos
258            )
259        }
260        ParseErrorKind::EmptyRestriction => {
261            format!(
262                "At position {}, the restriction clause is empty. \
263                A relative clause needs content to restrict the noun phrase.",
264                pos
265            )
266        }
267        ParseErrorKind::GappingResolutionFailed => {
268            format!(
269                "At position {}, I see a gapped construction (like '...and Mary, a pear'), \
270                but I couldn't find a verb in the previous clause to borrow. \
271                Gapping requires a clear action to repeat.",
272                pos
273            )
274        }
275        ParseErrorKind::StativeProgressiveConflict => {
276            format!(
277                "At position {}, a stative verb like 'know' or 'love' cannot be used with progressive aspect. \
278                Stative verbs describe states, not activities in progress.",
279                pos
280            )
281        }
282        ParseErrorKind::UndefinedVariable { name } => {
283            format!(
284                "At position {}, I found '{}' but this variable has not been defined. \
285                In imperative mode, all variables must be declared before use.",
286                pos, name
287            )
288        }
289        ParseErrorKind::UseAfterMove { name } => {
290            format!(
291                "At position {}, I found '{}' but this value has been moved. \
292                Once a value is moved, it cannot be used again.",
293                pos, name
294            )
295        }
296        ParseErrorKind::IsValueEquality { variable, value } => {
297            format!(
298                "At position {}, I found '{} is {}' but 'is' is for type/predicate checks. \
299                For value equality, use '{} equals {}'.",
300                pos, variable, value, variable, value
301            )
302        }
303        ParseErrorKind::ZeroIndex => {
304            format!(
305                "At position {}, I found 'item 0' but indices in LOGOS start at 1. \
306                In English, 'the 1st item' is the first item, not the zeroth. \
307                Try 'item 1 of list' to get the first element.",
308                pos
309            )
310        }
311        ParseErrorKind::ExpectedStatement => {
312            format!(
313                "At position {}, I expected a statement like 'Let', 'Set', or 'Return'.",
314                pos
315            )
316        }
317        ParseErrorKind::ExpectedKeyword { keyword } => {
318            format!(
319                "At position {}, I expected the keyword '{}'.",
320                pos, keyword
321            )
322        }
323        ParseErrorKind::ExpectedExpression => {
324            format!(
325                "At position {}, I expected an expression (number, variable, or computation).",
326                pos
327            )
328        }
329        ParseErrorKind::ExpectedIdentifier => {
330            format!(
331                "At position {}, I expected an identifier (variable name).",
332                pos
333            )
334        }
335        ParseErrorKind::RespectivelyLengthMismatch { subject_count, object_count } => {
336            format!(
337                "At position {}, 'respectively' requires equal-length lists. \
338                The subject has {} element(s) and the object has {} element(s). \
339                Each subject must pair with exactly one object.",
340                pos, subject_count, object_count
341            )
342        }
343        ParseErrorKind::TypeMismatch { expected, found } => {
344            format!(
345                "At position {}, I expected a value of type '{}' but found '{}'. \
346                Types must match in LOGOS. Check that your value matches the declared type.",
347                pos, expected, found
348            )
349        }
350        ParseErrorKind::InvalidRefinementPredicate => {
351            format!(
352                "At position {}, the refinement predicate is not valid. \
353                A refinement predicate must be a comparison like 'x > 0' or 'n < 100'.",
354                pos
355            )
356        }
357        ParseErrorKind::GrammarError(msg) => {
358            format!(
359                "At position {}, grammar issue: {}",
360                pos, msg
361            )
362        }
363        ParseErrorKind::ScopeViolation(msg) => {
364            format!(
365                "At position {}, scope violation: {}. The pronoun cannot access a referent \
366                trapped in a different scope (e.g., inside negation or disjunction).",
367                pos, msg
368            )
369        }
370        ParseErrorKind::UnresolvedPronoun { gender, number } => {
371            format!(
372                "At position {}, I found a {:?} {:?} pronoun but couldn't resolve it. \
373                In discourse mode, all pronouns must have an accessible antecedent from earlier sentences. \
374                The referent may be trapped in an inaccessible scope (negation, disjunction) or \
375                there may be no matching referent.",
376                pos, gender, number
377            )
378        }
379        ParseErrorKind::Custom(msg) => msg.clone(),
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::token::Span;
387
388    #[test]
389    fn parse_error_has_span() {
390        let error = ParseError {
391            kind: ParseErrorKind::ExpectedCopula,
392            span: Span::new(5, 10),
393        };
394        assert_eq!(error.span.start, 5);
395        assert_eq!(error.span.end, 10);
396    }
397
398    #[test]
399    fn display_with_source_shows_line_and_underline() {
400        let error = ParseError {
401            kind: ParseErrorKind::ExpectedCopula,
402            span: Span::new(8, 14),
403        };
404        let source = "All men mortal are.";
405        let display = error.display_with_source(source);
406        assert!(display.contains("mortal"), "Should contain source word: {}", display);
407        assert!(display.contains("^^^^^^"), "Should contain underline: {}", display);
408    }
409
410    #[test]
411    fn display_with_source_suggests_typo_fix() {
412        let error = ParseError {
413            kind: ParseErrorKind::ExpectedCopula,
414            span: Span::new(0, 5),
415        };
416        let source = "logoc is the study of reason.";
417        let display = error.display_with_source(source);
418        assert!(display.contains("did you mean"), "Should suggest fix: {}", display);
419        assert!(display.contains("logic"), "Should suggest 'logic': {}", display);
420    }
421
422    #[test]
423    fn display_with_source_has_color_codes() {
424        let error = ParseError {
425            kind: ParseErrorKind::ExpectedCopula,
426            span: Span::new(0, 3),
427        };
428        let source = "Alll men are mortal.";
429        let display = error.display_with_source(source);
430        assert!(display.contains("\x1b["), "Should contain ANSI escape codes: {}", display);
431    }
432}