Skip to main content

logicaffeine_language/
diagnostic.rs

1//! The canonical machine-readable diagnostic surface: `logos-diagnostic/1`.
2//!
3//! One [`Diagnostic`] JSON shape, produced here and consumed identically by
4//! `largo check --json`, `largo run --json`, the MCP server, and (for code and
5//! severity identity) the language server. Every [`ParseErrorKind`] variant
6//! decides its stable kebab-case [`code`](ParseErrorKind::code), its
7//! [`severity`](ParseErrorKind::severity), and a one-line
8//! [`headline`](ParseErrorKind::headline) in wildcard-free matches — a new
9//! error kind does not compile until it has all three.
10//!
11//! The [`catalog`] enumerates one rendered sample per variant and is the
12//! single source for the generated error catalog and `largo explain`.
13
14use serde::{Deserialize, Serialize};
15
16use logicaffeine_base::Interner;
17
18use crate::error::{
19    describe_token, pronoun_word, socratic_explanation, ParseError, ParseErrorKind,
20};
21
22/// How severely a diagnostic should be presented.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum DiagSeverity {
25    Error,
26    Warning,
27    Info,
28}
29
30impl DiagSeverity {
31    /// The wire spelling used in `logos-diagnostic/1` payloads.
32    pub fn as_str(self) -> &'static str {
33        match self {
34            DiagSeverity::Error => "error",
35            DiagSeverity::Warning => "warning",
36            DiagSeverity::Info => "info",
37        }
38    }
39}
40
41/// A source span in both byte offsets and 1-based, char-counted line/column.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct DiagSpan {
44    pub start: usize,
45    pub end: usize,
46    pub start_line: usize,
47    pub start_col: usize,
48    pub end_line: usize,
49    pub end_col: usize,
50}
51
52/// A secondary location attached to a diagnostic.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct RelatedInfo {
55    pub file: Option<String>,
56    pub span: DiagSpan,
57    pub message: String,
58}
59
60/// One `logos-diagnostic/1` object.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct Diagnostic {
63    /// Stable kebab-case identity, e.g. `"undefined-variable"`.
64    pub code: String,
65    /// `"error"` | `"warning"` | `"info"`.
66    pub severity: String,
67    /// The file the caller attributes the source to; `None` for snippets.
68    pub file: Option<String>,
69    pub span: DiagSpan,
70    /// One-line headline.
71    pub message: String,
72    /// The full Socratic explanation prose.
73    pub explanation: String,
74    /// "did you mean '…'?" when a close known word exists.
75    pub suggestion: Option<String>,
76    /// The mechanical fix note, when one is safe.
77    pub quickfix: Option<String>,
78    /// A quickguide URL, when a lesson teaches this construct.
79    pub docs: Option<String>,
80    pub related: Vec<RelatedInfo>,
81}
82
83/// One rendered sample per [`ParseErrorKind`] variant — the source of the
84/// generated error catalog and `largo explain <code>`.
85#[derive(Debug, Clone, PartialEq)]
86pub struct CatalogEntry {
87    /// The enum variant name, e.g. `"UnexpectedToken"`.
88    pub variant: &'static str,
89    pub code: &'static str,
90    pub severity: &'static str,
91    pub headline: String,
92    pub explanation: String,
93    pub quickfix: Option<&'static str>,
94    pub docs: Option<String>,
95}
96
97impl ParseErrorKind {
98    /// The stable kebab-case diagnostic code.
99    ///
100    /// Codes are the kebab-cased variant name, with the collapses the language
101    /// server already shipped kept intact: both type-mismatch variants share
102    /// `"type-mismatch"`.
103    pub fn code(&self) -> &'static str {
104        match self {
105            ParseErrorKind::UnexpectedToken { .. } => "unexpected-token",
106            ParseErrorKind::ExpectedContentWord { .. } => "expected-content-word",
107            ParseErrorKind::ExpectedCopula => "expected-copula",
108            ParseErrorKind::UnknownQuantifier { .. } => "unknown-quantifier",
109            ParseErrorKind::UnknownModal { .. } => "unknown-modal",
110            ParseErrorKind::ExpectedVerb { .. } => "expected-verb",
111            ParseErrorKind::ExpectedTemporalAdverb => "expected-temporal-adverb",
112            ParseErrorKind::ExpectedPresuppositionTrigger => "expected-presupposition-trigger",
113            ParseErrorKind::ExpectedFocusParticle => "expected-focus-particle",
114            ParseErrorKind::ExpectedScopalAdverb => "expected-scopal-adverb",
115            ParseErrorKind::ExpectedSuperlativeAdjective => "expected-superlative-adjective",
116            ParseErrorKind::ExpectedComparativeAdjective => "expected-comparative-adjective",
117            ParseErrorKind::ExpectedThan => "expected-than",
118            ParseErrorKind::ExpectedNumber => "expected-number",
119            ParseErrorKind::EmptyRestriction => "empty-restriction",
120            ParseErrorKind::GappingResolutionFailed => "gapping-resolution-failed",
121            ParseErrorKind::StativeProgressiveConflict => "stative-progressive-conflict",
122            ParseErrorKind::UndefinedVariable { .. } => "undefined-variable",
123            ParseErrorKind::UseAfterMove { .. } => "use-after-move",
124            ParseErrorKind::IsValueEquality { .. } => "is-value-equality",
125            ParseErrorKind::ZeroIndex => "zero-index",
126            ParseErrorKind::ExpectedStatement => "expected-statement",
127            ParseErrorKind::ExpectedKeyword { .. } => "expected-keyword",
128            ParseErrorKind::ExpectedExpression => "expected-expression",
129            ParseErrorKind::ExpectedIdentifier => "expected-identifier",
130            ParseErrorKind::RespectivelyLengthMismatch { .. } => "respectively-length-mismatch",
131            ParseErrorKind::TypeMismatch { .. } | ParseErrorKind::TypeMismatchDetailed { .. } => {
132                "type-mismatch"
133            }
134            ParseErrorKind::InfiniteType { .. } => "infinite-type",
135            ParseErrorKind::ArityMismatch { .. } => "arity-mismatch",
136            ParseErrorKind::FieldNotFound { .. } => "field-not-found",
137            ParseErrorKind::NotAFunction { .. } => "not-a-function",
138            ParseErrorKind::InvalidRefinementPredicate => "invalid-refinement-predicate",
139            ParseErrorKind::GrammarError(_) => "grammar-error",
140            ParseErrorKind::ScopeViolation(_) => "scope-violation",
141            ParseErrorKind::UnresolvedPronoun { .. } => "unresolved-pronoun",
142            ParseErrorKind::TrailingTokens { .. } => "trailing-tokens",
143            ParseErrorKind::AstTooDeep { .. } => "ast-too-deep",
144            ParseErrorKind::Custom(_) => "custom",
145        }
146    }
147
148    /// The presentation severity, agreeing with the LSP decision table.
149    pub fn severity(&self) -> DiagSeverity {
150        match self {
151            // Style: valid programs that read wrong.
152            ParseErrorKind::IsValueEquality { .. } | ParseErrorKind::GrammarError(_) => {
153                DiagSeverity::Warning
154            }
155            // Idiom: 1-based indexing is a convention, not a failure.
156            ParseErrorKind::ZeroIndex => DiagSeverity::Info,
157            ParseErrorKind::UnexpectedToken { .. }
158            | ParseErrorKind::ExpectedContentWord { .. }
159            | ParseErrorKind::ExpectedCopula
160            | ParseErrorKind::UnknownQuantifier { .. }
161            | ParseErrorKind::UnknownModal { .. }
162            | ParseErrorKind::ExpectedVerb { .. }
163            | ParseErrorKind::ExpectedTemporalAdverb
164            | ParseErrorKind::ExpectedPresuppositionTrigger
165            | ParseErrorKind::ExpectedFocusParticle
166            | ParseErrorKind::ExpectedScopalAdverb
167            | ParseErrorKind::ExpectedSuperlativeAdjective
168            | ParseErrorKind::ExpectedComparativeAdjective
169            | ParseErrorKind::ExpectedThan
170            | ParseErrorKind::ExpectedNumber
171            | ParseErrorKind::EmptyRestriction
172            | ParseErrorKind::GappingResolutionFailed
173            | ParseErrorKind::StativeProgressiveConflict
174            | ParseErrorKind::UndefinedVariable { .. }
175            | ParseErrorKind::UseAfterMove { .. }
176            | ParseErrorKind::ExpectedStatement
177            | ParseErrorKind::ExpectedKeyword { .. }
178            | ParseErrorKind::ExpectedExpression
179            | ParseErrorKind::ExpectedIdentifier
180            | ParseErrorKind::RespectivelyLengthMismatch { .. }
181            | ParseErrorKind::TypeMismatch { .. }
182            | ParseErrorKind::TypeMismatchDetailed { .. }
183            | ParseErrorKind::InfiniteType { .. }
184            | ParseErrorKind::ArityMismatch { .. }
185            | ParseErrorKind::FieldNotFound { .. }
186            | ParseErrorKind::NotAFunction { .. }
187            | ParseErrorKind::InvalidRefinementPredicate
188            | ParseErrorKind::ScopeViolation(_)
189            | ParseErrorKind::UnresolvedPronoun { .. }
190            | ParseErrorKind::TrailingTokens { .. }
191            | ParseErrorKind::AstTooDeep { .. }
192            | ParseErrorKind::Custom(_) => DiagSeverity::Error,
193        }
194    }
195
196    /// A one-line headline naming what went wrong, without the Socratic prose.
197    pub fn headline(&self) -> String {
198        match self {
199            ParseErrorKind::UnexpectedToken { expected, found } => format!(
200                "expected {}, found {}",
201                describe_token(expected),
202                describe_token(found)
203            ),
204            ParseErrorKind::ExpectedContentWord { found } => {
205                format!("expected a content word, found {}", describe_token(found))
206            }
207            ParseErrorKind::ExpectedCopula => "expected 'is' or 'are'".to_string(),
208            ParseErrorKind::UnknownQuantifier { found } => {
209                format!("expected a quantifier, found {}", describe_token(found))
210            }
211            ParseErrorKind::UnknownModal { found } => {
212                format!("expected a modal, found {}", describe_token(found))
213            }
214            ParseErrorKind::ExpectedVerb { found } => {
215                format!("expected a verb, found {}", describe_token(found))
216            }
217            ParseErrorKind::ExpectedTemporalAdverb => "expected a temporal adverb".to_string(),
218            ParseErrorKind::ExpectedPresuppositionTrigger => {
219                "expected a presupposition trigger".to_string()
220            }
221            ParseErrorKind::ExpectedFocusParticle => "expected a focus particle".to_string(),
222            ParseErrorKind::ExpectedScopalAdverb => "expected a scopal adverb".to_string(),
223            ParseErrorKind::ExpectedSuperlativeAdjective => {
224                "expected a superlative adjective".to_string()
225            }
226            ParseErrorKind::ExpectedComparativeAdjective => {
227                "expected a comparative adjective".to_string()
228            }
229            ParseErrorKind::ExpectedThan => "expected 'than' to complete the comparison".to_string(),
230            ParseErrorKind::ExpectedNumber => "expected a number".to_string(),
231            ParseErrorKind::EmptyRestriction => "empty relative clause".to_string(),
232            ParseErrorKind::GappingResolutionFailed => {
233                "no verb to borrow for the gapped clause".to_string()
234            }
235            ParseErrorKind::StativeProgressiveConflict => {
236                "stative verb used in the progressive".to_string()
237            }
238            ParseErrorKind::UndefinedVariable { name } => format!("'{name}' is not declared"),
239            ParseErrorKind::UseAfterMove { name } => {
240                format!("cannot use '{name}' after giving it away")
241            }
242            ParseErrorKind::IsValueEquality { variable, value } => {
243                format!("'{variable} is {value}' compares values; use 'equals'")
244            }
245            ParseErrorKind::ZeroIndex => "indices start at 1; there is no item 0".to_string(),
246            ParseErrorKind::ExpectedStatement => "expected a statement".to_string(),
247            ParseErrorKind::ExpectedKeyword { keyword } => {
248                format!("expected the word '{keyword}'")
249            }
250            ParseErrorKind::ExpectedExpression => "expected an expression".to_string(),
251            ParseErrorKind::ExpectedIdentifier => "expected a name".to_string(),
252            ParseErrorKind::RespectivelyLengthMismatch { subject_count, object_count } => format!(
253                "'respectively' lists differ: {subject_count} subject(s) vs {object_count} object(s)"
254            ),
255            ParseErrorKind::TypeMismatch { expected, found } => {
256                format!("expected '{expected}', found '{found}'")
257            }
258            ParseErrorKind::TypeMismatchDetailed { expected, found, context } => {
259                if context.is_empty() {
260                    format!("expected '{expected}', found '{found}'")
261                } else {
262                    format!("expected '{expected}', found '{found}' ({context})")
263                }
264            }
265            ParseErrorKind::InfiniteType { var_description, .. } => {
266                format!("infinite type: {var_description} would contain itself")
267            }
268            ParseErrorKind::ArityMismatch { function, expected, found } => {
269                format!("'{function}' takes {expected} argument(s), but this call passes {found}")
270            }
271            ParseErrorKind::FieldNotFound { type_name, field_name, .. } => {
272                format!("'{type_name}' has no field named '{field_name}'")
273            }
274            ParseErrorKind::NotAFunction { found_type } => {
275                format!("cannot call a value of type '{found_type}'")
276            }
277            ParseErrorKind::InvalidRefinementPredicate => {
278                "refinement predicate is not checkable".to_string()
279            }
280            ParseErrorKind::GrammarError(msg) => format!("grammar: {msg}"),
281            ParseErrorKind::ScopeViolation(msg) => format!("scope: {msg}"),
282            ParseErrorKind::UnresolvedPronoun { gender, number } => {
283                format!("pronoun {} has no antecedent", pronoun_word(gender, number))
284            }
285            ParseErrorKind::TrailingTokens { found } => {
286                format!("input left over, beginning with {}", describe_token(found))
287            }
288            ParseErrorKind::AstTooDeep { depth, max_depth } => {
289                format!("AST nests {depth} levels deep; the limit is {max_depth}")
290            }
291            ParseErrorKind::Custom(msg) => msg.clone(),
292        }
293    }
294
295    /// The variant's name, for catalog keys and golden stanza headers.
296    pub fn variant_name(&self) -> &'static str {
297        match self {
298            ParseErrorKind::UnexpectedToken { .. } => "UnexpectedToken",
299            ParseErrorKind::ExpectedContentWord { .. } => "ExpectedContentWord",
300            ParseErrorKind::ExpectedCopula => "ExpectedCopula",
301            ParseErrorKind::UnknownQuantifier { .. } => "UnknownQuantifier",
302            ParseErrorKind::UnknownModal { .. } => "UnknownModal",
303            ParseErrorKind::ExpectedVerb { .. } => "ExpectedVerb",
304            ParseErrorKind::ExpectedTemporalAdverb => "ExpectedTemporalAdverb",
305            ParseErrorKind::ExpectedPresuppositionTrigger => "ExpectedPresuppositionTrigger",
306            ParseErrorKind::ExpectedFocusParticle => "ExpectedFocusParticle",
307            ParseErrorKind::ExpectedScopalAdverb => "ExpectedScopalAdverb",
308            ParseErrorKind::ExpectedSuperlativeAdjective => "ExpectedSuperlativeAdjective",
309            ParseErrorKind::ExpectedComparativeAdjective => "ExpectedComparativeAdjective",
310            ParseErrorKind::ExpectedThan => "ExpectedThan",
311            ParseErrorKind::ExpectedNumber => "ExpectedNumber",
312            ParseErrorKind::EmptyRestriction => "EmptyRestriction",
313            ParseErrorKind::GappingResolutionFailed => "GappingResolutionFailed",
314            ParseErrorKind::StativeProgressiveConflict => "StativeProgressiveConflict",
315            ParseErrorKind::UndefinedVariable { .. } => "UndefinedVariable",
316            ParseErrorKind::UseAfterMove { .. } => "UseAfterMove",
317            ParseErrorKind::IsValueEquality { .. } => "IsValueEquality",
318            ParseErrorKind::ZeroIndex => "ZeroIndex",
319            ParseErrorKind::ExpectedStatement => "ExpectedStatement",
320            ParseErrorKind::ExpectedKeyword { .. } => "ExpectedKeyword",
321            ParseErrorKind::ExpectedExpression => "ExpectedExpression",
322            ParseErrorKind::ExpectedIdentifier => "ExpectedIdentifier",
323            ParseErrorKind::RespectivelyLengthMismatch { .. } => "RespectivelyLengthMismatch",
324            ParseErrorKind::TypeMismatch { .. } => "TypeMismatch",
325            ParseErrorKind::TypeMismatchDetailed { .. } => "TypeMismatchDetailed",
326            ParseErrorKind::InfiniteType { .. } => "InfiniteType",
327            ParseErrorKind::ArityMismatch { .. } => "ArityMismatch",
328            ParseErrorKind::FieldNotFound { .. } => "FieldNotFound",
329            ParseErrorKind::NotAFunction { .. } => "NotAFunction",
330            ParseErrorKind::InvalidRefinementPredicate => "InvalidRefinementPredicate",
331            ParseErrorKind::GrammarError(_) => "GrammarError",
332            ParseErrorKind::ScopeViolation(_) => "ScopeViolation",
333            ParseErrorKind::UnresolvedPronoun { .. } => "UnresolvedPronoun",
334            ParseErrorKind::TrailingTokens { .. } => "TrailingTokens",
335            ParseErrorKind::AstTooDeep { .. } => "AstTooDeep",
336            ParseErrorKind::Custom(_) => "Custom",
337        }
338    }
339
340    /// The mechanical fix note the LSP offers, when one is safe.
341    pub fn quickfix_note(&self) -> Option<&'static str> {
342        match self {
343            ParseErrorKind::IsValueEquality { .. } => Some("Use 'equals' for value comparison"),
344            ParseErrorKind::ZeroIndex => Some("Use 1-based indexing"),
345            ParseErrorKind::UseAfterMove { .. } => Some("Use 'a copy of …' instead"),
346            ParseErrorKind::UndefinedVariable { .. } => {
347                Some("Did you mean '<nearest definition>'?")
348            }
349            ParseErrorKind::UnexpectedToken { .. }
350            | ParseErrorKind::ExpectedContentWord { .. }
351            | ParseErrorKind::ExpectedCopula
352            | ParseErrorKind::UnknownQuantifier { .. }
353            | ParseErrorKind::UnknownModal { .. }
354            | ParseErrorKind::ExpectedVerb { .. }
355            | ParseErrorKind::ExpectedTemporalAdverb
356            | ParseErrorKind::ExpectedPresuppositionTrigger
357            | ParseErrorKind::ExpectedFocusParticle
358            | ParseErrorKind::ExpectedScopalAdverb
359            | ParseErrorKind::ExpectedSuperlativeAdjective
360            | ParseErrorKind::ExpectedComparativeAdjective
361            | ParseErrorKind::ExpectedThan
362            | ParseErrorKind::ExpectedNumber
363            | ParseErrorKind::EmptyRestriction
364            | ParseErrorKind::GappingResolutionFailed
365            | ParseErrorKind::StativeProgressiveConflict
366            | ParseErrorKind::ExpectedStatement
367            | ParseErrorKind::ExpectedKeyword { .. }
368            | ParseErrorKind::ExpectedExpression
369            | ParseErrorKind::ExpectedIdentifier
370            | ParseErrorKind::RespectivelyLengthMismatch { .. }
371            | ParseErrorKind::TypeMismatch { .. }
372            | ParseErrorKind::TypeMismatchDetailed { .. }
373            | ParseErrorKind::InfiniteType { .. }
374            | ParseErrorKind::ArityMismatch { .. }
375            | ParseErrorKind::FieldNotFound { .. }
376            | ParseErrorKind::NotAFunction { .. }
377            | ParseErrorKind::InvalidRefinementPredicate
378            | ParseErrorKind::GrammarError(_)
379            | ParseErrorKind::ScopeViolation(_)
380            | ParseErrorKind::UnresolvedPronoun { .. }
381            | ParseErrorKind::TrailingTokens { .. }
382            | ParseErrorKind::AstTooDeep { .. }
383            | ParseErrorKind::Custom(_) => None,
384        }
385    }
386
387    /// The quickguide heading slug, when a lesson teaches this construct.
388    pub fn docs_anchor(&self) -> Option<&'static str> {
389        match self {
390            ParseErrorKind::IsValueEquality { .. } => Some("3-arithmetic-comparison-logic-bitwise"),
391            ParseErrorKind::ZeroIndex => Some("5-collections"),
392            ParseErrorKind::UseAfterMove { .. } => Some("13-output"),
393            ParseErrorKind::UndefinedVariable { .. } => Some("2-variables--mutation"),
394            ParseErrorKind::TypeMismatch { .. } | ParseErrorKind::TypeMismatchDetailed { .. } => {
395                Some("2-variables--mutation")
396            }
397            ParseErrorKind::ArityMismatch { .. } | ParseErrorKind::NotAFunction { .. } => {
398                Some("7-functions--closures")
399            }
400            ParseErrorKind::FieldNotFound { .. } => Some("8-structs-enums--field-access"),
401            ParseErrorKind::UnexpectedToken { .. }
402            | ParseErrorKind::ExpectedContentWord { .. }
403            | ParseErrorKind::ExpectedCopula
404            | ParseErrorKind::UnknownQuantifier { .. }
405            | ParseErrorKind::UnknownModal { .. }
406            | ParseErrorKind::ExpectedVerb { .. }
407            | ParseErrorKind::ExpectedTemporalAdverb
408            | ParseErrorKind::ExpectedPresuppositionTrigger
409            | ParseErrorKind::ExpectedFocusParticle
410            | ParseErrorKind::ExpectedScopalAdverb
411            | ParseErrorKind::ExpectedSuperlativeAdjective
412            | ParseErrorKind::ExpectedComparativeAdjective
413            | ParseErrorKind::ExpectedThan
414            | ParseErrorKind::ExpectedNumber
415            | ParseErrorKind::EmptyRestriction
416            | ParseErrorKind::GappingResolutionFailed
417            | ParseErrorKind::StativeProgressiveConflict
418            | ParseErrorKind::ExpectedStatement
419            | ParseErrorKind::ExpectedKeyword { .. }
420            | ParseErrorKind::ExpectedExpression
421            | ParseErrorKind::ExpectedIdentifier
422            | ParseErrorKind::RespectivelyLengthMismatch { .. }
423            | ParseErrorKind::InfiniteType { .. }
424            | ParseErrorKind::InvalidRefinementPredicate
425            | ParseErrorKind::GrammarError(_)
426            | ParseErrorKind::ScopeViolation(_)
427            | ParseErrorKind::UnresolvedPronoun { .. }
428            | ParseErrorKind::TrailingTokens { .. }
429            | ParseErrorKind::AstTooDeep { .. }
430            | ParseErrorKind::Custom(_) => None,
431        }
432    }
433}
434
435/// The quickguide URL for an anchor — same rendering the LSP ships in
436/// `codeDescription`.
437pub fn guide_url(anchor: &str) -> String {
438    format!(
439        "https://github.com/Brahmastra-Labs/logicaffeine/blob/main/LOGOS_QUICKGUIDE.md#{anchor}"
440    )
441}
442
443/// 1-based `(line, column)` for a byte offset; columns count characters.
444pub fn line_col(source: &str, byte: usize) -> (usize, usize) {
445    let mut line = 1usize;
446    let mut col = 1usize;
447    for (i, c) in source.char_indices() {
448        if i >= byte {
449            break;
450        }
451        if c == '\n' {
452            line += 1;
453            col = 1;
454        } else {
455            col += 1;
456        }
457    }
458    (line, col)
459}
460
461/// Render one parse error into the canonical `logos-diagnostic/1` shape.
462pub fn diagnostic_for(
463    error: &ParseError,
464    source: &str,
465    file: Option<&str>,
466    interner: &Interner,
467) -> Diagnostic {
468    let (start_line, start_col) = line_col(source, error.span.start);
469    let (end_line, end_col) = line_col(source, error.span.end);
470    Diagnostic {
471        code: error.kind.code().to_string(),
472        severity: error.kind.severity().as_str().to_string(),
473        file: file.map(str::to_string),
474        span: DiagSpan {
475            start: error.span.start,
476            end: error.span.end,
477            start_line,
478            start_col,
479            end_line,
480            end_col,
481        },
482        message: error.kind.headline(),
483        explanation: socratic_explanation(error, interner),
484        suggestion: error
485            .suggestion(source)
486            .map(|word| format!("did you mean '{word}'?")),
487        quickfix: error.kind.quickfix_note().map(str::to_string),
488        docs: error.kind.docs_anchor().map(guide_url),
489        related: Vec::new(),
490    }
491}
492
493/// One representative `(variant name, kind)` per [`ParseErrorKind`] variant.
494///
495/// This is the canonical enumeration surface: the catalog, the schema lock,
496/// and the LSP agreement lock all iterate it.
497pub fn sample_kinds() -> Vec<(&'static str, ParseErrorKind)> {
498    use crate::drs::{Gender, Number};
499    use crate::token::TokenType;
500
501    let kinds = vec![
502        ParseErrorKind::UnexpectedToken {
503            expected: TokenType::Period,
504            found: TokenType::Comma,
505        },
506        ParseErrorKind::ExpectedContentWord {
507            found: TokenType::Comma,
508        },
509        ParseErrorKind::ExpectedCopula,
510        ParseErrorKind::UnknownQuantifier {
511            found: TokenType::Comma,
512        },
513        ParseErrorKind::UnknownModal {
514            found: TokenType::Comma,
515        },
516        ParseErrorKind::ExpectedVerb {
517            found: TokenType::Comma,
518        },
519        ParseErrorKind::ExpectedTemporalAdverb,
520        ParseErrorKind::ExpectedPresuppositionTrigger,
521        ParseErrorKind::ExpectedFocusParticle,
522        ParseErrorKind::ExpectedScopalAdverb,
523        ParseErrorKind::ExpectedSuperlativeAdjective,
524        ParseErrorKind::ExpectedComparativeAdjective,
525        ParseErrorKind::ExpectedThan,
526        ParseErrorKind::ExpectedNumber,
527        ParseErrorKind::EmptyRestriction,
528        ParseErrorKind::GappingResolutionFailed,
529        ParseErrorKind::StativeProgressiveConflict,
530        ParseErrorKind::UndefinedVariable {
531            name: "x".to_string(),
532        },
533        ParseErrorKind::UseAfterMove {
534            name: "x".to_string(),
535        },
536        ParseErrorKind::IsValueEquality {
537            variable: "x".to_string(),
538            value: "5".to_string(),
539        },
540        ParseErrorKind::ZeroIndex,
541        ParseErrorKind::ExpectedStatement,
542        ParseErrorKind::ExpectedKeyword {
543            keyword: "to".to_string(),
544        },
545        ParseErrorKind::ExpectedExpression,
546        ParseErrorKind::ExpectedIdentifier,
547        ParseErrorKind::RespectivelyLengthMismatch {
548            subject_count: 2,
549            object_count: 3,
550        },
551        ParseErrorKind::TypeMismatch {
552            expected: "Int".to_string(),
553            found: "Text".to_string(),
554        },
555        ParseErrorKind::TypeMismatchDetailed {
556            expected: "Int".to_string(),
557            found: "Text".to_string(),
558            context: "in argument 2 of 'compute'".to_string(),
559        },
560        ParseErrorKind::InfiniteType {
561            var_description: "T".to_string(),
562            type_description: "Seq of T".to_string(),
563        },
564        ParseErrorKind::ArityMismatch {
565            function: "greet".to_string(),
566            expected: 2,
567            found: 1,
568        },
569        ParseErrorKind::FieldNotFound {
570            type_name: "Point".to_string(),
571            field_name: "z".to_string(),
572            available: vec!["x".to_string(), "y".to_string()],
573        },
574        ParseErrorKind::NotAFunction {
575            found_type: "Int".to_string(),
576        },
577        ParseErrorKind::InvalidRefinementPredicate,
578        ParseErrorKind::GrammarError("its vs it's".to_string()),
579        ParseErrorKind::ScopeViolation("pronoun trapped in negation".to_string()),
580        ParseErrorKind::UnresolvedPronoun {
581            gender: Gender::Female,
582            number: Number::Singular,
583        },
584        ParseErrorKind::TrailingTokens {
585            found: TokenType::Comma,
586        },
587        ParseErrorKind::AstTooDeep {
588            depth: 3000,
589            max_depth: 2048,
590        },
591        ParseErrorKind::Custom("zone escape".to_string()),
592    ];
593
594    kinds
595        .into_iter()
596        .map(|kind| (kind.variant_name(), kind))
597        .collect()
598}
599
600/// The full rendered catalog, one entry per variant.
601pub fn catalog() -> Vec<CatalogEntry> {
602    let interner = Interner::new();
603    sample_kinds()
604        .into_iter()
605        .map(|(variant, kind)| {
606            let error = ParseError {
607                kind,
608                span: crate::token::Span::new(0, 0),
609            };
610            CatalogEntry {
611                variant,
612                code: error.kind.code(),
613                severity: error.kind.severity().as_str(),
614                headline: error.kind.headline(),
615                explanation: socratic_explanation(&error, &interner),
616                quickfix: error.kind.quickfix_note(),
617                docs: error.kind.docs_anchor().map(guide_url),
618            }
619        })
620        .collect()
621}
622
623/// Catalog entries grouped by code, groups in first-appearance (enum) order.
624fn catalog_by_code(entries: &[CatalogEntry]) -> Vec<(&'static str, Vec<&CatalogEntry>)> {
625    let mut order: Vec<&'static str> = Vec::new();
626    let mut groups: std::collections::BTreeMap<&'static str, Vec<&CatalogEntry>> =
627        std::collections::BTreeMap::new();
628    for entry in entries {
629        if !groups.contains_key(entry.code) {
630            order.push(entry.code);
631        }
632        groups.entry(entry.code).or_default().push(entry);
633    }
634    order
635        .into_iter()
636        .map(|code| (code, groups[code].clone()))
637        .collect()
638}
639
640/// The catalog as the reference guide `docs/ERROR_CATALOG.md`: one section per code (variants
641/// sharing a code share a section), each sample headline fenced as `text`, never as `logos`.
642pub fn catalog_markdown() -> String {
643    let entries = catalog();
644    let mut out = String::new();
645    out.push_str("# LOGOS Error Catalog\n\n");
646    out.push_str(
647        "Every diagnostic the LOGOS toolchain emits, keyed by its stable\n\
648         `logos-diagnostic/1` code — the `code` field of `largo check --json`,\n\
649         `largo run --json`, and the MCP `logos_check` tool. Look a code up here\n\
650         or with `largo explain <code>`.\n\n\
651         Generated from `logicaffeine_language::diagnostic::catalog()`. Do not\n\
652         edit by hand; run `./scripts/release/gen-error-catalog.sh` after changing\n\
653         `ParseErrorKind`.\n",
654    );
655    for (code, group) in catalog_by_code(&entries) {
656        out.push_str(&format!("\n## `{code}`\n\n"));
657        let severity = group[0].severity;
658        out.push_str(&format!("- **Severity**: {severity}\n"));
659        let variants: Vec<&str> = group.iter().map(|entry| entry.variant).collect();
660        out.push_str(&format!("- **Variant**: `{}`\n", variants.join("`, `")));
661        if let Some(quickfix) = group[0].quickfix {
662            out.push_str(&format!("- **Quickfix**: {quickfix}\n"));
663        }
664        if let Some(docs) = &group[0].docs {
665            out.push_str(&format!("- **Guide**: <{docs}>\n"));
666        }
667        for entry in &group {
668            out.push_str("\n**Example**:\n\n```text\n");
669            out.push_str(&entry.headline);
670            out.push_str("\n```\n\n");
671            out.push_str(&format!("> {}\n", entry.explanation));
672        }
673    }
674    out
675}
676
677/// The catalog as `errors.json` (schema `logos-errors/1`): one object per variant, pretty-printed,
678/// newline-terminated.
679pub fn catalog_json() -> String {
680    let items: Vec<serde_json::Value> = catalog()
681        .iter()
682        .map(|entry| {
683            serde_json::json!({
684                "code": entry.code,
685                "severity": entry.severity,
686                "variant": entry.variant,
687                "headline": entry.headline,
688                "explanation": entry.explanation,
689                "quickfix": entry.quickfix,
690                "docs": entry.docs,
691            })
692        })
693        .collect();
694    let doc = serde_json::json!({
695        "schema": "logos-errors/1",
696        "errors": items,
697    });
698    let mut text = serde_json::to_string_pretty(&doc).expect("the catalog serializes");
699    text.push('\n');
700    text
701}
702
703#[cfg(test)]
704#[path = "../../logicaffeine_tests/unit/logicaffeine_language/diagnostic/tests.rs"]
705mod tests;