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
13pub 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
63pub 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
112pub 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
132pub 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
195pub 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
247fn 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
279pub 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 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 let zero = tower_lsp::lsp_types::Position { line: 0, character: 0 };
325 (zero, zero)
326 };
327
328 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
346fn 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 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 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
384pub struct ErrorDecision {
391 pub severity: DiagnosticSeverity,
392 pub code: Option<&'static str>,
394 pub quickfix: Quickfix,
395 pub docs: DocsLink,
397}
398
399pub enum Quickfix {
401 Provided(&'static str),
403 None(&'static str),
405}
406
407pub enum DocsLink {
409 Anchor(&'static str),
413 None(&'static str),
415}
416
417fn 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
429fn 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 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 ParseErrorKind::ZeroIndex => (
455 S::INFORMATION,
456 Some("zero-index"),
457 Quickfix::Provided("Use 1-based indexing"),
458 DocsLink::Anchor("5-collections"),
459 ),
460
461 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(diagnostics.len(), 1);
986 assert!(
988 diagnostics[0].related_information.is_none(),
989 "With no tokens, related info should be None"
990 );
991 }
992}