Skip to main content

shape_lsp/
diagnostics.rs

1//! Diagnostics conversion from Shape errors to LSP diagnostics
2
3use crate::annotation_discovery::AnnotationDiscovery;
4use crate::type_inference::unified_metadata;
5use crate::util::span_to_range;
6use shape_ast::ast::{Annotation, Expr, Item, Literal, Program, Span, Statement};
7use shape_ast::error::{
8    ErrorNote, ErrorRenderer, ErrorSeverity, ParseErrorKind, ShapeError, SourceLocation,
9    StructuredParseError,
10};
11use tower_lsp_server::ls_types::{
12    CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag,
13    Location, NumberOrString, Position, Range, Uri,
14};
15
16/// W2.3 / 1.17 — book URL base for diagnostic `codeDescription` targets.
17///
18/// Per audit `docs/cluster-audits/v0.3-lsp-feature-spec.md` row 1.17, the
19/// LSP `codeDescription.href` field points at the book page documenting
20/// the error code. The book's stable public hostname is
21/// `book.shape-lang.dev` (used throughout `../shape-web/landing/index.html`,
22/// e.g. `https://book.shape-lang.dev/getting-started/installation/`).
23///
24/// URL pattern: `{BASE}#{code}` — anchors per-code within a single
25/// appendix page. Once the dedicated error-code reference page lands in
26/// the book (W3 follow-up; the audit row notes "depends on W3 Shape Book
27/// deliverable" but defers schema design to this wave), the anchor
28/// pattern stays valid. Minimal-viable landing target today is the
29/// appendix index — clients that follow the link land on the appendix
30/// page even before the per-code section exists, matching the
31/// rust-analyzer rendering of `--explain` for unknown codes.
32const BOOK_CODE_DESCRIPTION_BASE: &str = "https://book.shape-lang.dev/appendix/error-codes/";
33
34/// W2.3 / 1.17 — build a `CodeDescription` for a Shape error code string.
35///
36/// Returns `None` if `code` is empty (defensive; every Shape error code
37/// is non-empty in practice). URL fragment is the code — e.g. `E0100` →
38/// `https://book.shape-lang.dev/appendix/error-codes/#E0100`.
39///
40/// Exposed via the module's public surface so the borrow/MIR diagnostic
41/// path and the per-validator diagnostics share one URL builder.
42pub fn code_description_for(code: &str) -> Option<CodeDescription> {
43    if code.is_empty() {
44        return None;
45    }
46    use std::str::FromStr;
47    let href_str = format!("{}#{}", BOOK_CODE_DESCRIPTION_BASE, code);
48    Uri::from_str(&href_str)
49        .ok()
50        .map(|href| CodeDescription { href })
51}
52
53/// W2.3 / 1.19 — derive LSP `DiagnosticTag`s from a shape diagnostic.
54///
55/// Currently surfaces `UNNECESSARY` for "unused" lints. Invoked at
56/// diagnostic-construction time so the tag travels with every diagnostic
57/// that has a matching code/source, regardless of which validator
58/// produced it. Empty tag vector returns `None` to keep wire format
59/// compact.
60pub fn diagnostic_tags_for(code: Option<&str>, message: &str) -> Option<Vec<DiagnosticTag>> {
61    let mut tags = Vec::new();
62    // W0102 = unused-import lint (registered in `validate_unused_imports`).
63    // W0103 reserved for future unused-variable lint. Message-based
64    // fallback catches any future compiler-emitted "unused" diagnostics
65    // without requiring per-code wiring — the tag is semantic (faded-out
66    // rendering), not behavioral, so over-tagging is acceptable.
67    let is_unused = matches!(code, Some("W0102") | Some("W0103"))
68        || message.contains("unused import")
69        || message.contains("unused variable");
70    if is_unused {
71        tags.push(DiagnosticTag::UNNECESSARY);
72    }
73    if tags.is_empty() { None } else { Some(tags) }
74}
75
76/// W2.3 — backfill `code_description` (1.17) + `tags` (1.19) on a
77/// diagnostic batch produced by the per-validator pipeline.
78///
79/// Validators in this module historically construct `Diagnostic` literals
80/// with `code_description: None` and `tags: None`. Rather than touch
81/// every literal site, this enricher runs once at the end of the
82/// semantic-diagnostics pipeline (`analysis::analyze_program_semantics`).
83/// Diagnostics with an existing `code_description` (e.g. already set by
84/// the structured-error renderer) are left untouched — this is a
85/// backfill, not an overwrite.
86pub fn enrich_diagnostics_with_code_metadata(diagnostics: &mut [Diagnostic]) {
87    for diag in diagnostics.iter_mut() {
88        if diag.code_description.is_none() {
89            if let Some(NumberOrString::String(code)) = &diag.code {
90                diag.code_description = code_description_for(code);
91            }
92        }
93        if diag.tags.is_none() {
94            let code_str = match &diag.code {
95                Some(NumberOrString::String(c)) => Some(c.as_str()),
96                _ => None,
97            };
98            diag.tags = diagnostic_tags_for(code_str, &diag.message);
99        }
100    }
101}
102
103/// LSP Error Renderer - converts structured errors to LSP Diagnostics
104pub struct LspErrorRenderer {
105    /// URI of the document being validated
106    uri: Uri,
107}
108
109impl LspErrorRenderer {
110    pub fn new(uri: Uri) -> Self {
111        Self { uri }
112    }
113
114    /// Convert a structured error to an LSP diagnostic
115    pub fn structured_error_to_diagnostic(&self, error: &StructuredParseError) -> Diagnostic {
116        let severity = match error.severity {
117            ErrorSeverity::Error => DiagnosticSeverity::ERROR,
118            ErrorSeverity::Warning => DiagnosticSeverity::WARNING,
119            ErrorSeverity::Info => DiagnosticSeverity::INFORMATION,
120            ErrorSeverity::Hint => DiagnosticSeverity::HINT,
121        };
122
123        // Convert location to range
124        let range = self.structured_location_to_range(error);
125
126        // Build the main message
127        let message = format_structured_message(&error.kind, &error.suggestions);
128
129        // Build related information from related locations
130        let related_information = if !error.related.is_empty() {
131            Some(
132                error
133                    .related
134                    .iter()
135                    .map(|rel| DiagnosticRelatedInformation {
136                        location: Location {
137                            uri: self.uri.clone(),
138                            range: self.source_location_to_range(&rel.location),
139                        },
140                        message: rel.message.clone(),
141                    })
142                    .collect(),
143            )
144        } else {
145            None
146        };
147
148        let code_str = error.code.as_str().to_string();
149        // W2.3 / 1.17 + 1.19 — structured errors get the same metadata
150        // backfill as validator-emitted diagnostics.
151        let code_description = code_description_for(&code_str);
152        let tags = diagnostic_tags_for(Some(&code_str), &message);
153
154        Diagnostic {
155            range,
156            severity: Some(severity),
157            code: Some(NumberOrString::String(code_str)),
158            code_description,
159            source: Some("shape".to_string()),
160            message,
161            related_information,
162            tags,
163            data: None,
164        }
165    }
166
167    fn structured_location_to_range(&self, error: &StructuredParseError) -> Range {
168        let line = error.location.line.saturating_sub(1) as u32;
169        let column = error.location.column.saturating_sub(1) as u32;
170
171        let start = Position {
172            line,
173            character: column,
174        };
175
176        let end = if let Some((end_line, end_col)) = error.span_end {
177            Position {
178                line: end_line.saturating_sub(1) as u32,
179                character: end_col.saturating_sub(1) as u32,
180            }
181        } else if let Some(len) = error.location.length {
182            Position {
183                line,
184                character: column + len as u32,
185            }
186        } else {
187            // Default to a reasonable span
188            Position {
189                line,
190                character: column + 1,
191            }
192        };
193
194        Range { start, end }
195    }
196
197    fn source_location_to_range(&self, location: &SourceLocation) -> Range {
198        let line = location.line.saturating_sub(1) as u32;
199        let column = location.column.saturating_sub(1) as u32;
200
201        Range {
202            start: Position {
203                line,
204                character: column,
205            },
206            end: Position {
207                line,
208                character: column + location.length.unwrap_or(1) as u32,
209            },
210        }
211    }
212}
213
214impl ErrorRenderer for LspErrorRenderer {
215    type Output = Vec<Diagnostic>;
216
217    fn render(&self, error: &StructuredParseError) -> Self::Output {
218        vec![self.structured_error_to_diagnostic(error)]
219    }
220
221    fn render_all(&self, errors: &[StructuredParseError]) -> Self::Output {
222        errors
223            .iter()
224            .map(|e| self.structured_error_to_diagnostic(e))
225            .collect()
226    }
227}
228
229/// Format the error message with suggestions for LSP display
230fn format_structured_message(
231    kind: &ParseErrorKind,
232    suggestions: &[shape_ast::error::Suggestion],
233) -> String {
234    use shape_ast::error::parse_error::format_error_message;
235
236    let base_message = format_error_message(kind);
237
238    if suggestions.is_empty() {
239        return base_message;
240    }
241
242    // Add suggestions as hints in the message
243    let suggestion_text: Vec<String> = suggestions.iter().map(|s| s.message.clone()).collect();
244
245    if suggestion_text.is_empty() {
246        base_message
247    } else {
248        format!("{}\n\n{}", base_message, suggestion_text.join("\n"))
249    }
250}
251
252/// Convert Shape errors to LSP diagnostics
253pub fn error_to_diagnostic(error: &ShapeError) -> Vec<Diagnostic> {
254    error_to_diagnostic_with_uri(error, None)
255}
256
257/// Convert Shape errors to LSP diagnostics with optional URI for structured errors
258pub fn error_to_diagnostic_with_uri(error: &ShapeError, uri: Option<Uri>) -> Vec<Diagnostic> {
259    // Get error code if available
260    let error_code = error.error_code().map(|c| c.as_str());
261
262    match error {
263        ShapeError::StructuredParse(structured) => {
264            // Use the LSP renderer for structured errors if we have a URI
265            if let Some(uri) = uri {
266                let renderer = LspErrorRenderer::new(uri);
267                renderer.render(structured)
268            } else {
269                // Fallback: convert to a basic diagnostic
270                vec![create_diagnostic_with_code(
271                    &structured.to_string(),
272                    Some(&structured.location),
273                    DiagnosticSeverity::ERROR,
274                    "shape",
275                    Some(structured.code.as_str()),
276                )]
277            }
278        }
279        ShapeError::ParseError { message, location } => {
280            vec![create_diagnostic_with_code(
281                message,
282                location.as_ref(),
283                DiagnosticSeverity::ERROR,
284                "shape",
285                error_code,
286            )]
287        }
288        ShapeError::LexError { message, location } => {
289            vec![create_diagnostic_with_code(
290                message,
291                location.as_ref(),
292                DiagnosticSeverity::ERROR,
293                "shape",
294                error_code,
295            )]
296        }
297        ShapeError::SemanticError { message, location } => {
298            vec![create_diagnostic_with_code(
299                message,
300                location.as_ref(),
301                DiagnosticSeverity::ERROR,
302                "shape",
303                error_code,
304            )]
305        }
306        ShapeError::RuntimeError { message, location } => {
307            vec![create_diagnostic_with_code(
308                message,
309                location.as_ref(),
310                DiagnosticSeverity::WARNING,
311                "shape",
312                error_code,
313            )]
314        }
315        ShapeError::TypeError(type_error) => {
316            // Type errors may have location information
317            vec![create_diagnostic_with_code(
318                &type_error.to_string(),
319                None,
320                DiagnosticSeverity::ERROR,
321                "shape",
322                error_code,
323            )]
324        }
325        ShapeError::PatternError {
326            message,
327            pattern_name,
328        } => {
329            let msg = if let Some(name) = pattern_name {
330                format!("Pattern '{}': {}", name, message)
331            } else {
332                message.clone()
333            };
334            vec![create_diagnostic_with_code(
335                &msg,
336                None,
337                DiagnosticSeverity::ERROR,
338                "shape",
339                error_code,
340            )]
341        }
342        ShapeError::DataError {
343            message,
344            symbol,
345            timeframe,
346        } => {
347            let mut msg = message.clone();
348            if let Some(sym) = symbol {
349                msg.push_str(&format!(" (symbol: {})", sym));
350            }
351            if let Some(tf) = timeframe {
352                msg.push_str(&format!(" (timeframe: {})", tf));
353            }
354            vec![create_diagnostic_with_code(
355                &msg,
356                None,
357                DiagnosticSeverity::WARNING,
358                "shape",
359                error_code,
360            )]
361        }
362        ShapeError::ModuleError {
363            message,
364            module_path,
365        } => {
366            let msg = if let Some(path) = module_path {
367                format!("{}: {}", path.display(), message)
368            } else {
369                message.clone()
370            };
371            vec![create_diagnostic_with_code(
372                &msg,
373                None,
374                DiagnosticSeverity::ERROR,
375                "shape",
376                error_code,
377            )]
378        }
379
380        ShapeError::MultiError(errors) => {
381            // Flatten MultiError into individual diagnostics
382            errors
383                .iter()
384                .flat_map(|e| error_to_diagnostic_with_uri(e, uri.clone()))
385                .collect()
386        }
387
388        // Other error types get generic diagnostic
389        _ => vec![create_diagnostic_with_code(
390            &error.to_string(),
391            None,
392            DiagnosticSeverity::ERROR,
393            "shape",
394            error_code,
395        )],
396    }
397}
398
399/// Create a diagnostic from error information
400fn create_diagnostic(
401    message: &str,
402    location: Option<&SourceLocation>,
403    severity: DiagnosticSeverity,
404    source: &str,
405) -> Diagnostic {
406    let range = location_to_range(location);
407
408    // Build message with hints
409    let full_message = if let Some(loc) = location {
410        if !loc.hints.is_empty() {
411            let hints = loc
412                .hints
413                .iter()
414                .map(|h| format!("help: {}", h))
415                .collect::<Vec<_>>()
416                .join("\n");
417            format!("{}\n{}", message, hints)
418        } else {
419            message.to_string()
420        }
421    } else {
422        message.to_string()
423    };
424
425    // Build related information from notes and cross-file locations
426    let related_information = if let Some(loc) = location {
427        let mut related = if !loc.notes.is_empty() {
428            notes_to_related_info(&loc.notes, loc.file.as_deref())
429        } else {
430            Vec::new()
431        };
432
433        // If the error originated in a different file, add a related info entry
434        // pointing to the actual source location so the user can navigate there
435        if let Some(ref file) = loc.file {
436            if let Some(file_uri) = Uri::from_file_path(file) {
437                let line = if loc.line > 0 { loc.line - 1 } else { 0 } as u32;
438                let col = if loc.column > 0 { loc.column - 1 } else { 0 } as u32;
439                let end_char = loc.length.map(|l| col + l as u32).unwrap_or(col + 1);
440                related.push(DiagnosticRelatedInformation {
441                    location: Location {
442                        uri: file_uri,
443                        range: Range {
444                            start: Position {
445                                line,
446                                character: col,
447                            },
448                            end: Position {
449                                line,
450                                character: end_char,
451                            },
452                        },
453                    },
454                    message: format!("error originates in {}", file),
455                });
456            }
457        }
458
459        if related.is_empty() {
460            None
461        } else {
462            Some(related)
463        }
464    } else {
465        None
466    };
467
468    Diagnostic {
469        range,
470        severity: Some(severity),
471        code: None, // Error code is set per-error type
472        code_description: None,
473        source: Some(source.to_string()),
474        message: full_message,
475        related_information,
476        tags: None,
477        data: None,
478    }
479}
480
481/// Create a diagnostic with error code
482fn create_diagnostic_with_code(
483    message: &str,
484    location: Option<&SourceLocation>,
485    severity: DiagnosticSeverity,
486    source: &str,
487    error_code: Option<&str>,
488) -> Diagnostic {
489    let mut diag = create_diagnostic(message, location, severity, source);
490    if let Some(code) = error_code {
491        diag.code = Some(NumberOrString::String(code.to_string()));
492        // W2.3 / 1.17 — attach clickable book URL for the error code.
493        diag.code_description = code_description_for(code);
494    }
495    // W2.3 / 1.19 — derive UNNECESSARY tag from code/message at construction.
496    diag.tags = diagnostic_tags_for(error_code, &diag.message);
497    diag
498}
499
500/// Convert ErrorNotes to LSP DiagnosticRelatedInformation
501fn notes_to_related_info(
502    notes: &[ErrorNote],
503    default_file: Option<&str>,
504) -> Vec<DiagnosticRelatedInformation> {
505    notes
506        .iter()
507        .filter_map(|note| {
508            // Try to create a valid URI for the location
509            let location = note.location.as_ref()?;
510            let file = location.file.as_deref().or(default_file)?;
511            let uri = Uri::from_file_path(file)?;
512
513            let line = if location.line > 0 {
514                location.line - 1
515            } else {
516                0
517            } as u32;
518            let column = if location.column > 0 {
519                location.column - 1
520            } else {
521                0
522            } as u32;
523
524            Some(DiagnosticRelatedInformation {
525                location: Location {
526                    uri,
527                    range: Range {
528                        start: Position {
529                            line,
530                            character: column,
531                        },
532                        end: Position {
533                            line,
534                            character: column + 1,
535                        },
536                    },
537                },
538                message: note.message.clone(),
539            })
540        })
541        .collect()
542}
543
544/// Convert SourceLocation to LSP Range
545fn location_to_range(location: Option<&SourceLocation>) -> Range {
546    // For missing or synthetic locations, highlight the full first line
547    // so the diagnostic is visible rather than a zero-width invisible marker
548    let full_first_line = Range {
549        start: Position {
550            line: 0,
551            character: 0,
552        },
553        end: Position {
554            line: 0,
555            character: 1000,
556        },
557    };
558
559    if let Some(loc) = location {
560        if loc.is_synthetic {
561            return full_first_line;
562        }
563
564        // Shape uses 1-based line/column, LSP uses 0-based
565        let line = if loc.line > 0 { loc.line - 1 } else { 0 } as u32;
566        let column = if loc.column > 0 { loc.column - 1 } else { 0 } as u32;
567
568        let start = Position {
569            line,
570            character: column,
571        };
572
573        // If we have length, calculate end position
574        let end = if let Some(len) = loc.length {
575            Position {
576                line,
577                character: column + len as u32,
578            }
579        } else {
580            // Default to highlighting the whole line if no length
581            Position {
582                line,
583                character: column + 100, // Reasonable default
584            }
585        };
586
587        Range { start, end }
588    } else {
589        full_first_line
590    }
591}
592
593/// Validate annotations in a program and return diagnostics for any issues
594///
595/// Checks:
596/// - Whether annotations are defined (in local file or imports)
597/// - Whether annotation arguments are valid
598pub fn validate_annotations(
599    program: &Program,
600    annotation_discovery: &AnnotationDiscovery,
601    source: &str,
602) -> Vec<Diagnostic> {
603    let mut diagnostics = Vec::new();
604
605    for item in &program.items {
606        let (annotations, span) = match item {
607            Item::Function(func, span) => (&func.annotations, span),
608            Item::ForeignFunction(foreign_fn, span) => (&foreign_fn.annotations, span),
609            _ => continue,
610        };
611        for annotation in annotations {
612            if let Some(diag) = validate_annotation(annotation, span, annotation_discovery, source)
613            {
614                diagnostics.push(diag);
615            }
616        }
617    }
618
619    diagnostics
620}
621
622/// Validate a single annotation usage
623fn validate_annotation(
624    annotation: &Annotation,
625    item_span: &Span,
626    annotation_discovery: &AnnotationDiscovery,
627    source: &str,
628) -> Option<Diagnostic> {
629    let name = &annotation.name;
630
631    // Check if annotation is defined
632    if !annotation_discovery.is_defined(name) {
633        let available: Vec<_> = annotation_discovery
634            .all_annotations()
635            .iter()
636            .map(|a| format!("@{}", a.name))
637            .collect();
638
639        let message = if available.is_empty() {
640            format!("Undefined annotation: @{}", name)
641        } else {
642            format!(
643                "Undefined annotation: @{}. Available: {}",
644                name,
645                available.join(", ")
646            )
647        };
648
649        // Calculate position from span
650        let range = span_to_range(source, item_span);
651
652        return Some(Diagnostic {
653            range,
654            severity: Some(DiagnosticSeverity::ERROR),
655            code: Some(NumberOrString::String("E0100".to_string())),
656            code_description: None,
657            source: Some("shape".to_string()),
658            message,
659            related_information: None,
660            tags: None,
661            data: None,
662        });
663    }
664
665    // Check argument count if annotation is defined
666    if let Some(ann_info) = annotation_discovery.get(name) {
667        let expected = ann_info.params.len();
668        let actual = annotation.args.len();
669
670        // Allow 0 args for optional-arg annotations, or exact match
671        if actual > expected || (actual < expected && expected > 0 && actual > 0) {
672            let range = span_to_range(source, item_span);
673
674            return Some(Diagnostic {
675                range,
676                severity: Some(DiagnosticSeverity::WARNING),
677                code: Some(NumberOrString::String("W0101".to_string())),
678                code_description: None,
679                source: Some("shape".to_string()),
680                message: format!("@{} expects {} argument(s), got {}", name, expected, actual),
681                related_information: None,
682                tags: None,
683                data: None,
684            });
685        }
686    }
687
688    None
689}
690
691/// Validate async join usage: `await join` must be inside an async function.
692///
693/// Walks the AST to find `Expr::Join` nodes that appear outside async function bodies.
694pub fn validate_async_join(program: &Program, source: &str) -> Vec<Diagnostic> {
695    use shape_ast::ast::Expr;
696    use shape_runtime::visitor::{Visitor, walk_program};
697
698    struct AsyncJoinValidator<'a> {
699        source: &'a str,
700        async_depth_stack: Vec<bool>,
701        diagnostics: Vec<Diagnostic>,
702    }
703
704    impl AsyncJoinValidator<'_> {
705        fn is_in_async(&self) -> bool {
706            self.async_depth_stack.last().copied().unwrap_or(false)
707        }
708    }
709
710    impl Visitor for AsyncJoinValidator<'_> {
711        fn visit_function(&mut self, func: &shape_ast::ast::FunctionDef) -> bool {
712            self.async_depth_stack.push(func.is_async);
713            true
714        }
715
716        fn leave_function(&mut self, _func: &shape_ast::ast::FunctionDef) {
717            self.async_depth_stack.pop();
718        }
719
720        fn visit_expr(&mut self, expr: &Expr) -> bool {
721            if let Expr::Join(_, span) = expr {
722                if !self.is_in_async() {
723                    let range = span_to_range(self.source, span);
724                    self.diagnostics.push(Diagnostic {
725                        range,
726                        severity: Some(DiagnosticSeverity::ERROR),
727                        code: Some(NumberOrString::String("E0200".to_string())),
728                        code_description: None,
729                        source: Some("shape".to_string()),
730                        message: "`await join` can only be used inside an async function"
731                            .to_string(),
732                        related_information: None,
733                        tags: None,
734                        data: None,
735                    });
736                }
737            }
738            true
739        }
740    }
741
742    let mut validator = AsyncJoinValidator {
743        source,
744        async_depth_stack: Vec::new(),
745        diagnostics: Vec::new(),
746    };
747    walk_program(&mut validator, program);
748    validator.diagnostics
749}
750
751/// Validate structured concurrency constructs (`async let`, `async scope`, `for await`)
752/// are only used inside async function bodies.
753pub fn validate_async_structured_concurrency(program: &Program, source: &str) -> Vec<Diagnostic> {
754    use shape_ast::ast::Expr;
755    use shape_runtime::visitor::{Visitor, walk_program};
756
757    struct AsyncStructuredValidator<'a> {
758        source: &'a str,
759        async_depth_stack: Vec<bool>,
760        diagnostics: Vec<Diagnostic>,
761    }
762
763    impl AsyncStructuredValidator<'_> {
764        fn is_in_async(&self) -> bool {
765            self.async_depth_stack.last().copied().unwrap_or(false)
766        }
767    }
768
769    impl Visitor for AsyncStructuredValidator<'_> {
770        fn visit_function(&mut self, func: &shape_ast::ast::FunctionDef) -> bool {
771            self.async_depth_stack.push(func.is_async);
772            true
773        }
774
775        fn leave_function(&mut self, _func: &shape_ast::ast::FunctionDef) {
776            self.async_depth_stack.pop();
777        }
778
779        fn visit_expr(&mut self, expr: &Expr) -> bool {
780            match expr {
781                Expr::AsyncLet(_, span) => {
782                    if !self.is_in_async() {
783                        let range = span_to_range(self.source, span);
784                        self.diagnostics.push(Diagnostic {
785                            range,
786                            severity: Some(DiagnosticSeverity::ERROR),
787                            code: Some(NumberOrString::String("E0201".to_string())),
788                            code_description: None,
789                            source: Some("shape".to_string()),
790                            message: "`async let` can only be used inside an async function"
791                                .to_string(),
792                            related_information: None,
793                            tags: None,
794                            data: None,
795                        });
796                    }
797                }
798                Expr::AsyncScope(_, span) => {
799                    if !self.is_in_async() {
800                        let range = span_to_range(self.source, span);
801                        self.diagnostics.push(Diagnostic {
802                            range,
803                            severity: Some(DiagnosticSeverity::ERROR),
804                            code: Some(NumberOrString::String("E0202".to_string())),
805                            code_description: None,
806                            source: Some("shape".to_string()),
807                            message: "`async scope` can only be used inside an async function"
808                                .to_string(),
809                            related_information: None,
810                            tags: None,
811                            data: None,
812                        });
813                    }
814                }
815                Expr::For(for_expr, span) if for_expr.is_async => {
816                    if !self.is_in_async() {
817                        let range = span_to_range(self.source, span);
818                        self.diagnostics.push(Diagnostic {
819                            range,
820                            severity: Some(DiagnosticSeverity::ERROR),
821                            code: Some(NumberOrString::String("E0203".to_string())),
822                            code_description: None,
823                            source: Some("shape".to_string()),
824                            message: "`for await` can only be used inside an async function"
825                                .to_string(),
826                            related_information: None,
827                            tags: None,
828                            data: None,
829                        });
830                    }
831                }
832                _ => {}
833            }
834            true
835        }
836
837        fn visit_stmt(&mut self, stmt: &shape_ast::ast::Statement) -> bool {
838            if let shape_ast::ast::Statement::For(for_loop, span) = stmt {
839                if for_loop.is_async && !self.is_in_async() {
840                    let range = span_to_range(self.source, span);
841                    self.diagnostics.push(Diagnostic {
842                        range,
843                        severity: Some(DiagnosticSeverity::ERROR),
844                        code: Some(NumberOrString::String("E0203".to_string())),
845                        code_description: None,
846                        source: Some("shape".to_string()),
847                        message: "`for await` can only be used inside an async function"
848                            .to_string(),
849                        related_information: None,
850                        tags: None,
851                        data: None,
852                    });
853                }
854            }
855            true
856        }
857    }
858
859    let mut validator = AsyncStructuredValidator {
860        source,
861        async_depth_stack: Vec::new(),
862        diagnostics: Vec::new(),
863    };
864    walk_program(&mut validator, program);
865    validator.diagnostics
866}
867
868/// Validate formatted interpolation specs in `f"..."` string literals.
869///
870/// This catches invalid typed specs (unknown keys, invalid enum values, malformed
871/// spec calls) in the editor without waiting for a full compile pass.
872pub fn validate_interpolation_format_specs(program: &Program, source: &str) -> Vec<Diagnostic> {
873    use shape_ast::interpolation::parse_interpolation_with_mode;
874    use shape_runtime::visitor::{Visitor, walk_program};
875
876    struct InterpolationFormatSpecValidator<'a> {
877        source: &'a str,
878        diagnostics: Vec<Diagnostic>,
879    }
880
881    impl Visitor for InterpolationFormatSpecValidator<'_> {
882        fn visit_expr(&mut self, expr: &Expr) -> bool {
883            if let Expr::Literal(Literal::FormattedString { value, mode }, span) = expr {
884                if let Err(err) = parse_interpolation_with_mode(value, *mode) {
885                    let range = span_to_range(self.source, span);
886                    self.diagnostics.push(Diagnostic {
887                        range,
888                        severity: Some(DiagnosticSeverity::ERROR),
889                        code: Some(NumberOrString::String("E0300".to_string())),
890                        code_description: None,
891                        source: Some("shape".to_string()),
892                        message: format!("Invalid interpolation format spec: {}", err),
893                        related_information: None,
894                        tags: None,
895                        data: None,
896                    });
897                }
898            }
899            true
900        }
901    }
902
903    let mut validator = InterpolationFormatSpecValidator {
904        source,
905        diagnostics: Vec::new(),
906    };
907    walk_program(&mut validator, program);
908    validator.diagnostics
909}
910
911/// Validate type alias overrides: only comptime fields can be overridden.
912///
913/// Checks `type EUR = Currency { symbol: "EUR" }` and reports an error if `symbol`
914/// is not a comptime field of `Currency`.
915pub fn validate_comptime_overrides(program: &Program, source: &str) -> Vec<Diagnostic> {
916    use std::collections::HashMap;
917
918    let mut diagnostics = Vec::new();
919
920    // Collect struct type definitions with their comptime field names
921    let mut struct_comptime_fields: HashMap<String, Vec<String>> = HashMap::new();
922    for item in &program.items {
923        if let Item::StructType(struct_def, _) = item {
924            let comptime_names: Vec<String> = struct_def
925                .fields
926                .iter()
927                .filter(|f| f.is_comptime)
928                .map(|f| f.name.clone())
929                .collect();
930            struct_comptime_fields.insert(struct_def.name.clone(), comptime_names);
931        }
932    }
933
934    // Check type alias overrides
935    for item in &program.items {
936        if let Item::TypeAlias(alias_def, span) = item {
937            if let Some(overrides) = &alias_def.meta_param_overrides {
938                // Get the base type name from the type annotation
939                let base_type = match &alias_def.type_annotation {
940                    shape_ast::ast::TypeAnnotation::Basic(name) => name.clone(),
941                    _ => continue,
942                };
943
944                if let Some(comptime_fields) = struct_comptime_fields.get(&base_type) {
945                    for (field_name, _value) in overrides {
946                        if !comptime_fields.contains(field_name) {
947                            // Find the override position within the span for better error location
948                            let range = span_to_range(source, span);
949                            diagnostics.push(Diagnostic {
950                                range,
951                                severity: Some(DiagnosticSeverity::ERROR),
952                                code: Some(NumberOrString::String("E0300".to_string())),
953                                code_description: None,
954                                source: Some("shape".to_string()),
955                                message: format!(
956                                    "Cannot override field '{}': only comptime fields can be overridden in type alias. \
957                                     '{}' is not a comptime field of '{}'.",
958                                    field_name, field_name, base_type
959                                ),
960                                related_information: None,
961                                tags: None,
962                                data: None,
963                            });
964                        }
965                    }
966                }
967            }
968        }
969    }
970
971    diagnostics
972}
973
974/// Warn when a comptime block contains side-effecting expressions.
975///
976/// Comptime blocks run at compile time, so side effects (print, I/O) are
977/// unexpected and likely mistakes.
978pub fn validate_comptime_side_effects(program: &Program, source: &str) -> Vec<Diagnostic> {
979    let mut diagnostics = Vec::new();
980
981    // Walk top-level items for Item::Comptime and expressions containing Expr::Comptime
982    for item in &program.items {
983        match item {
984            Item::Comptime(stmts, span) => {
985                check_stmts_for_side_effects(stmts, span, source, &mut diagnostics);
986            }
987            _ => {
988                visit_item_exprs(item, source, &mut diagnostics);
989            }
990        }
991    }
992
993    diagnostics
994}
995
996/// Known side-effecting function names that should not appear in comptime blocks.
997const SIDE_EFFECT_FNS: &[&str] = &["print", "println", "debug", "log", "write", "fetch"];
998
999fn check_stmts_for_side_effects(
1000    stmts: &[Statement],
1001    _block_span: &Span,
1002    source: &str,
1003    diagnostics: &mut Vec<Diagnostic>,
1004) {
1005    for stmt in stmts {
1006        check_stmt_for_side_effects(stmt, source, diagnostics);
1007    }
1008}
1009
1010fn check_stmt_for_side_effects(stmt: &Statement, source: &str, diagnostics: &mut Vec<Diagnostic>) {
1011    match stmt {
1012        Statement::Expression(expr, _) => check_expr_for_side_effects(expr, source, diagnostics),
1013        Statement::VariableDecl(decl, _) => {
1014            if let Some(init) = &decl.value {
1015                check_expr_for_side_effects(init, source, diagnostics);
1016            }
1017        }
1018        Statement::Return(Some(expr), _) => check_expr_for_side_effects(expr, source, diagnostics),
1019        Statement::For(for_loop, _) => {
1020            for s in &for_loop.body {
1021                check_stmt_for_side_effects(s, source, diagnostics);
1022            }
1023        }
1024        Statement::While(while_loop, _) => {
1025            for s in &while_loop.body {
1026                check_stmt_for_side_effects(s, source, diagnostics);
1027            }
1028        }
1029        Statement::If(if_stmt, _) => {
1030            for s in &if_stmt.then_body {
1031                check_stmt_for_side_effects(s, source, diagnostics);
1032            }
1033            if let Some(else_body) = &if_stmt.else_body {
1034                for s in else_body {
1035                    check_stmt_for_side_effects(s, source, diagnostics);
1036                }
1037            }
1038        }
1039        _ => {}
1040    }
1041}
1042
1043fn check_expr_for_side_effects(expr: &Expr, source: &str, diagnostics: &mut Vec<Diagnostic>) {
1044    match expr {
1045        Expr::FunctionCall {
1046            name,
1047            span,
1048            args,
1049            named_args,
1050        } => {
1051            if SIDE_EFFECT_FNS.contains(&name.as_str()) {
1052                let range = span_to_range(source, span);
1053                diagnostics.push(Diagnostic {
1054                    range,
1055                    severity: Some(DiagnosticSeverity::WARNING),
1056                    code: Some(NumberOrString::String("W0100".to_string())),
1057                    code_description: None,
1058                    source: Some("shape".to_string()),
1059                    message: format!(
1060                        "Side effect in comptime block: `{}()` performs I/O at compile time. \
1061                         Consider removing or using a comptime-safe alternative.",
1062                        name
1063                    ),
1064                    related_information: None,
1065                    tags: None,
1066                    data: None,
1067                });
1068            }
1069            // Recurse into args
1070            for arg in args {
1071                check_expr_for_side_effects(arg, source, diagnostics);
1072            }
1073            for (_, arg) in named_args {
1074                check_expr_for_side_effects(arg, source, diagnostics);
1075            }
1076        }
1077        Expr::Comptime(stmts, span) => {
1078            // Nested comptime — still check
1079            check_stmts_for_side_effects(stmts, span, source, diagnostics);
1080        }
1081        _ => {}
1082    }
1083}
1084
1085/// Walk an item's expressions looking for Expr::Comptime blocks to validate.
1086fn visit_item_exprs(item: &Item, source: &str, diagnostics: &mut Vec<Diagnostic>) {
1087    // We only need to find Expr::Comptime inside function bodies, variable decls, etc.
1088    match item {
1089        Item::Function(func_def, _) => {
1090            for stmt in &func_def.body {
1091                visit_stmt_for_comptime(stmt, source, diagnostics);
1092            }
1093        }
1094        Item::VariableDecl(decl, _) => {
1095            if let Some(init) = &decl.value {
1096                visit_expr_for_comptime(init, source, diagnostics);
1097            }
1098        }
1099        Item::Expression(expr, _) => {
1100            visit_expr_for_comptime(expr, source, diagnostics);
1101        }
1102        Item::Statement(stmt, _) => {
1103            visit_stmt_for_comptime(stmt, source, diagnostics);
1104        }
1105        _ => {}
1106    }
1107}
1108
1109fn visit_stmt_for_comptime(stmt: &Statement, source: &str, diagnostics: &mut Vec<Diagnostic>) {
1110    match stmt {
1111        Statement::Expression(expr, _) => visit_expr_for_comptime(expr, source, diagnostics),
1112        Statement::VariableDecl(decl, _) => {
1113            if let Some(init) = &decl.value {
1114                visit_expr_for_comptime(init, source, diagnostics);
1115            }
1116        }
1117        Statement::Return(Some(expr), _) => visit_expr_for_comptime(expr, source, diagnostics),
1118        Statement::For(for_loop, _) => {
1119            for s in &for_loop.body {
1120                visit_stmt_for_comptime(s, source, diagnostics);
1121            }
1122        }
1123        Statement::While(while_loop, _) => {
1124            for s in &while_loop.body {
1125                visit_stmt_for_comptime(s, source, diagnostics);
1126            }
1127        }
1128        Statement::If(if_stmt, _) => {
1129            for s in &if_stmt.then_body {
1130                visit_stmt_for_comptime(s, source, diagnostics);
1131            }
1132            if let Some(else_body) = &if_stmt.else_body {
1133                for s in else_body {
1134                    visit_stmt_for_comptime(s, source, diagnostics);
1135                }
1136            }
1137        }
1138        _ => {}
1139    }
1140}
1141
1142fn visit_expr_for_comptime(expr: &Expr, source: &str, diagnostics: &mut Vec<Diagnostic>) {
1143    match expr {
1144        Expr::Comptime(stmts, span) => {
1145            check_stmts_for_side_effects(stmts, span, source, diagnostics);
1146        }
1147        Expr::FunctionCall {
1148            args, named_args, ..
1149        } => {
1150            for arg in args {
1151                visit_expr_for_comptime(arg, source, diagnostics);
1152            }
1153            for (_, arg) in named_args {
1154                visit_expr_for_comptime(arg, source, diagnostics);
1155            }
1156        }
1157        Expr::Conditional {
1158            condition,
1159            then_expr,
1160            else_expr,
1161            ..
1162        } => {
1163            visit_expr_for_comptime(condition, source, diagnostics);
1164            visit_expr_for_comptime(then_expr, source, diagnostics);
1165            if let Some(e) = else_expr {
1166                visit_expr_for_comptime(e, source, diagnostics);
1167            }
1168        }
1169        Expr::BinaryOp { left, right, .. } => {
1170            visit_expr_for_comptime(left, source, diagnostics);
1171            visit_expr_for_comptime(right, source, diagnostics);
1172        }
1173        Expr::UnaryOp { operand, .. } => {
1174            visit_expr_for_comptime(operand, source, diagnostics);
1175        }
1176        _ => {}
1177    }
1178}
1179
1180/// Diagnose comptime-only builtins called outside a `comptime { }` block.
1181pub fn validate_comptime_builtins_context(program: &Program, source: &str) -> Vec<Diagnostic> {
1182    let mut diagnostics = Vec::new();
1183
1184    for item in &program.items {
1185        match item {
1186            Item::Comptime(_, _) => {
1187                // Inside comptime — everything is allowed
1188            }
1189            Item::Function(func_def, _) => {
1190                for stmt in &func_def.body {
1191                    check_stmt_comptime_only(stmt, false, source, &mut diagnostics);
1192                }
1193            }
1194            Item::VariableDecl(decl, _) => {
1195                if let Some(init) = &decl.value {
1196                    check_expr_comptime_only(init, false, source, &mut diagnostics);
1197                }
1198            }
1199            Item::Expression(expr, _) => {
1200                check_expr_comptime_only(expr, false, source, &mut diagnostics);
1201            }
1202            Item::Statement(stmt, _) => {
1203                check_stmt_comptime_only(stmt, false, source, &mut diagnostics);
1204            }
1205            _ => {}
1206        }
1207    }
1208
1209    diagnostics
1210}
1211
1212fn check_stmt_comptime_only(
1213    stmt: &Statement,
1214    in_comptime: bool,
1215    source: &str,
1216    diagnostics: &mut Vec<Diagnostic>,
1217) {
1218    match stmt {
1219        Statement::Expression(expr, _) => {
1220            check_expr_comptime_only(expr, in_comptime, source, diagnostics);
1221        }
1222        Statement::VariableDecl(decl, _) => {
1223            if let Some(init) = &decl.value {
1224                check_expr_comptime_only(init, in_comptime, source, diagnostics);
1225            }
1226        }
1227        Statement::Return(Some(expr), _) => {
1228            check_expr_comptime_only(expr, in_comptime, source, diagnostics);
1229        }
1230        Statement::For(for_loop, _) => {
1231            for s in &for_loop.body {
1232                check_stmt_comptime_only(s, in_comptime, source, diagnostics);
1233            }
1234        }
1235        Statement::While(while_loop, _) => {
1236            for s in &while_loop.body {
1237                check_stmt_comptime_only(s, in_comptime, source, diagnostics);
1238            }
1239        }
1240        Statement::If(if_stmt, _) => {
1241            for s in &if_stmt.then_body {
1242                check_stmt_comptime_only(s, in_comptime, source, diagnostics);
1243            }
1244            if let Some(else_body) = &if_stmt.else_body {
1245                for s in else_body {
1246                    check_stmt_comptime_only(s, in_comptime, source, diagnostics);
1247                }
1248            }
1249        }
1250        _ => {}
1251    }
1252}
1253
1254fn check_expr_comptime_only(
1255    expr: &Expr,
1256    in_comptime: bool,
1257    source: &str,
1258    diagnostics: &mut Vec<Diagnostic>,
1259) {
1260    match expr {
1261        Expr::Comptime(stmts, _) => {
1262            // Inside comptime block — builtins are allowed
1263            for stmt in stmts {
1264                check_stmt_comptime_only(stmt, true, source, diagnostics);
1265            }
1266        }
1267        Expr::FunctionCall {
1268            name,
1269            span,
1270            args,
1271            named_args,
1272        } => {
1273            let is_comptime_only = unified_metadata()
1274                .get_function(name)
1275                .map(|f| f.comptime_only)
1276                .unwrap_or(false);
1277            if !in_comptime && is_comptime_only {
1278                let range = span_to_range(source, span);
1279                diagnostics.push(Diagnostic {
1280                    range,
1281                    severity: Some(DiagnosticSeverity::ERROR),
1282                    code: Some(NumberOrString::String("E0301".to_string())),
1283                    code_description: None,
1284                    source: Some("shape".to_string()),
1285                    message: format!(
1286                        "`{}()` is a comptime-only builtin and can only be called inside a `comptime {{ }}` block.",
1287                        name
1288                    ),
1289                    related_information: None,
1290                    tags: None,
1291                    data: None,
1292                });
1293            }
1294            for arg in args {
1295                check_expr_comptime_only(arg, in_comptime, source, diagnostics);
1296            }
1297            for (_, arg) in named_args {
1298                check_expr_comptime_only(arg, in_comptime, source, diagnostics);
1299            }
1300        }
1301        Expr::Conditional {
1302            condition,
1303            then_expr,
1304            else_expr,
1305            ..
1306        } => {
1307            check_expr_comptime_only(condition, in_comptime, source, diagnostics);
1308            check_expr_comptime_only(then_expr, in_comptime, source, diagnostics);
1309            if let Some(e) = else_expr {
1310                check_expr_comptime_only(e, in_comptime, source, diagnostics);
1311            }
1312        }
1313        Expr::BinaryOp { left, right, .. } => {
1314            check_expr_comptime_only(left, in_comptime, source, diagnostics);
1315            check_expr_comptime_only(right, in_comptime, source, diagnostics);
1316        }
1317        Expr::UnaryOp { operand, .. } => {
1318            check_expr_comptime_only(operand, in_comptime, source, diagnostics);
1319        }
1320        _ => {}
1321    }
1322}
1323
1324/// Validate trait bound satisfaction in impl blocks and function type parameters.
1325///
1326/// Checks:
1327/// - Functions with bounded type params: `fn foo<T: Comparable>(x: T)` — the trait must exist
1328/// - Impl blocks: all required trait methods are implemented
1329pub fn validate_trait_bounds(program: &Program, source: &str) -> Vec<Diagnostic> {
1330    let mut diagnostics = Vec::new();
1331
1332    // Collect known trait definitions and their required method names
1333    let mut trait_methods: std::collections::HashMap<String, Vec<String>> =
1334        std::collections::HashMap::new();
1335    let mut trait_spans: std::collections::HashMap<String, Span> = std::collections::HashMap::new();
1336    for item in &program.items {
1337        if let Item::Trait(trait_def, span) = item {
1338            let required: Vec<String> = trait_def
1339                .members
1340                .iter()
1341                .filter_map(|m| match m {
1342                    shape_ast::ast::TraitMember::Required(
1343                        shape_ast::ast::TraitMemberSignature::Method { name, .. },
1344                    ) => Some(name.clone()),
1345                    _ => None,
1346                })
1347                .collect();
1348            trait_methods.insert(trait_def.name.clone(), required);
1349            trait_spans.insert(trait_def.name.clone(), *span);
1350        }
1351    }
1352
1353    // Check function type parameter trait bounds reference existing traits
1354    for item in &program.items {
1355        if let Item::Function(func, span) = item {
1356            if let Some(type_params) = &func.type_params {
1357                for tp in type_params {
1358                    // Const generics have no trait bounds (`trait_bounds()`
1359                    // returns an empty slice for the Const variant); this
1360                    // loop simply skips them until B.3 gives const generics
1361                    // their own predicate story.
1362                    for bound in tp.trait_bounds() {
1363                        if !trait_methods.contains_key(bound.as_str()) {
1364                            let range = span_to_range(source, span);
1365                            diagnostics.push(Diagnostic {
1366                                range,
1367                                severity: Some(DiagnosticSeverity::ERROR),
1368                                code: Some(NumberOrString::String("E0400".to_string())),
1369                                code_description: None,
1370                                source: Some("shape".to_string()),
1371                                message: format!(
1372                                    "Trait bound '{}' on type parameter '{}' refers to an undefined trait.",
1373                                    bound, tp.name()
1374                                ),
1375                                related_information: None,
1376                                tags: None,
1377                                data: None,
1378                            });
1379                        }
1380                    }
1381                }
1382            }
1383        }
1384    }
1385
1386    // Check impl blocks: all required methods are implemented
1387    for item in &program.items {
1388        if let Item::Impl(impl_block, span) = item {
1389            let trait_name = match &impl_block.trait_name {
1390                shape_ast::ast::TypeName::Simple(n) => n.to_string(),
1391                shape_ast::ast::TypeName::Generic { name, .. } => name.to_string(),
1392            };
1393            let target_type = match &impl_block.target_type {
1394                shape_ast::ast::TypeName::Simple(n) => n.to_string(),
1395                shape_ast::ast::TypeName::Generic { name, .. } => name.to_string(),
1396            };
1397
1398            if let Some(required_methods) = trait_methods.get(&trait_name) {
1399                let implemented: Vec<String> =
1400                    impl_block.methods.iter().map(|m| m.name.clone()).collect();
1401                for required in required_methods {
1402                    if !implemented.contains(required) {
1403                        let range = span_to_range(source, span);
1404                        diagnostics.push(Diagnostic {
1405                            range,
1406                            severity: Some(DiagnosticSeverity::ERROR),
1407                            code: Some(NumberOrString::String("E0401".to_string())),
1408                            code_description: None,
1409                            source: Some("shape".to_string()),
1410                            message: format!(
1411                                "Missing required method '{}' in impl {} for {}.",
1412                                required, trait_name, target_type
1413                            ),
1414                            related_information: None,
1415                            tags: None,
1416                            data: None,
1417                        });
1418                    }
1419                }
1420            }
1421        }
1422    }
1423
1424    diagnostics
1425}
1426
1427/// Validate Content API calls.
1428///
1429/// - Warn on `Color.rgb()` with values outside 0-255
1430pub fn validate_color_rgb_range(program: &Program, source: &str) -> Vec<Diagnostic> {
1431    use shape_runtime::visitor::{Visitor, walk_program};
1432
1433    struct ColorRgbValidator<'a> {
1434        source: &'a str,
1435        diagnostics: Vec<Diagnostic>,
1436    }
1437
1438    impl Visitor for ColorRgbValidator<'_> {
1439        fn visit_expr(&mut self, expr: &Expr) -> bool {
1440            // Check Color.rgb(r, g, b) for out-of-range values
1441            if let Expr::MethodCall {
1442                receiver,
1443                method,
1444                args,
1445                span,
1446                ..
1447            } = expr
1448            {
1449                if method == "rgb" {
1450                    if let Expr::Identifier(name, _) = receiver.as_ref() {
1451                        if name == "Color" {
1452                            for arg in args {
1453                                let out_of_range = match arg {
1454                                    Expr::Literal(Literal::Int(v), _) => *v < 0 || *v > 255,
1455                                    Expr::Literal(Literal::Number(v), _) => {
1456                                        (*v as i64) < 0 || (*v as i64) > 255
1457                                    }
1458                                    _ => false,
1459                                };
1460                                if out_of_range {
1461                                    let val_str = match arg {
1462                                        Expr::Literal(Literal::Int(v), _) => v.to_string(),
1463                                        Expr::Literal(Literal::Number(v), _) => v.to_string(),
1464                                        _ => String::new(),
1465                                    };
1466                                    let range = span_to_range(self.source, span);
1467                                    self.diagnostics.push(Diagnostic {
1468                                        range,
1469                                        severity: Some(DiagnosticSeverity::WARNING),
1470                                        code: Some(NumberOrString::String("W0310".to_string())),
1471                                        code_description: None,
1472                                        source: Some("shape".to_string()),
1473                                        message: format!(
1474                                            "Color.rgb() component value {} is outside the valid range 0-255.",
1475                                            val_str
1476                                        ),
1477                                        related_information: None,
1478                                        tags: None,
1479                                        data: None,
1480                                    });
1481                                }
1482                            }
1483                        }
1484                    }
1485                }
1486            }
1487            true
1488        }
1489    }
1490
1491    let mut validator = ColorRgbValidator {
1492        source,
1493        diagnostics: Vec::new(),
1494    };
1495    walk_program(&mut validator, program);
1496    validator.diagnostics
1497}
1498
1499/// Validate that foreign function parameters and return types are explicitly annotated.
1500///
1501/// Foreign function bodies are opaque — the type system cannot infer types from them.
1502/// Uses `ForeignFunctionDef::validate_type_annotations()` (shared with the compiler).
1503pub fn validate_foreign_function_types(program: &Program, source: &str) -> Vec<Diagnostic> {
1504    let mut diagnostics = Vec::new();
1505
1506    for item in &program.items {
1507        let foreign_fn = match item {
1508            Item::ForeignFunction(f, _) => f,
1509            Item::Export(export, _) => {
1510                if let shape_ast::ast::ExportItem::ForeignFunction(f) = &export.item {
1511                    f
1512                } else {
1513                    continue;
1514                }
1515            }
1516            _ => continue,
1517        };
1518
1519        for (msg, span) in foreign_fn.validate_type_annotations(true) {
1520            let range = if span.is_dummy() {
1521                span_to_range(source, &foreign_fn.name_span)
1522            } else {
1523                span_to_range(source, &span)
1524            };
1525            diagnostics.push(Diagnostic {
1526                range,
1527                severity: Some(DiagnosticSeverity::ERROR),
1528                code: Some(NumberOrString::String("E0400".to_string())),
1529                code_description: None,
1530                source: Some("shape".to_string()),
1531                message: msg,
1532                related_information: None,
1533                tags: None,
1534                data: None,
1535            });
1536        }
1537    }
1538
1539    diagnostics
1540}
1541
1542// ─── MIR borrow analysis → LSP diagnostics ─────────────────────────────────
1543//
1544// The compiler already converts MIR `BorrowError`/`MutabilityError` into
1545// `ShapeError::SemanticError` which flows through `error_to_diagnostic`.
1546// This module provides an *alternative* path that produces richer LSP
1547// diagnostics directly from the structured analysis data (proper error
1548// codes, `DiagnosticRelatedInformation` for borrow origins, etc.).
1549//
1550// Usage: call `borrow_analysis_to_diagnostics` after a successful or
1551// recovered compilation to surface borrow warnings that the compiler's
1552// `RecoverAll` mode collected but did not promote to hard errors.
1553
1554/// Convert structured MIR borrow errors into LSP diagnostics.
1555///
1556/// This produces higher-fidelity diagnostics than the `error_to_diagnostic`
1557/// path because it has direct access to `BorrowError` fields:
1558/// - Sets `code` to the unified `BorrowErrorCode` (`B0001`..`B0007`).
1559/// - Attaches `DiagnosticRelatedInformation` for the loan origin span
1560///   and the last-use span (if available).
1561pub fn borrow_analysis_to_diagnostics(
1562    analysis: &shape_vm::mir::analysis::BorrowAnalysis,
1563    source: &str,
1564    uri: &Uri,
1565) -> Vec<Diagnostic> {
1566    let mut diagnostics = Vec::new();
1567
1568    for error in &analysis.errors {
1569        let code = error.kind.code();
1570
1571        let primary_range = span_to_range(source, &error.span);
1572
1573        let message = borrow_error_message(&error.kind, code);
1574
1575        // Build related-information entries.
1576        let mut related = Vec::new();
1577
1578        // 1. Where the conflicting loan was created.
1579        let loan_range = span_to_range(source, &error.loan_span);
1580        related.push(DiagnosticRelatedInformation {
1581            location: Location {
1582                uri: uri.clone(),
1583                range: loan_range,
1584            },
1585            message: borrow_origin_note(&error.kind),
1586        });
1587
1588        // 2. Where the loan is still needed (last use).
1589        if let Some(last_use) = error.last_use_span {
1590            let last_use_range = span_to_range(source, &last_use);
1591            related.push(DiagnosticRelatedInformation {
1592                location: Location {
1593                    uri: uri.clone(),
1594                    range: last_use_range,
1595                },
1596                message: "borrow is still needed here".to_string(),
1597            });
1598        }
1599
1600        // Build hint text from repair suggestions.
1601        let hint = if let Some(repair) = error.repairs.first() {
1602            format!(
1603                "help: {}\nhelp: {}",
1604                borrow_error_hint(&error.kind),
1605                repair.description
1606            )
1607        } else {
1608            format!("help: {}", borrow_error_hint(&error.kind))
1609        };
1610
1611        diagnostics.push(Diagnostic {
1612            range: primary_range,
1613            severity: Some(DiagnosticSeverity::ERROR),
1614            code: Some(NumberOrString::String(code.as_str().to_string())),
1615            code_description: None,
1616            source: Some("shape-borrow".to_string()),
1617            message: format!("{}\n{}", message, hint),
1618            related_information: Some(related),
1619            tags: None,
1620            data: None,
1621        });
1622    }
1623
1624    for error in &analysis.mutability_errors {
1625        let primary_range = span_to_range(source, &error.span);
1626
1627        let binding_kind = if error.is_const {
1628            "const"
1629        } else if error.is_explicit_let {
1630            "let"
1631        } else {
1632            "immutable"
1633        };
1634
1635        let message = format!(
1636            "cannot assign to {} binding '{}'",
1637            binding_kind, error.variable_name
1638        );
1639
1640        let decl_range = span_to_range(source, &error.declaration_span);
1641        let related = vec![DiagnosticRelatedInformation {
1642            location: Location {
1643                uri: uri.clone(),
1644                range: decl_range,
1645            },
1646            message: format!("'{}' declared here", error.variable_name),
1647        }];
1648
1649        diagnostics.push(Diagnostic {
1650            range: primary_range,
1651            severity: Some(DiagnosticSeverity::ERROR),
1652            code: Some(NumberOrString::String("E0384".to_string())),
1653            code_description: None,
1654            source: Some("shape-borrow".to_string()),
1655            message: format!(
1656                "{}\nhelp: consider changing '{}' to 'let mut {}' or 'var {}'",
1657                message, error.variable_name, error.variable_name, error.variable_name
1658            ),
1659            related_information: Some(related),
1660            tags: None,
1661            data: None,
1662        });
1663    }
1664
1665    diagnostics
1666}
1667
1668/// Human-readable message for a borrow error kind (with code prefix).
1669fn borrow_error_message(
1670    kind: &shape_vm::mir::analysis::BorrowErrorKind,
1671    code: shape_vm::mir::analysis::BorrowErrorCode,
1672) -> String {
1673    use shape_vm::mir::analysis::BorrowErrorKind;
1674    let body = match kind {
1675        BorrowErrorKind::ConflictSharedExclusive => {
1676            "cannot mutably borrow this value while shared borrows are active"
1677        }
1678        BorrowErrorKind::ConflictExclusiveExclusive => {
1679            "cannot mutably borrow this value because it is already borrowed"
1680        }
1681        BorrowErrorKind::ReadWhileExclusivelyBorrowed => {
1682            "cannot read this value while it is mutably borrowed"
1683        }
1684        BorrowErrorKind::WriteWhileBorrowed => {
1685            "cannot write to this value while it is borrowed"
1686        }
1687        BorrowErrorKind::ReferenceEscape => {
1688            "cannot return or store a reference that outlives its owner"
1689        }
1690        BorrowErrorKind::ReferenceStoredInArray => {
1691            "cannot store a reference in an array"
1692        }
1693        BorrowErrorKind::ReferenceStoredInObject => {
1694            "cannot store a reference in an object or struct literal"
1695        }
1696        BorrowErrorKind::ReferenceStoredInEnum => {
1697            "cannot store a reference in an enum payload"
1698        }
1699        BorrowErrorKind::ReferenceEscapeIntoClosure => {
1700            "reference cannot escape into a closure"
1701        }
1702        BorrowErrorKind::UseAfterMove => {
1703            "cannot use this value after it was moved"
1704        }
1705        BorrowErrorKind::ExclusiveRefAcrossTaskBoundary => {
1706            "cannot move an exclusive reference across a task boundary"
1707        }
1708        BorrowErrorKind::SharedRefAcrossDetachedTask => {
1709            "cannot send a shared reference across a detached task boundary"
1710        }
1711        BorrowErrorKind::InconsistentReferenceReturn => {
1712            "reference-returning functions must return a reference on every path from the same borrowed origin and borrow kind"
1713        }
1714        BorrowErrorKind::CallSiteAliasConflict => {
1715            "cannot pass the same variable to multiple parameters that conflict on aliasing"
1716        }
1717        BorrowErrorKind::NonSendableAcrossTaskBoundary => {
1718            "cannot send a non-sendable value across a task boundary"
1719        }
1720    };
1721    format!("[{}] {}", code, body)
1722}
1723
1724/// Hint text for a borrow error kind.
1725fn borrow_error_hint(kind: &shape_vm::mir::analysis::BorrowErrorKind) -> &'static str {
1726    use shape_vm::mir::analysis::BorrowErrorKind;
1727    match kind {
1728        BorrowErrorKind::ConflictSharedExclusive => {
1729            "move the mutable borrow later, or end the shared borrow sooner"
1730        }
1731        BorrowErrorKind::ConflictExclusiveExclusive => {
1732            "end the previous mutable borrow before creating another one"
1733        }
1734        BorrowErrorKind::ReadWhileExclusivelyBorrowed => {
1735            "read through the existing reference, or move the read after the borrow ends"
1736        }
1737        BorrowErrorKind::WriteWhileBorrowed => "move this write after the borrow ends",
1738        BorrowErrorKind::ReferenceEscape => "return an owned value instead of a reference",
1739        BorrowErrorKind::ReferenceStoredInArray
1740        | BorrowErrorKind::ReferenceStoredInObject
1741        | BorrowErrorKind::ReferenceStoredInEnum => {
1742            "store owned values instead of references"
1743        }
1744        BorrowErrorKind::ReferenceEscapeIntoClosure => {
1745            "capture an owned value instead of a reference"
1746        }
1747        BorrowErrorKind::UseAfterMove => {
1748            "clone the value before moving it, or stop using the original after the move"
1749        }
1750        BorrowErrorKind::ExclusiveRefAcrossTaskBoundary => {
1751            "keep the mutable reference within the current task or pass an owned value instead"
1752        }
1753        BorrowErrorKind::SharedRefAcrossDetachedTask => {
1754            "clone the value before sending it to a detached task, or use a structured task instead"
1755        }
1756        BorrowErrorKind::InconsistentReferenceReturn => {
1757            "return a reference from the same borrowed origin on every path, or return owned values instead"
1758        }
1759        BorrowErrorKind::CallSiteAliasConflict => {
1760            "use separate variables for each argument, or clone one of them"
1761        }
1762        BorrowErrorKind::NonSendableAcrossTaskBoundary => {
1763            "clone the captured state or use an owned value that is safe to send across tasks"
1764        }
1765    }
1766}
1767
1768/// Note text for the related-information entry pointing at the loan origin.
1769fn borrow_origin_note(kind: &shape_vm::mir::analysis::BorrowErrorKind) -> String {
1770    use shape_vm::mir::analysis::BorrowErrorKind;
1771    match kind {
1772        BorrowErrorKind::ConflictSharedExclusive
1773        | BorrowErrorKind::ConflictExclusiveExclusive
1774        | BorrowErrorKind::ReadWhileExclusivelyBorrowed
1775        | BorrowErrorKind::WriteWhileBorrowed => "conflicting borrow originates here".to_string(),
1776        BorrowErrorKind::ReferenceEscape
1777        | BorrowErrorKind::ReferenceStoredInArray
1778        | BorrowErrorKind::ReferenceStoredInObject
1779        | BorrowErrorKind::ReferenceStoredInEnum
1780        | BorrowErrorKind::ReferenceEscapeIntoClosure
1781        | BorrowErrorKind::ExclusiveRefAcrossTaskBoundary
1782        | BorrowErrorKind::SharedRefAcrossDetachedTask => {
1783            "reference originates here".to_string()
1784        }
1785        BorrowErrorKind::UseAfterMove => "value was moved here".to_string(),
1786        BorrowErrorKind::InconsistentReferenceReturn => {
1787            "borrowed origin on another return path originates here".to_string()
1788        }
1789        BorrowErrorKind::CallSiteAliasConflict => {
1790            "conflicting arguments originate here".to_string()
1791        }
1792        BorrowErrorKind::NonSendableAcrossTaskBoundary => {
1793            "non-sendable value originates here".to_string()
1794        }
1795    }
1796}
1797
1798// ─── W2.3 / 1.19 — unused-import lint ──────────────────────────────────────
1799
1800/// W2.3 / 1.19 — detect unused imports and emit `W0102` diagnostics
1801/// tagged with `DiagnosticTag::UNNECESSARY` (via the metadata enricher).
1802///
1803/// A named import (`from path use { Foo, Bar as B }`) is "unused" when
1804/// the local binding (`Foo` or alias `B`) never appears as an identifier
1805/// in the rest of the program. Namespace imports (`use path` /
1806/// `use path as alias`) are skipped — namespace use detection requires
1807/// qualified-name resolution that lives in the compiler proper and is
1808/// too noisy for an LSP-side scan.
1809///
1810/// Annotation imports (`is_annotation`) are skipped — their use sites
1811/// are `@name`-prefixed and the simple identifier scan would miss them.
1812///
1813/// Identifiers prefixed with `_` are intentionally-unused per convention
1814/// and never flagged.
1815///
1816/// The detection uses both:
1817/// 1. An AST visitor for `Expr::Identifier` / `Expr::FunctionCall` /
1818///    `Expr::MethodCall` / `Expr::PropertyAccess` references.
1819/// 2. A whole-word text-search fallback over the source outside the
1820///    import statements for occurrences (type annotations, impl-block
1821///    trait names, generic args) the Expr visitor doesn't reach.
1822///
1823/// Over-counting (false-negatives — failing to flag a truly unused
1824/// import) is preferred over false-positives — flagging an actually-used
1825/// import is a worse user experience than a missing diagnostic.
1826pub fn validate_unused_imports(program: &Program, source: &str) -> Vec<Diagnostic> {
1827    use shape_runtime::visitor::{Visitor, walk_program};
1828    use std::collections::HashSet;
1829
1830    struct ImportInfo {
1831        local_name: String,
1832        span: Span,
1833    }
1834
1835    let mut imports: Vec<ImportInfo> = Vec::new();
1836    for item in &program.items {
1837        if let Item::Import(import_stmt, span) = item {
1838            if let shape_ast::ast::ImportItems::Named(specs) = &import_stmt.items {
1839                for spec in specs {
1840                    if spec.is_annotation {
1841                        continue;
1842                    }
1843                    let local = spec.alias.clone().unwrap_or_else(|| spec.name.clone());
1844                    if local.starts_with('_') {
1845                        continue;
1846                    }
1847                    imports.push(ImportInfo {
1848                        local_name: local,
1849                        span: *span,
1850                    });
1851                }
1852            }
1853        }
1854    }
1855
1856    if imports.is_empty() {
1857        return Vec::new();
1858    }
1859
1860    struct IdentCollector {
1861        names: HashSet<String>,
1862    }
1863    impl Visitor for IdentCollector {
1864        fn visit_expr(&mut self, expr: &Expr) -> bool {
1865            match expr {
1866                Expr::Identifier(name, _) => {
1867                    self.names.insert(name.clone());
1868                }
1869                Expr::FunctionCall { name, .. } => {
1870                    self.names.insert(name.clone());
1871                }
1872                Expr::MethodCall { method, .. } => {
1873                    self.names.insert(method.clone());
1874                }
1875                Expr::PropertyAccess { property, .. } => {
1876                    self.names.insert(property.clone());
1877                }
1878                _ => {}
1879            }
1880            true
1881        }
1882    }
1883
1884    let mut collector = IdentCollector {
1885        names: HashSet::new(),
1886    };
1887    walk_program(&mut collector, program);
1888
1889    let mut diagnostics = Vec::new();
1890    for info in &imports {
1891        if collector.names.contains(&info.local_name) {
1892            continue;
1893        }
1894        if source_references_name_outside_span(source, &info.local_name, info.span) {
1895            continue;
1896        }
1897        let range = span_to_range(source, &info.span);
1898        let diag = Diagnostic {
1899            range,
1900            severity: Some(DiagnosticSeverity::WARNING),
1901            code: Some(NumberOrString::String("W0102".to_string())),
1902            code_description: code_description_for("W0102"),
1903            source: Some("shape".to_string()),
1904            message: format!(
1905                "unused import '{}': the imported symbol is never referenced in this file",
1906                info.local_name
1907            ),
1908            related_information: None,
1909            tags: Some(vec![DiagnosticTag::UNNECESSARY]),
1910            data: None,
1911        };
1912        diagnostics.push(diag);
1913    }
1914    diagnostics
1915}
1916
1917/// Whole-word text search for `name` in `source`, excluding bytes
1918/// covered by `exclude_span` (the import statement itself). Used as a
1919/// fallback by `validate_unused_imports` for occurrences (type
1920/// annotations, impl-block trait names, generic args) the Expr visitor
1921/// doesn't reach.
1922fn source_references_name_outside_span(source: &str, name: &str, exclude_span: Span) -> bool {
1923    if name.is_empty() {
1924        return false;
1925    }
1926    let bytes = source.as_bytes();
1927    let name_bytes = name.as_bytes();
1928    let mut i = 0usize;
1929    while i + name_bytes.len() <= bytes.len() {
1930        if i >= exclude_span.start && i < exclude_span.end {
1931            i = exclude_span.end;
1932            continue;
1933        }
1934        let prev_is_word = i > 0 && is_ident_byte(bytes[i - 1]);
1935        let next_idx = i + name_bytes.len();
1936        let next_is_word = next_idx < bytes.len() && is_ident_byte(bytes[next_idx]);
1937        if !prev_is_word && !next_is_word && &bytes[i..next_idx] == name_bytes {
1938            return true;
1939        }
1940        i += 1;
1941    }
1942    false
1943}
1944
1945fn is_ident_byte(b: u8) -> bool {
1946    b.is_ascii_alphanumeric() || b == b'_'
1947}
1948
1949#[cfg(test)]
1950mod tests {
1951    use super::*;
1952    use crate::util::offset_to_line_col;
1953
1954    #[test]
1955    fn test_location_to_range() {
1956        // Test with location
1957        let loc = SourceLocation::new(5, 10);
1958        let range = location_to_range(Some(&loc));
1959
1960        assert_eq!(range.start.line, 4); // 0-based
1961        assert_eq!(range.start.character, 9); // 0-based
1962
1963        // Test without location
1964        let range = location_to_range(None);
1965        assert_eq!(range.start.line, 0);
1966        assert_eq!(range.start.character, 0);
1967    }
1968
1969    #[test]
1970    fn test_parse_error_diagnostic() {
1971        let error = ShapeError::ParseError {
1972            message: "Expected expression".to_string(),
1973            location: Some(SourceLocation::new(10, 5)),
1974        };
1975
1976        let diagnostics = error_to_diagnostic(&error);
1977        assert_eq!(diagnostics.len(), 1);
1978        assert_eq!(diagnostics[0].message, "Expected expression");
1979        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
1980        assert_eq!(diagnostics[0].source.as_deref(), Some("shape"));
1981        assert_eq!(diagnostics[0].range.start.line, 9); // 0-based
1982    }
1983
1984    #[test]
1985    fn test_semantic_error_diagnostic() {
1986        let error = ShapeError::SemanticError {
1987            message: "Undefined variable 'x'".to_string(),
1988            location: Some(SourceLocation::new(3, 7)),
1989        };
1990
1991        let diagnostics = error_to_diagnostic(&error);
1992        assert_eq!(diagnostics.len(), 1);
1993        assert_eq!(diagnostics[0].message, "Undefined variable 'x'");
1994        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
1995        assert_eq!(diagnostics[0].source.as_deref(), Some("shape"));
1996    }
1997
1998    #[test]
1999    fn test_multi_error_flattening() {
2000        let multi_error = ShapeError::MultiError(vec![
2001            ShapeError::SemanticError {
2002                message: "Undefined variable 'x'".to_string(),
2003                location: Some(SourceLocation::new(1, 1)),
2004            },
2005            ShapeError::SemanticError {
2006                message: "Undefined variable 'y'".to_string(),
2007                location: Some(SourceLocation::new(2, 1)),
2008            },
2009        ]);
2010
2011        let diagnostics = error_to_diagnostic(&multi_error);
2012        assert_eq!(
2013            diagnostics.len(),
2014            2,
2015            "MultiError should flatten into 2 diagnostics"
2016        );
2017        assert!(diagnostics[0].message.contains("x"));
2018        assert!(diagnostics[1].message.contains("y"));
2019    }
2020
2021    #[test]
2022    fn test_multi_error_display() {
2023        let multi_error = ShapeError::MultiError(vec![
2024            ShapeError::SemanticError {
2025                message: "Error one".to_string(),
2026                location: None,
2027            },
2028            ShapeError::SemanticError {
2029                message: "Error two".to_string(),
2030                location: None,
2031            },
2032        ]);
2033
2034        let display = multi_error.to_string();
2035        assert!(
2036            display.contains("Error one"),
2037            "Display should contain first error"
2038        );
2039        assert!(
2040            display.contains("Error two"),
2041            "Display should contain second error"
2042        );
2043    }
2044
2045    #[test]
2046    fn test_offset_to_line_col() {
2047        let source = "line1\nline2\nline3";
2048
2049        // Start of file
2050        assert_eq!(offset_to_line_col(source, 0), (0, 0));
2051
2052        // End of first line
2053        assert_eq!(offset_to_line_col(source, 5), (0, 5));
2054
2055        // Start of second line
2056        assert_eq!(offset_to_line_col(source, 6), (1, 0));
2057
2058        // Middle of second line
2059        assert_eq!(offset_to_line_col(source, 8), (1, 2));
2060    }
2061
2062    #[test]
2063    fn test_validate_annotations_with_defined() {
2064        use shape_ast::parser::parse_program;
2065
2066        // Define @my_ann locally, then use it on a function
2067        let source = r#"
2068annotation my_ann() {
2069    on_define(fn, ctx) {
2070        ctx.registry("items").set(fn.name, fn)
2071    }
2072}
2073
2074@my_ann
2075function my_func(x) {
2076    return x + 1;
2077}
2078"#;
2079
2080        let program = parse_program(source).unwrap();
2081        let mut discovery = AnnotationDiscovery::new();
2082        discovery.discover_from_program(&program);
2083
2084        let diagnostics = validate_annotations(&program, &discovery, source);
2085
2086        // @my_ann is defined locally, so no errors
2087        assert!(
2088            diagnostics.is_empty(),
2089            "Expected no diagnostics for defined annotation, got: {:?}",
2090            diagnostics
2091        );
2092    }
2093
2094    #[test]
2095    fn test_validate_annotations_with_undefined() {
2096        use shape_ast::parser::parse_program;
2097
2098        let source = r#"
2099@undefined_annotation
2100function my_func() {
2101    return None;
2102}
2103"#;
2104
2105        let program = parse_program(source).unwrap();
2106        let mut discovery = AnnotationDiscovery::new();
2107        discovery.discover_from_program(&program);
2108
2109        let diagnostics = validate_annotations(&program, &discovery, source);
2110
2111        // @undefined_annotation is not defined anywhere
2112        assert_eq!(
2113            diagnostics.len(),
2114            1,
2115            "Expected 1 diagnostic for undefined annotation"
2116        );
2117        assert!(diagnostics[0].message.contains("Undefined annotation"));
2118        assert!(diagnostics[0].message.contains("undefined_annotation"));
2119    }
2120
2121    #[test]
2122    fn test_validate_trait_bounds_missing_method() {
2123        use shape_ast::parser::parse_program;
2124
2125        // Fixture migrated to Form B per cd7d97a4 (2026-05-18) grammar surgery.
2126        let source = "trait Queryable {\n    method filter(self, pred) -> any;\n    method select(self, cols) -> any;\n}\nimpl Queryable for MyTable {\n    method filter(pred) { self }\n}\n";
2127        let program = parse_program(source).unwrap();
2128        let diagnostics = validate_trait_bounds(&program, source);
2129
2130        assert_eq!(
2131            diagnostics.len(),
2132            1,
2133            "Should report 1 missing method error, got: {:?}",
2134            diagnostics
2135        );
2136        assert!(diagnostics[0].message.contains("Missing required method"));
2137        assert!(diagnostics[0].message.contains("select"));
2138    }
2139
2140    #[test]
2141    fn test_validate_trait_bounds_all_implemented() {
2142        use shape_ast::parser::parse_program;
2143
2144        // Fixture migrated to Form B per cd7d97a4 (2026-05-18) grammar surgery.
2145        let source = "trait Queryable {\n    method filter(self, pred) -> any;\n    method select(self, cols) -> any;\n}\nimpl Queryable for MyTable {\n    method filter(pred) { self }\n    method select(cols) { self }\n}\n";
2146        let program = parse_program(source).unwrap();
2147        let diagnostics = validate_trait_bounds(&program, source);
2148
2149        assert_eq!(
2150            diagnostics.len(),
2151            0,
2152            "Should report no errors when all methods implemented"
2153        );
2154    }
2155
2156    #[test]
2157    fn test_validate_trait_bounds_undefined_trait_in_bound() {
2158        use shape_ast::parser::parse_program;
2159
2160        let source = "fn foo<T: NonExistent>(x: T) {\n    x\n}\n";
2161        let program = parse_program(source).unwrap();
2162        let diagnostics = validate_trait_bounds(&program, source);
2163
2164        assert_eq!(
2165            diagnostics.len(),
2166            1,
2167            "Should report undefined trait in bound"
2168        );
2169        assert!(diagnostics[0].message.contains("NonExistent"));
2170        assert!(diagnostics[0].message.contains("undefined trait"));
2171    }
2172
2173    #[test]
2174    fn test_validate_trait_bounds_valid_bound() {
2175        use shape_ast::parser::parse_program;
2176
2177        // Fixture migrated to Form B per cd7d97a4 (2026-05-18) grammar surgery.
2178        let source = "trait Comparable {\n    method compare(self, other) -> number;\n}\nfn foo<T: Comparable>(x: T) {\n    x\n}\n";
2179        let program = parse_program(source).unwrap();
2180        let diagnostics = validate_trait_bounds(&program, source);
2181
2182        assert_eq!(
2183            diagnostics.len(),
2184            0,
2185            "Should report no errors for valid trait bound"
2186        );
2187    }
2188
2189    #[test]
2190    fn test_validate_async_join_outside_async() {
2191        use shape_ast::parser::parse_program;
2192
2193        let source = "fn foo() {\n  let x = await join all {\n    1,\n    2\n  }\n}";
2194        let program = parse_program(source).unwrap();
2195        let diagnostics = validate_async_join(&program, source);
2196
2197        assert_eq!(
2198            diagnostics.len(),
2199            1,
2200            "Should report error for join outside async function"
2201        );
2202        assert!(
2203            diagnostics[0].message.contains("async"),
2204            "Error should mention async, got: {}",
2205            diagnostics[0].message
2206        );
2207    }
2208
2209    #[test]
2210    fn test_validate_async_join_inside_async() {
2211        use shape_ast::parser::parse_program;
2212
2213        let source = "async fn foo() {\n  let x = await join all {\n    1,\n    2\n  }\n}";
2214        let program = parse_program(source).unwrap();
2215        let diagnostics = validate_async_join(&program, source);
2216
2217        assert_eq!(
2218            diagnostics.len(),
2219            0,
2220            "Should not report error for join inside async function"
2221        );
2222    }
2223
2224    #[test]
2225    fn test_validate_async_join_top_level() {
2226        use shape_ast::parser::parse_program;
2227
2228        // Join at top level (not inside any function) should be an error
2229        let source = "let x = await join race {\n  1,\n  2\n}";
2230        let program = parse_program(source).unwrap();
2231        let diagnostics = validate_async_join(&program, source);
2232
2233        assert_eq!(
2234            diagnostics.len(),
2235            1,
2236            "Should report error for join at top level"
2237        );
2238    }
2239
2240    #[test]
2241    fn test_validate_comptime_side_effects_with_print() {
2242        use shape_ast::parser::parse_program;
2243
2244        let source = "comptime {\n  print(\"hello\")\n}";
2245        let program = parse_program(source).unwrap();
2246        let diagnostics = validate_comptime_side_effects(&program, source);
2247
2248        assert_eq!(
2249            diagnostics.len(),
2250            1,
2251            "Should warn about print() in comptime block"
2252        );
2253        assert!(
2254            diagnostics[0].message.contains("print"),
2255            "Warning should mention print"
2256        );
2257        assert_eq!(
2258            diagnostics[0].severity,
2259            Some(DiagnosticSeverity::WARNING),
2260            "Should be a warning, not an error"
2261        );
2262    }
2263
2264    #[test]
2265    fn test_validate_comptime_side_effects_clean() {
2266        use shape_ast::parser::parse_program;
2267
2268        let source = "comptime {\n  let x = 42\n}";
2269        let program = parse_program(source).unwrap();
2270        let diagnostics = validate_comptime_side_effects(&program, source);
2271
2272        assert_eq!(
2273            diagnostics.len(),
2274            0,
2275            "Pure comptime block should have no warnings"
2276        );
2277    }
2278
2279    #[test]
2280    fn test_validate_comptime_side_effects_nested_in_function() {
2281        use shape_ast::parser::parse_program;
2282
2283        let source = "fn foo() {\n  let x = comptime {\n    print(\"debug\")\n  }\n}\n";
2284        let program = parse_program(source).unwrap();
2285        let diagnostics = validate_comptime_side_effects(&program, source);
2286
2287        assert_eq!(
2288            diagnostics.len(),
2289            1,
2290            "Should warn about print() in nested comptime block, got: {:?}",
2291            diagnostics
2292        );
2293    }
2294
2295    #[test]
2296    fn test_validate_comptime_side_effects_fetch() {
2297        use shape_ast::parser::parse_program;
2298
2299        let source = "comptime {\n  let data = fetch(\"http://example.com\")\n}\n";
2300        let program = parse_program(source).unwrap();
2301        let diagnostics = validate_comptime_side_effects(&program, source);
2302
2303        assert_eq!(
2304            diagnostics.len(),
2305            1,
2306            "Should warn about fetch() in comptime block"
2307        );
2308        assert!(diagnostics[0].message.contains("fetch"));
2309    }
2310
2311    #[test]
2312    fn test_validate_comptime_builtins_outside_comptime() {
2313        use shape_ast::parser::parse_program;
2314
2315        let source = r#"let x = implements("Point", "Display")"#;
2316        let program = parse_program(source).unwrap();
2317        let diagnostics = validate_comptime_builtins_context(&program, source);
2318
2319        assert_eq!(
2320            diagnostics.len(),
2321            1,
2322            "Should report error for comptime builtin outside comptime"
2323        );
2324        assert!(diagnostics[0].message.contains("comptime-only"));
2325        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
2326    }
2327
2328    #[test]
2329    fn test_validate_comptime_builtins_inside_comptime_ok() {
2330        use shape_ast::parser::parse_program;
2331
2332        let source = "comptime {\n  let has = implements(\"Point\", \"Display\")\n}";
2333        let program = parse_program(source).unwrap();
2334        let diagnostics = validate_comptime_builtins_context(&program, source);
2335
2336        assert_eq!(
2337            diagnostics.len(),
2338            0,
2339            "comptime builtin inside comptime should be allowed"
2340        );
2341    }
2342
2343    #[test]
2344    fn test_validate_comptime_builtins_build_config_outside() {
2345        use shape_ast::parser::parse_program;
2346
2347        let source = "let cfg = build_config()";
2348        let program = parse_program(source).unwrap();
2349        let diagnostics = validate_comptime_builtins_context(&program, source);
2350
2351        assert_eq!(
2352            diagnostics.len(),
2353            1,
2354            "Should report error for build_config() outside comptime"
2355        );
2356    }
2357
2358    #[test]
2359    fn test_validate_async_let_outside_async() {
2360        use shape_ast::parser::parse_program;
2361
2362        let source = "fn foo() {\n  async let x = fetch(\"url\")\n}";
2363        let program = parse_program(source).unwrap();
2364        let diagnostics = validate_async_structured_concurrency(&program, source);
2365
2366        assert_eq!(
2367            diagnostics.len(),
2368            1,
2369            "Should report error for async let outside async: {:?}",
2370            diagnostics
2371        );
2372        assert!(diagnostics[0].message.contains("async let"));
2373        assert_eq!(
2374            diagnostics[0].code,
2375            Some(NumberOrString::String("E0201".to_string()))
2376        );
2377    }
2378
2379    #[test]
2380    fn test_validate_async_let_inside_async() {
2381        use shape_ast::parser::parse_program;
2382
2383        let source = "async fn foo() {\n  async let x = fetch(\"url\")\n}";
2384        let program = parse_program(source).unwrap();
2385        let diagnostics = validate_async_structured_concurrency(&program, source);
2386
2387        assert!(
2388            diagnostics.is_empty(),
2389            "Should have no errors for async let inside async fn: {:?}",
2390            diagnostics
2391        );
2392    }
2393
2394    #[test]
2395    fn test_validate_async_scope_outside_async() {
2396        use shape_ast::parser::parse_program;
2397
2398        let source = "fn foo() {\n  let result = async scope { 42 }\n}";
2399        let program = parse_program(source).unwrap();
2400        let diagnostics = validate_async_structured_concurrency(&program, source);
2401
2402        assert_eq!(
2403            diagnostics.len(),
2404            1,
2405            "Should report error for async scope outside async: {:?}",
2406            diagnostics
2407        );
2408        assert!(diagnostics[0].message.contains("async scope"));
2409        assert_eq!(
2410            diagnostics[0].code,
2411            Some(NumberOrString::String("E0202".to_string()))
2412        );
2413    }
2414
2415    #[test]
2416    fn test_validate_async_scope_inside_async() {
2417        use shape_ast::parser::parse_program;
2418
2419        let source = "async fn foo() {\n  let result = async scope { 42 }\n}";
2420        let program = parse_program(source).unwrap();
2421        let diagnostics = validate_async_structured_concurrency(&program, source);
2422
2423        assert!(
2424            diagnostics.is_empty(),
2425            "Should have no errors for async scope inside async fn: {:?}",
2426            diagnostics
2427        );
2428    }
2429
2430    #[test]
2431    fn test_validate_for_await_outside_async() {
2432        use shape_ast::parser::parse_program;
2433
2434        let source = "fn foo() {\n  for await x in stream {\n    x\n  }\n}";
2435        let program = parse_program(source).unwrap();
2436        let diagnostics = validate_async_structured_concurrency(&program, source);
2437
2438        assert_eq!(
2439            diagnostics.len(),
2440            1,
2441            "Should report error for for-await outside async: {:?}",
2442            diagnostics
2443        );
2444        assert!(diagnostics[0].message.contains("for await"));
2445        assert_eq!(
2446            diagnostics[0].code,
2447            Some(NumberOrString::String("E0203".to_string()))
2448        );
2449    }
2450
2451    #[test]
2452    fn test_validate_for_await_inside_async() {
2453        use shape_ast::parser::parse_program;
2454
2455        let source = "async fn foo() {\n  for await x in stream {\n    x\n  }\n}";
2456        let program = parse_program(source).unwrap();
2457        let diagnostics = validate_async_structured_concurrency(&program, source);
2458
2459        assert!(
2460            diagnostics.is_empty(),
2461            "Should have no errors for for-await inside async fn: {:?}",
2462            diagnostics
2463        );
2464    }
2465
2466    #[test]
2467    fn test_validate_interpolation_format_specs_ok() {
2468        use shape_ast::parser::parse_program;
2469
2470        let source = r#"let s = f"value={price:fixed(2)}""#;
2471        let program = parse_program(source).unwrap();
2472        let diagnostics = validate_interpolation_format_specs(&program, source);
2473        assert!(
2474            diagnostics.is_empty(),
2475            "unexpected diagnostics: {:?}",
2476            diagnostics
2477        );
2478    }
2479
2480    #[test]
2481    fn test_validate_interpolation_format_specs_reports_invalid_table_key() {
2482        use shape_ast::parser::parse_program;
2483
2484        let source = r#"let s = f"{rows:table(foo=1)}""#;
2485        let program = parse_program(source).unwrap();
2486        let diagnostics = validate_interpolation_format_specs(&program, source);
2487        assert_eq!(diagnostics.len(), 1, "expected a single diagnostic");
2488        assert!(
2489            diagnostics[0].message.contains("Unknown table format key"),
2490            "unexpected diagnostic message: {}",
2491            diagnostics[0].message
2492        );
2493        assert_eq!(
2494            diagnostics[0].code,
2495            Some(NumberOrString::String("E0300".to_string()))
2496        );
2497        assert_eq!(
2498            diagnostics[0].range.start.line, 0,
2499            "diagnostic should point to formatted string line"
2500        );
2501    }
2502
2503    #[test]
2504    fn test_validate_color_rgb_out_of_range() {
2505        use shape_ast::parser::parse_program;
2506
2507        let source = "let c = Color.rgb(300, 100, 256)";
2508        let program = parse_program(source).unwrap();
2509        let diagnostics = validate_color_rgb_range(&program, source);
2510
2511        assert_eq!(
2512            diagnostics.len(),
2513            2,
2514            "expected 2 diagnostics for out-of-range RGB values (300 and 256), got: {:?}",
2515            diagnostics
2516        );
2517        assert!(diagnostics[0].message.contains("300"));
2518        assert!(diagnostics[1].message.contains("256"));
2519        assert_eq!(
2520            diagnostics[0].code,
2521            Some(NumberOrString::String("W0310".to_string()))
2522        );
2523        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
2524    }
2525
2526    #[test]
2527    fn test_validate_color_rgb_valid_range_ok() {
2528        use shape_ast::parser::parse_program;
2529
2530        let source = "let c = Color.rgb(255, 128, 0)";
2531        let program = parse_program(source).unwrap();
2532        let diagnostics = validate_color_rgb_range(&program, source);
2533
2534        assert!(
2535            diagnostics.is_empty(),
2536            "valid Color.rgb should produce no diagnostics: {:?}",
2537            diagnostics
2538        );
2539    }
2540
2541    #[test]
2542    fn test_borrow_analysis_to_diagnostics_empty() {
2543        let analysis = shape_vm::mir::analysis::BorrowAnalysis::empty();
2544        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
2545        let diagnostics = borrow_analysis_to_diagnostics(&analysis, "", &uri);
2546        assert!(
2547            diagnostics.is_empty(),
2548            "Empty analysis should produce no diagnostics"
2549        );
2550    }
2551
2552    #[test]
2553    fn test_borrow_analysis_to_diagnostics_with_error() {
2554        use shape_vm::mir::analysis::*;
2555        use shape_vm::mir::types::*;
2556
2557        let mut analysis = BorrowAnalysis::empty();
2558        analysis.errors.push(BorrowError {
2559            kind: BorrowErrorKind::ConflictExclusiveExclusive,
2560            span: Span { start: 10, end: 20 },
2561            conflicting_loan: LoanId(0),
2562            loan_span: Span { start: 0, end: 5 },
2563            last_use_span: Some(Span { start: 25, end: 30 }),
2564            repairs: Vec::new(),
2565        });
2566
2567        let source = "let mut x = 10\nlet m1 = &mut x\nlet m2 = &mut x\nprint(m1)\nprint(m2)";
2568        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
2569        let diagnostics = borrow_analysis_to_diagnostics(&analysis, source, &uri);
2570
2571        assert_eq!(diagnostics.len(), 1, "Should produce one diagnostic");
2572        let diag = &diagnostics[0];
2573        assert_eq!(diag.severity, Some(DiagnosticSeverity::ERROR));
2574        assert_eq!(
2575            diag.code,
2576            Some(NumberOrString::String("B0001".to_string()))
2577        );
2578        assert_eq!(diag.source.as_deref(), Some("shape-borrow"));
2579        assert!(
2580            diag.message.contains("cannot mutably borrow"),
2581            "Message should describe the conflict: {}",
2582            diag.message
2583        );
2584        // Should have related information (loan origin + last use)
2585        let related = diag.related_information.as_ref().unwrap();
2586        assert_eq!(
2587            related.len(),
2588            2,
2589            "Should have loan origin + last use entries"
2590        );
2591        assert!(related[0].message.contains("conflicting borrow"));
2592        assert!(related[1].message.contains("still needed"));
2593    }
2594
2595    #[test]
2596    fn test_borrow_analysis_to_diagnostics_mutability_error() {
2597        use shape_vm::mir::analysis::*;
2598
2599        let mut analysis = BorrowAnalysis::empty();
2600        analysis.mutability_errors.push(MutabilityError {
2601            span: Span { start: 10, end: 15 },
2602            variable_name: "x".to_string(),
2603            declaration_span: Span { start: 0, end: 5 },
2604            is_explicit_let: true,
2605            is_const: false,
2606        });
2607
2608        let source = "let x = 42\nx = 100\n";
2609        let uri = Uri::from_file_path("/tmp/test.shape").unwrap();
2610        let diagnostics = borrow_analysis_to_diagnostics(&analysis, source, &uri);
2611
2612        assert_eq!(diagnostics.len(), 1);
2613        let diag = &diagnostics[0];
2614        assert!(diag.message.contains("cannot assign to let binding"));
2615        assert_eq!(
2616            diag.code,
2617            Some(NumberOrString::String("E0384".to_string()))
2618        );
2619        let related = diag.related_information.as_ref().unwrap();
2620        assert_eq!(related.len(), 1);
2621        assert!(related[0].message.contains("declared here"));
2622    }
2623
2624    // W14.2-B1: per-line coverage additions
2625    #[test]
2626    fn test_code_description_for_known_code() {
2627        let result = code_description_for("E0001");
2628        assert!(result.is_some(), "expected code description for E0001");
2629    }
2630
2631    #[test]
2632    fn test_code_description_for_empty_returns_none() {
2633        let result = code_description_for("");
2634        assert!(result.is_none());
2635    }
2636
2637    #[test]
2638    fn test_diagnostic_tags_for_unused_import_by_code() {
2639        let tags = diagnostic_tags_for(Some("W0102"), "unused import 'foo'");
2640        assert!(tags.is_some());
2641        let tags = tags.unwrap();
2642        assert!(tags.contains(&DiagnosticTag::UNNECESSARY));
2643    }
2644
2645    #[test]
2646    fn test_diagnostic_tags_for_unused_variable_by_message() {
2647        let tags = diagnostic_tags_for(None, "unused variable 'x'");
2648        assert!(tags.is_some());
2649        let tags = tags.unwrap();
2650        assert!(tags.contains(&DiagnosticTag::UNNECESSARY));
2651    }
2652
2653    #[test]
2654    fn test_diagnostic_tags_for_other_returns_none() {
2655        let tags = diagnostic_tags_for(Some("E0001"), "Expected expression");
2656        assert!(tags.is_none(), "non-unused diagnostics should have no tags");
2657    }
2658
2659    #[test]
2660    fn test_enrich_diagnostics_with_code_metadata_backfills_description() {
2661        let mut diagnostics = vec![Diagnostic {
2662            range: Range::default(),
2663            severity: Some(DiagnosticSeverity::WARNING),
2664            code: Some(NumberOrString::String("W0102".to_string())),
2665            code_description: None,
2666            tags: None,
2667            source: Some("shape".to_string()),
2668            message: "unused import 'x'".to_string(),
2669            ..Default::default()
2670        }];
2671        enrich_diagnostics_with_code_metadata(&mut diagnostics);
2672        assert!(diagnostics[0].code_description.is_some());
2673        assert!(diagnostics[0].tags.is_some());
2674        let tags = diagnostics[0].tags.as_ref().unwrap();
2675        assert!(tags.contains(&DiagnosticTag::UNNECESSARY));
2676    }
2677
2678    #[test]
2679    fn test_enrich_diagnostics_preserves_existing_metadata() {
2680        use std::str::FromStr;
2681        let existing_desc = CodeDescription {
2682            href: Uri::from_str("https://example.com/x").unwrap(),
2683        };
2684        let mut diagnostics = vec![Diagnostic {
2685            range: Range::default(),
2686            severity: Some(DiagnosticSeverity::ERROR),
2687            code: Some(NumberOrString::String("E0001".to_string())),
2688            code_description: Some(existing_desc.clone()),
2689            tags: None,
2690            source: Some("shape".to_string()),
2691            message: "Existing".to_string(),
2692            ..Default::default()
2693        }];
2694        enrich_diagnostics_with_code_metadata(&mut diagnostics);
2695        // Should NOT overwrite the existing description
2696        assert!(diagnostics[0].code_description.is_some());
2697    }
2698
2699    #[test]
2700    fn test_parse_error_diagnostic_no_location_defaults_to_origin() {
2701        let error = ShapeError::ParseError {
2702            message: "no loc".to_string(),
2703            location: None,
2704        };
2705        let diagnostics = error_to_diagnostic(&error);
2706        assert_eq!(diagnostics.len(), 1);
2707        assert_eq!(diagnostics[0].range.start.line, 0);
2708        assert_eq!(diagnostics[0].range.start.character, 0);
2709    }
2710
2711    #[test]
2712    fn test_validate_unused_imports_finds_unused() {
2713        let source = "from std::core::math use { abs }\nlet x = 5\n";
2714        let program = shape_ast::parser::parse_program(source).unwrap();
2715        let diagnostics = validate_unused_imports(&program, source);
2716        // `abs` is imported but unused — should emit a warning
2717        assert!(
2718            diagnostics
2719                .iter()
2720                .any(|d| d.message.contains("unused") || d.message.contains("abs")),
2721            "expected unused-import diagnostic; got: {:?}",
2722            diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
2723        );
2724    }
2725
2726    #[test]
2727    fn test_validate_unused_imports_clean_when_used() {
2728        let source = "from std::core::math use { abs }\nlet x = abs(-5)\n";
2729        let program = shape_ast::parser::parse_program(source).unwrap();
2730        let diagnostics = validate_unused_imports(&program, source);
2731        assert!(
2732            diagnostics.is_empty(),
2733            "expected no diagnostics when import is used; got: {:?}",
2734            diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>()
2735        );
2736    }
2737}