Skip to main content

logicaffeine_lsp/
diagnostics.rs

1use tower_lsp::lsp_types::{
2    Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, Location, Range, Url,
3};
4
5use logicaffeine_base::Interner;
6use logicaffeine_language::error::{ParseError, ParseErrorKind, socratic_explanation};
7use logicaffeine_language::token::{Token, TokenType};
8
9use crate::index::{find_cause_keyword_span, find_last_token_span_for_name};
10use crate::line_index::LineIndex;
11use crate::pipeline::AnalysisError;
12
13/// Convert a list of parse errors to LSP diagnostics.
14///
15/// `tokens` and `uri` enable `DiagnosticRelatedInformation` linking an error
16/// to its cause site (e.g. use-after-move → the Give statement). Pass `&[]`
17/// and `None` when neither is available.
18pub fn convert_errors(
19    errors: &[ParseError],
20    tokens: &[Token],
21    interner: &Interner,
22    line_index: &LineIndex,
23    uri: Option<&Url>,
24) -> Vec<Diagnostic> {
25    errors
26        .iter()
27        .map(|e| error_to_diagnostic(e, tokens, interner, line_index, uri))
28        .collect()
29}
30
31fn error_to_diagnostic(
32    error: &ParseError,
33    tokens: &[Token],
34    interner: &Interner,
35    line_index: &LineIndex,
36    uri: Option<&Url>,
37) -> Diagnostic {
38    let start = line_index.position(error.span.start);
39    let end = line_index.position(error.span.end.max(error.span.start + 1));
40
41    let message = socratic_explanation(error, interner);
42    let decision = decision_for(&error.kind);
43    let related_information =
44        uri.and_then(|u| parse_error_related_info(error, tokens, interner, line_index, u));
45
46    Diagnostic {
47        range: Range { start, end },
48        severity: Some(decision.severity),
49        code: decision
50            .code
51            .map(|c| tower_lsp::lsp_types::NumberOrString::String(c.to_string())),
52        code_description: match decision.docs {
53            DocsLink::Anchor(anchor) => code_description_for(anchor),
54            DocsLink::None(_) => None,
55        },
56        source: Some("logicaffeine".to_string()),
57        message,
58        related_information,
59        ..Default::default()
60    }
61}
62
63/// Hints for `Let` bindings nothing ever reads.
64///
65/// A definition counts as used when any reference RESOLVING TO IT sits at a
66/// different span than the definition itself (the definition site indexes a
67/// self-reference, which does not count). Variables only — an unused
68/// parameter can be a deliberate interface, and fields/variants are shape,
69/// not dataflow. Severity HINT + the UNNECESSARY tag: editors fade the
70/// binding rather than shouting about it.
71pub fn unused_variable_hints(
72    index: &crate::index::SymbolIndex,
73    line_index: &LineIndex,
74) -> Vec<Diagnostic> {
75    let mut hints = Vec::new();
76    for (def_idx, def) in index.definitions.iter().enumerate() {
77        if def.kind != crate::index::DefinitionKind::Variable {
78            continue;
79        }
80        if def.span == logicaffeine_language::token::Span::default() || def.name.starts_with('_') {
81            continue;
82        }
83        let used = index.references.iter().any(|r| {
84            r.definition_idx == Some(def_idx) && r.span.start != def.span.start
85        });
86        if used {
87            continue;
88        }
89        hints.push(Diagnostic {
90            range: Range {
91                start: line_index.position(def.span.start),
92                end: line_index.position(def.span.end),
93            },
94            severity: Some(DiagnosticSeverity::HINT),
95            code: Some(tower_lsp::lsp_types::NumberOrString::String(
96                "unused-variable".to_string(),
97            )),
98            code_description: code_description_for("2-variables--mutation"),
99            source: Some("logicaffeine".to_string()),
100            message: format!(
101                "'{name}' is never used.\n\nNothing reads '{name}' after this Let. \
102                Remove the statement, or use the value.",
103                name = def.name
104            ),
105            tags: Some(vec![tower_lsp::lsp_types::DiagnosticTag::UNNECESSARY]),
106            ..Default::default()
107        });
108    }
109    hints
110}
111
112/// The severity contract for the analysis-layer hint/warning codes — one row
113/// per emitted code, locked (both directions) by `tests/locks.rs`.
114pub const ANALYSIS_DECISIONS: &[(&str, DiagnosticSeverity, &str)] = &[
115    (
116        "unused-variable",
117        DiagnosticSeverity::HINT,
118        "a faded nudge — an unused binding is often work in progress, never a failure",
119    ),
120    (
121        "shadowed-variable",
122        DiagnosticSeverity::WARNING,
123        "legal but usually unintended — the earlier binding silently becomes unreachable",
124    ),
125    (
126        "unused-function",
127        DiagnosticSeverity::HINT,
128        "a faded nudge, and only in programs with a Main — a library file's functions are its API",
129    ),
130];
131
132/// Warnings for re-declared names: a second `Let x` in the same block makes
133/// the earlier `x` unreachable from that point on — legal, and usually a
134/// misspelled `Set`.
135pub fn shadowing_warnings(
136    index: &crate::index::SymbolIndex,
137    line_index: &LineIndex,
138    uri: Option<&Url>,
139) -> Vec<Diagnostic> {
140    use std::collections::HashMap;
141    let mut by_name: HashMap<(&str, Option<usize>), Vec<&crate::index::Definition>> =
142        HashMap::new();
143    for def in &index.definitions {
144        if def.kind != crate::index::DefinitionKind::Variable
145            || def.span == logicaffeine_language::token::Span::default()
146        {
147            continue;
148        }
149        by_name.entry((def.name.as_str(), def.scope.block_idx)).or_default().push(def);
150    }
151
152    let mut warnings = Vec::new();
153    for ((name, _), mut defs) in by_name {
154        if defs.len() < 2 {
155            continue;
156        }
157        defs.sort_by_key(|d| d.span.start);
158        let first = defs[0];
159        for shadow in defs.iter().skip(1).filter(|d| d.span != first.span) {
160            warnings.push(Diagnostic {
161                range: Range {
162                    start: line_index.position(shadow.span.start),
163                    end: line_index.position(shadow.span.end),
164                },
165                severity: Some(DiagnosticSeverity::WARNING),
166                code: Some(tower_lsp::lsp_types::NumberOrString::String(
167                    "shadowed-variable".to_string(),
168                )),
169                code_description: code_description_for("2-variables--mutation"),
170                source: Some("logicaffeine".to_string()),
171                message: format!(
172                    "'{name}' is declared again — the earlier '{name}' above becomes \
173                     unreachable from here on. Did you mean to update it with \
174                     'Set {name} to …', or does this new value deserve its own name?"
175                ),
176                related_information: uri.map(|u| {
177                    vec![DiagnosticRelatedInformation {
178                        location: Location {
179                            uri: u.clone(),
180                            range: Range {
181                                start: line_index.position(first.span.start),
182                                end: line_index.position(first.span.end),
183                            },
184                        },
185                        message: format!("the earlier '{name}' is declared here"),
186                    }]
187                }),
188                ..Default::default()
189            });
190        }
191    }
192    warnings
193}
194
195/// Hints for functions nothing calls — only in programs WITH a `## Main`
196/// (a library file's functions are its API, not dead code).
197pub fn unused_function_hints(
198    index: &crate::index::SymbolIndex,
199    line_index: &LineIndex,
200) -> Vec<Diagnostic> {
201    let has_main = index
202        .block_spans
203        .iter()
204        .any(|(_, block_type, _)| {
205            matches!(block_type, logicaffeine_language::token::BlockType::Main)
206        });
207    if !has_main {
208        return Vec::new();
209    }
210
211    let mut hints = Vec::new();
212    for (def_idx, def) in index.definitions.iter().enumerate() {
213        if def.kind != crate::index::DefinitionKind::Function
214            || def.span == logicaffeine_language::token::Span::default()
215        {
216            continue;
217        }
218        let called = index.references.iter().any(|r| {
219            r.definition_idx == Some(def_idx) && r.span.start != def.span.start
220        });
221        if called {
222            continue;
223        }
224        hints.push(Diagnostic {
225            range: Range {
226                start: line_index.position(def.span.start),
227                end: line_index.position(def.span.end),
228            },
229            severity: Some(DiagnosticSeverity::HINT),
230            code: Some(tower_lsp::lsp_types::NumberOrString::String(
231                "unused-function".to_string(),
232            )),
233            code_description: code_description_for("7-functions--closures"),
234            source: Some("logicaffeine".to_string()),
235            message: format!(
236                "'{name}' is never called.\n\nNothing in this program calls '{name}'. \
237                 Is it wired up yet, or left over from a refactor?",
238                name = def.name
239            ),
240            tags: Some(vec![tower_lsp::lsp_types::DiagnosticTag::UNNECESSARY]),
241            ..Default::default()
242        });
243    }
244    hints
245}
246
247/// Link a parse error to its cause site where one exists in the token stream.
248fn parse_error_related_info(
249    error: &ParseError,
250    tokens: &[Token],
251    interner: &Interner,
252    line_index: &LineIndex,
253    uri: &Url,
254) -> Option<Vec<DiagnosticRelatedInformation>> {
255    match &error.kind {
256        ParseErrorKind::UseAfterMove { name } => {
257            let cause_span = find_cause_keyword_span(
258                tokens,
259                TokenType::Give,
260                name,
261                interner,
262                error.span.start,
263            )?;
264            Some(vec![DiagnosticRelatedInformation {
265                location: Location {
266                    uri: uri.clone(),
267                    range: Range {
268                        start: line_index.position(cause_span.start),
269                        end: line_index.position(cause_span.end),
270                    },
271                },
272                message: format!("'{}' was given away here", name),
273            }])
274        }
275        _ => None,
276    }
277}
278
279/// Convert analysis errors (escape/ownership) to LSP diagnostics.
280///
281/// The `uri` parameter is used for `DiagnosticRelatedInformation` locations.
282/// Pass `None` to omit related information (e.g., in tests without a real URI).
283pub fn convert_analysis_errors(
284    errors: &[AnalysisError],
285    tokens: &[Token],
286    interner: &Interner,
287    line_index: &LineIndex,
288    uri: Option<&Url>,
289) -> Vec<Diagnostic> {
290    errors
291        .iter()
292        .map(|e| analysis_error_to_diagnostic(e, tokens, interner, line_index, uri))
293        .collect()
294}
295
296fn analysis_error_to_diagnostic(
297    error: &AnalysisError,
298    tokens: &[Token],
299    interner: &Interner,
300    line_index: &LineIndex,
301    uri: Option<&Url>,
302) -> Diagnostic {
303    // Primary range: the variable's token INSIDE the erring statement when
304    // the checker gave us that statement's span; else the first occurrence.
305    let primary = error
306        .use_span
307        .and_then(|stmt| {
308            tokens
309                .iter()
310                .filter(|t| t.span.start >= stmt.start && t.span.end <= stmt.end)
311                .find(|t| {
312                    crate::index::resolve_token_name(t, interner)
313                        .map(|n| n == error.variable)
314                        .unwrap_or(false)
315                })
316                .map(|t| t.span)
317                .or(Some(stmt))
318        })
319        .or_else(|| find_last_token_span_for_name(tokens, &error.variable, interner));
320    let (start, end) = if let Some(span) = primary {
321        (line_index.position(span.start), line_index.position(span.end))
322    } else {
323        // Fallback: point to start of document
324        let zero = tower_lsp::lsp_types::Position { line: 0, character: 0 };
325        (zero, zero)
326    };
327
328    // Build related information pointing to the cause (Give statement, Zone entry, etc.)
329    let use_offset = primary.map(|s| s.start).unwrap_or(usize::MAX);
330    let related_information =
331        uri.and_then(|u| build_related_information(error, tokens, interner, line_index, u, use_offset));
332
333    Diagnostic {
334        range: Range { start, end },
335        severity: Some(DiagnosticSeverity::ERROR),
336        code: Some(tower_lsp::lsp_types::NumberOrString::String(error.code.to_string())),
337        code_description: docs_anchor_for_analysis_code(error.code)
338            .and_then(code_description_for),
339        source: Some("logicaffeine".to_string()),
340        message: error.message.clone(),
341        related_information,
342        ..Default::default()
343    }
344}
345
346/// Build `DiagnosticRelatedInformation` linking the error to its cause.
347fn build_related_information(
348    error: &AnalysisError,
349    tokens: &[Token],
350    interner: &Interner,
351    line_index: &LineIndex,
352    uri: &Url,
353    use_offset: usize,
354) -> Option<Vec<DiagnosticRelatedInformation>> {
355    let cause_message = error.cause_context.as_ref()?;
356
357    // Determine which keyword token to search for based on error code
358    let keyword = match error.code {
359        "use-after-move" | "double-move" | "maybe-moved" => TokenType::Give,
360        "escape-return" | "escape-assignment" => TokenType::Zone,
361        _ => return None,
362    };
363
364    // The checker's statement-level cause (from stmt_spans) is exact; the
365    // bounded object-position keyword search is the fallback.
366    let cause_span = error.cause_span.or_else(|| {
367        find_cause_keyword_span(tokens, keyword, &error.variable, interner, use_offset)
368    })?;
369    let cause_start = line_index.position(cause_span.start);
370    let cause_end = line_index.position(cause_span.end);
371
372    Some(vec![DiagnosticRelatedInformation {
373        location: Location {
374            uri: uri.clone(),
375            range: Range {
376                start: cause_start,
377                end: cause_end,
378            },
379        },
380        message: cause_message.clone(),
381    }])
382}
383
384/// The explicit presentation decision for one `ParseErrorKind`.
385///
386/// Every kind decides all three axes. The wildcard-free match in
387/// [`decision_for`] is the compile-time ratchet: a new error kind does not
388/// build until someone chooses its severity, its diagnostic code, and whether
389/// a quickfix is safe — `tests/locks.rs` pins the runtime side.
390pub struct ErrorDecision {
391    pub severity: DiagnosticSeverity,
392    /// Stable kebab-case code that code actions and clients key on.
393    pub code: Option<&'static str>,
394    pub quickfix: Quickfix,
395    /// Whether the diagnostic links a quickguide lesson via `codeDescription`.
396    pub docs: DocsLink,
397}
398
399/// Whether `code_actions` offers a mechanical fix for an error kind.
400pub enum Quickfix {
401    /// A fix exists, keyed on the diagnostic code; the string names it.
402    Provided(&'static str),
403    /// Deliberately no fix; the reason is part of the decision.
404    None(&'static str),
405}
406
407/// Whether a diagnostic carries a "read more" link into LOGOS_QUICKGUIDE.md.
408pub enum DocsLink {
409    /// A quickguide heading slug (rendered through `teach_md::guide_url`).
410    /// Clients show `codeDescription` alongside the code, so a code is
411    /// required — the decision lock enforces it.
412    Anchor(&'static str),
413    /// Deliberately no link; the reason is part of the decision.
414    None(&'static str),
415}
416
417/// The quickguide anchor for an analysis-path diagnostic code (ownership and
418/// escape findings arrive as `AnalysisError`, outside `decision_for`).
419fn docs_anchor_for_analysis_code(code: &str) -> Option<&'static str> {
420    match code {
421        "use-after-move" | "double-move" | "maybe-moved" => Some("13-output"),
422        "escape-return" | "escape-assignment" => {
423            Some("12-distributed-crdt-concurrency-networking-zones")
424        }
425        _ => None,
426    }
427}
428
429/// A ready `codeDescription` for a quickguide anchor.
430fn code_description_for(anchor: &str) -> Option<tower_lsp::lsp_types::CodeDescription> {
431    Url::parse(&crate::teach_md::guide_url(anchor))
432        .ok()
433        .map(|href| tower_lsp::lsp_types::CodeDescription { href })
434}
435
436pub fn decision_for(kind: &ParseErrorKind) -> ErrorDecision {
437    use DiagnosticSeverity as S;
438    let (severity, code, quickfix, docs) = match kind {
439        // Style: valid programs that read wrong.
440        ParseErrorKind::IsValueEquality { .. } => (
441            S::WARNING,
442            Some("is-value-equality"),
443            Quickfix::Provided("Use 'equals' for value comparison"),
444            DocsLink::Anchor("3-arithmetic-comparison-logic-bitwise"),
445        ),
446        ParseErrorKind::GrammarError(_) => (
447            S::WARNING,
448            Some("grammar-error"),
449            Quickfix::None("the message itself names the correction; no single edit is safe"),
450            DocsLink::None("the guide documents constructs, not English orthography"),
451        ),
452
453        // Idiom: 1-based indexing is a convention, not a failure.
454        ParseErrorKind::ZeroIndex => (
455            S::INFORMATION,
456            Some("zero-index"),
457            Quickfix::Provided("Use 1-based indexing"),
458            DocsLink::Anchor("5-collections"),
459        ),
460
461        // Ownership.
462        ParseErrorKind::UseAfterMove { .. } => (
463            S::ERROR,
464            Some("use-after-move"),
465            Quickfix::Provided("Use 'a copy of …' instead"),
466            DocsLink::Anchor("13-output"),
467        ),
468
469        // Name resolution.
470        ParseErrorKind::UndefinedVariable { .. } => (
471            S::ERROR,
472            Some("undefined-variable"),
473            Quickfix::Provided("Did you mean '<nearest definition>'?"),
474            DocsLink::Anchor("2-variables--mutation"),
475        ),
476
477        // Types.
478        ParseErrorKind::TypeMismatch { .. } | ParseErrorKind::TypeMismatchDetailed { .. } => (
479            S::ERROR,
480            Some("type-mismatch"),
481            Quickfix::None("no conversion is safe for arbitrary expected/found pairs"),
482            DocsLink::Anchor("2-variables--mutation"),
483        ),
484        ParseErrorKind::InfiniteType { .. } => (
485            S::ERROR,
486            Some("infinite-type"),
487            Quickfix::None("breaking a type cycle requires restructuring, not an edit"),
488            DocsLink::None("the fix is structural; no guide section teaches type cycles"),
489        ),
490        ParseErrorKind::ArityMismatch { .. } => (
491            S::ERROR,
492            Some("arity-mismatch"),
493            Quickfix::None("which argument to add or drop is the author's call"),
494            DocsLink::Anchor("7-functions--closures"),
495        ),
496        ParseErrorKind::FieldNotFound { .. } => (
497            S::ERROR,
498            Some("field-not-found"),
499            Quickfix::None(
500                "the diagnostic anchors on the whole statement; the message lists the fields that exist",
501            ),
502            DocsLink::Anchor("8-structs-enums--field-access"),
503        ),
504        ParseErrorKind::NotAFunction { .. } => (
505            S::ERROR,
506            Some("not-a-function"),
507            Quickfix::None("the intended callee is unknowable from the call site"),
508            DocsLink::Anchor("7-functions--closures"),
509        ),
510        ParseErrorKind::InvalidRefinementPredicate => (
511            S::ERROR,
512            None,
513            Quickfix::None("a refinement predicate must be rethought, not patched"),
514            DocsLink::None("no diagnostic code to hang a codeDescription on"),
515        ),
516        ParseErrorKind::AstTooDeep { .. } => (
517            S::ERROR,
518            Some("ast-too-deep"),
519            Quickfix::None("nesting past the engine's recursion gate needs restructuring, not an edit"),
520            DocsLink::None("the depth limit is an engine gate, not a construct the guide teaches"),
521        ),
522
523        // Sentence structure: the socratic message lists example words; the
524        // choice of word is the author's, so no insertion is safe.
525        ParseErrorKind::UnexpectedToken { .. }
526        | ParseErrorKind::ExpectedContentWord { .. }
527        | ParseErrorKind::ExpectedCopula
528        | ParseErrorKind::UnknownQuantifier { .. }
529        | ParseErrorKind::UnknownModal { .. }
530        | ParseErrorKind::ExpectedVerb { .. }
531        | ParseErrorKind::ExpectedTemporalAdverb
532        | ParseErrorKind::ExpectedPresuppositionTrigger
533        | ParseErrorKind::ExpectedFocusParticle
534        | ParseErrorKind::ExpectedScopalAdverb
535        | ParseErrorKind::ExpectedSuperlativeAdjective
536        | ParseErrorKind::ExpectedComparativeAdjective
537        | ParseErrorKind::ExpectedThan
538        | ParseErrorKind::ExpectedNumber
539        | ParseErrorKind::ExpectedStatement
540        | ParseErrorKind::ExpectedKeyword { .. }
541        | ParseErrorKind::ExpectedExpression
542        | ParseErrorKind::ExpectedIdentifier
543        | ParseErrorKind::TrailingTokens { .. } => (
544            S::ERROR,
545            None,
546            Quickfix::None("word choice is the author's; the message lists examples"),
547            DocsLink::None("no diagnostic code; the socratic message already names example words"),
548        ),
549
550        // Linguistic restructuring: the sentence needs rethinking as a whole.
551        ParseErrorKind::EmptyRestriction
552        | ParseErrorKind::GappingResolutionFailed
553        | ParseErrorKind::StativeProgressiveConflict
554        | ParseErrorKind::RespectivelyLengthMismatch { .. }
555        | ParseErrorKind::ScopeViolation(_)
556        | ParseErrorKind::UnresolvedPronoun { .. } => (
557            S::ERROR,
558            None,
559            Quickfix::None("requires restructuring the sentence, not a mechanical edit"),
560            DocsLink::None("no diagnostic code; logic-mode phenomena are beyond the quickguide"),
561        ),
562
563        // Escape/zone analysis reports through AnalysisError with its own
564        // codes; the Custom passthrough carries only prose here.
565        ParseErrorKind::Custom(_) => (
566            S::ERROR,
567            None,
568            Quickfix::None("a catch-all message carries no structure to key a fix on"),
569            DocsLink::None("caller prose carries no stable identity to link"),
570        ),
571    };
572    ErrorDecision { severity, code, quickfix, docs }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use logicaffeine_language::token::Span;
579
580    #[test]
581    fn parse_error_produces_diagnostic() {
582        let interner = Interner::new();
583        let line_index = LineIndex::new("Let x be 5.\nSet x to 10.");
584
585        let error = ParseError {
586            kind: ParseErrorKind::ExpectedExpression,
587            span: Span::new(12, 15),
588        };
589
590        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
591        assert_eq!(diagnostics.len(), 1);
592        assert_eq!(diagnostics[0].range.start.line, 1);
593        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
594        assert!(diagnostics[0].message.contains("expression"));
595    }
596
597    #[test]
598    fn is_value_equality_is_warning() {
599        let interner = Interner::new();
600        let line_index = LineIndex::new("x is 5");
601
602        let error = ParseError {
603            kind: ParseErrorKind::IsValueEquality {
604                variable: "x".to_string(),
605                value: "5".to_string(),
606            },
607            span: Span::new(0, 6),
608        };
609
610        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
611        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
612    }
613
614    #[test]
615    fn grammar_error_is_warning() {
616        let interner = Interner::new();
617        let line_index = LineIndex::new("bad grammar");
618
619        let error = ParseError {
620            kind: ParseErrorKind::GrammarError("test".to_string()),
621            span: Span::new(0, 3),
622        };
623
624        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
625        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
626    }
627
628    #[test]
629    fn zero_index_is_information() {
630        let interner = Interner::new();
631        let line_index = LineIndex::new("list[0]");
632
633        let error = ParseError {
634            kind: ParseErrorKind::ZeroIndex,
635            span: Span::new(4, 7),
636        };
637
638        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
639        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::INFORMATION));
640    }
641
642    #[test]
643    fn multiple_errors_produce_multiple_diagnostics() {
644        let interner = Interner::new();
645        let line_index = LineIndex::new("abc\ndef\nghi");
646
647        let errors = vec![
648            ParseError {
649                kind: ParseErrorKind::ExpectedExpression,
650                span: Span::new(0, 3),
651            },
652            ParseError {
653                kind: ParseErrorKind::ExpectedExpression,
654                span: Span::new(4, 7),
655            },
656        ];
657
658        let diagnostics = convert_errors(&errors, &[], &interner, &line_index, None);
659        assert_eq!(diagnostics.len(), 2);
660        assert_eq!(diagnostics[0].range.start.line, 0);
661        assert_eq!(diagnostics[1].range.start.line, 1);
662    }
663
664    #[test]
665    fn empty_errors_produce_empty_diagnostics() {
666        let interner = Interner::new();
667        let line_index = LineIndex::new("fine");
668        let diagnostics = convert_errors(&[], &[], &interner, &line_index, None);
669        assert!(diagnostics.is_empty());
670    }
671
672    #[test]
673    fn use_after_move_is_error_severity() {
674        let decision = decision_for(&ParseErrorKind::UseAfterMove {
675            name: "x".to_string(),
676        });
677        assert_eq!(decision.severity, DiagnosticSeverity::ERROR);
678    }
679
680    #[test]
681    fn diagnostic_range_spans_correct_positions() {
682        let interner = Interner::new();
683        let line_index = LineIndex::new("abc\ndef");
684        let error = ParseError {
685            kind: ParseErrorKind::ExpectedExpression,
686            span: Span::new(4, 7),
687        };
688        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
689        assert_eq!(diagnostics[0].range.start.line, 1);
690        assert_eq!(diagnostics[0].range.start.character, 0);
691        assert_eq!(diagnostics[0].range.end.line, 1);
692        assert_eq!(diagnostics[0].range.end.character, 3);
693    }
694
695    #[test]
696    fn is_value_equality_has_diagnostic_code() {
697        let interner = Interner::new();
698        let line_index = LineIndex::new("x is 5");
699
700        let error = ParseError {
701            kind: ParseErrorKind::IsValueEquality {
702                variable: "x".to_string(),
703                value: "5".to_string(),
704            },
705            span: Span::new(0, 6),
706        };
707
708        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
709        assert_eq!(
710            diagnostics[0].code,
711            Some(tower_lsp::lsp_types::NumberOrString::String("is-value-equality".to_string())),
712            "IsValueEquality should have diagnostic code"
713        );
714    }
715
716    #[test]
717    fn use_after_move_has_diagnostic_code() {
718        let interner = Interner::new();
719        let line_index = LineIndex::new("x moved");
720
721        let error = ParseError {
722            kind: ParseErrorKind::UseAfterMove {
723                name: "x".to_string(),
724            },
725            span: Span::new(0, 1),
726        };
727
728        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
729        assert_eq!(
730            diagnostics[0].code,
731            Some(tower_lsp::lsp_types::NumberOrString::String("use-after-move".to_string())),
732            "UseAfterMove should have diagnostic code"
733        );
734    }
735
736    #[test]
737    fn regular_error_has_no_diagnostic_code() {
738        let interner = Interner::new();
739        let line_index = LineIndex::new("bad");
740
741        let error = ParseError {
742            kind: ParseErrorKind::ExpectedExpression,
743            span: Span::new(0, 3),
744        };
745
746        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
747        assert_eq!(diagnostics[0].code, None, "ExpectedExpression should have no code");
748    }
749
750    #[test]
751    fn diagnostic_has_source() {
752        let interner = Interner::new();
753        let line_index = LineIndex::new("bad");
754
755        let error = ParseError {
756            kind: ParseErrorKind::ExpectedExpression,
757            span: Span::new(0, 3),
758        };
759
760        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
761        assert_eq!(diagnostics[0].source, Some("logicaffeine".to_string()));
762    }
763
764    #[test]
765    fn escape_error_produces_diagnostic_with_code() {
766        let error = AnalysisError {
767            message: "Reference 'x' cannot escape zone 'temp'.".to_string(),
768            variable: "x".to_string(),
769            code: "escape-return",
770            cause_context: Some("zone 'temp'".to_string()),
771            use_span: None,
772            cause_span: None,
773        };
774        let interner = Interner::new();
775        let line_index = LineIndex::new("Let x be 5.");
776        let diagnostics = convert_analysis_errors(&[error], &[], &interner, &line_index, None);
777        assert_eq!(diagnostics.len(), 1);
778        assert_eq!(
779            diagnostics[0].code,
780            Some(tower_lsp::lsp_types::NumberOrString::String("escape-return".to_string()))
781        );
782        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
783    }
784
785    #[test]
786    fn ownership_error_has_error_severity() {
787        let error = AnalysisError {
788            message: "Cannot use 'x' after giving it away.".to_string(),
789            variable: "x".to_string(),
790            code: "use-after-move",
791            cause_context: None,
792            use_span: None,
793            cause_span: None,
794        };
795        let interner = Interner::new();
796        let line_index = LineIndex::new("Let x be 5.");
797        let diagnostics = convert_analysis_errors(&[error], &[], &interner, &line_index, None);
798        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
799    }
800
801    #[test]
802    fn ownership_error_message_is_socratic() {
803        let error = AnalysisError {
804            message: "Cannot use 'x' after giving it away.\n\nYou transferred ownership.".to_string(),
805            variable: "x".to_string(),
806            code: "use-after-move",
807            cause_context: None,
808            use_span: None,
809            cause_span: None,
810        };
811        let interner = Interner::new();
812        let line_index = LineIndex::new("Let x be 5.");
813        let diagnostics = convert_analysis_errors(&[error], &[], &interner, &line_index, None);
814        assert!(diagnostics[0].message.contains("giving it away"), "Message should be Socratic: {}", diagnostics[0].message);
815        assert!(diagnostics[0].message.contains("ownership"), "Message should explain ownership: {}", diagnostics[0].message);
816    }
817
818    #[test]
819    fn undefined_variable_has_diagnostic_code() {
820        let interner = Interner::new();
821        let line_index = LineIndex::new("Show z.");
822
823        let error = ParseError {
824            kind: ParseErrorKind::UndefinedVariable {
825                name: "z".to_string(),
826            },
827            span: Span::new(5, 6),
828        };
829
830        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
831        assert_eq!(
832            diagnostics[0].code,
833            Some(tower_lsp::lsp_types::NumberOrString::String("undefined-variable".to_string()))
834        );
835    }
836
837    #[test]
838    fn type_mismatch_has_diagnostic_code() {
839        let interner = Interner::new();
840        let line_index = LineIndex::new("Let x: Int be \"hello\".");
841
842        let error = ParseError {
843            kind: ParseErrorKind::TypeMismatch {
844                expected: "Int".to_string(),
845                found: "Text".to_string(),
846            },
847            span: Span::new(0, 22),
848        };
849
850        let diagnostics = convert_errors(&[error], &[], &interner, &line_index, None);
851        assert_eq!(
852            diagnostics[0].code,
853            Some(tower_lsp::lsp_types::NumberOrString::String("type-mismatch".to_string()))
854        );
855    }
856
857    #[test]
858    fn use_after_move_diagnostic_has_related_information() {
859        // Use the full pipeline to get real tokens with Give
860        let source = "## Main\n    Let x be 5.\n    Let y be 0.\n    Give x to y.\n    Show x.\n";
861        let result = crate::pipeline::analyze(source);
862        let line_index = LineIndex::new(source);
863        let uri = Url::parse("file:///test.la").unwrap();
864
865        // Create a move error with cause_context (as pipeline does)
866        let error = AnalysisError {
867            message: "Cannot use 'x' after giving it away.".to_string(),
868            variable: "x".to_string(),
869            code: "use-after-move",
870            cause_context: Some("'x' was given away here".to_string()),
871            use_span: None,
872            cause_span: None,
873        };
874
875        let diagnostics = convert_analysis_errors(
876            &[error],
877            &result.tokens,
878            &result.interner,
879            &line_index,
880            Some(&uri),
881        );
882
883        assert_eq!(diagnostics.len(), 1);
884        let related = diagnostics[0].related_information.as_ref();
885        assert!(
886            related.is_some(),
887            "Use-after-move diagnostic should have related information"
888        );
889        let related = related.unwrap();
890        assert_eq!(related.len(), 1);
891        assert!(
892            related[0].message.contains("given away"),
893            "Related info should mention giving away: {}",
894            related[0].message
895        );
896        assert_eq!(related[0].location.uri, uri);
897    }
898
899    #[test]
900    fn related_info_points_to_give_statement() {
901        let source = "## Main\n    Let x be 5.\n    Let y be 0.\n    Give x to y.\n    Show x.\n";
902        let result = crate::pipeline::analyze(source);
903        let line_index = LineIndex::new(source);
904        let uri = Url::parse("file:///test.la").unwrap();
905
906        let error = AnalysisError {
907            message: "Cannot use 'x' after giving it away.".to_string(),
908            variable: "x".to_string(),
909            code: "use-after-move",
910            cause_context: Some("'x' was given away here".to_string()),
911            use_span: None,
912            cause_span: None,
913        };
914
915        let diagnostics = convert_analysis_errors(
916            &[error],
917            &result.tokens,
918            &result.interner,
919            &line_index,
920            Some(&uri),
921        );
922
923        let related = diagnostics[0].related_information.as_ref().unwrap();
924        // The Give keyword should be on line 3 (0-indexed): "    Give x to y."
925        assert_eq!(
926            related[0].location.range.start.line, 3,
927            "Related info should point to the Give statement on line 3"
928        );
929    }
930
931    #[test]
932    fn no_related_info_without_uri() {
933        let source = "## Main\n    Let x be 5.\n    Let y be 0.\n    Give x to y.\n    Show x.\n";
934        let result = crate::pipeline::analyze(source);
935        let line_index = LineIndex::new(source);
936
937        let error = AnalysisError {
938            message: "Cannot use 'x' after giving it away.".to_string(),
939            variable: "x".to_string(),
940            code: "use-after-move",
941            cause_context: Some("'x' was given away here".to_string()),
942            use_span: None,
943            cause_span: None,
944        };
945
946        let diagnostics = convert_analysis_errors(
947            &[error],
948            &result.tokens,
949            &result.interner,
950            &line_index,
951            None,
952        );
953
954        assert!(
955            diagnostics[0].related_information.is_none(),
956            "Without URI, related information should be None"
957        );
958    }
959
960    #[test]
961    fn escape_diagnostic_has_related_info_pointing_to_zone() {
962        // Escape errors should have related info pointing to the Zone keyword
963        let error = AnalysisError {
964            message: "Reference 'x' cannot escape zone 'temp'.".to_string(),
965            variable: "x".to_string(),
966            code: "escape-return",
967            cause_context: Some("zone 'temp'".to_string()),
968            use_span: None,
969            cause_span: None,
970        };
971
972        // Without real tokens containing a Zone keyword, related info won't resolve.
973        // This tests the fallback when no matching keyword is found.
974        let interner = Interner::new();
975        let line_index = LineIndex::new("Zone temp: Let x be 5.");
976        let uri = Url::parse("file:///test.la").unwrap();
977        let diagnostics = convert_analysis_errors(
978            &[error],
979            &[],
980            &interner,
981            &line_index,
982            Some(&uri),
983        );
984        // With empty tokens, the keyword search fails gracefully
985        assert_eq!(diagnostics.len(), 1);
986        // related_information is None because no tokens matched
987        assert!(
988            diagnostics[0].related_information.is_none(),
989            "With no tokens, related info should be None"
990        );
991    }
992}