1use logicaffeine_base::Interner;
17use crate::style::Style;
18use crate::suggest::{find_similar, KNOWN_WORDS};
19use crate::token::{Span, TokenType};
20
21#[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 RespectivelyLengthMismatch {
136 subject_count: usize,
137 object_count: usize,
138 },
139 TypeMismatch {
141 expected: String,
142 found: String,
143 },
144 InvalidRefinementPredicate,
146 GrammarError(String),
148 ScopeViolation(String),
150 UnresolvedPronoun {
152 gender: crate::drs::Gender,
153 number: crate::drs::Number,
154 },
155 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}