Skip to main content

shape_lsp/
analysis.rs

1//! Shared semantic analysis pipeline for Shape LSP.
2//!
3//! This module centralizes semantic diagnostics so the language server and
4//! the `shape-test` fluent harness use the same logic.
5
6use crate::annotation_discovery::AnnotationDiscovery;
7use crate::diagnostics::{
8    enrich_diagnostics_with_code_metadata, error_to_diagnostic, validate_annotations,
9    validate_async_join, validate_async_structured_concurrency, validate_color_rgb_range,
10    validate_comptime_builtins_context, validate_comptime_overrides,
11    validate_comptime_side_effects, validate_foreign_function_types,
12    validate_interpolation_format_specs, validate_trait_bounds, validate_unused_imports,
13};
14use crate::module_cache::ModuleCache;
15use crate::scope::ScopeTree;
16use crate::util::offset_to_line_col;
17use shape_ast::ast::{Expr, ImportItems, Item, Program};
18use shape_runtime::visitor::{Visitor, walk_program};
19use std::collections::{HashMap, HashSet};
20use tower_lsp_server::ls_types::{Diagnostic, DiagnosticSeverity, Position, Range};
21
22const MAX_SEMANTIC_DIAGNOSTICS: usize = 200;
23
24/// Run semantic diagnostics for a parsed Shape program.
25pub fn analyze_program_semantics(
26    program: &Program,
27    text: &str,
28    file_path: Option<&std::path::Path>,
29    module_cache: Option<&ModuleCache>,
30    workspace_root: Option<&std::path::Path>,
31) -> Vec<Diagnostic> {
32    let mut diagnostics = Vec::new();
33
34    // Discover annotations from the program and imports.
35    let mut annotation_discovery = AnnotationDiscovery::new();
36    annotation_discovery.discover_from_program(program);
37    if let (Some(path), Some(cache)) = (file_path, module_cache) {
38        annotation_discovery.discover_from_imports_with_cache(program, path, cache, workspace_root);
39    } else {
40        annotation_discovery.discover_from_imports(program);
41    }
42
43    diagnostics.extend(validate_annotations(program, &annotation_discovery, text));
44    diagnostics.extend(validate_async_join(program, text));
45    diagnostics.extend(validate_async_structured_concurrency(program, text));
46    diagnostics.extend(validate_interpolation_format_specs(program, text));
47    diagnostics.extend(validate_comptime_overrides(program, text));
48    diagnostics.extend(validate_comptime_side_effects(program, text));
49    diagnostics.extend(validate_comptime_builtins_context(program, text));
50    diagnostics.extend(validate_trait_bounds(program, text));
51    diagnostics.extend(validate_color_rgb_range(program, text));
52    diagnostics.extend(validate_foreign_function_types(program, text));
53    // W2.3 / 1.19 — unused-import lint emits W0102 with DiagnosticTag::UNNECESSARY.
54    diagnostics.extend(validate_unused_imports(program, text));
55
56    let mut compiler = shape_vm::BytecodeCompiler::new();
57    compiler.set_type_diagnostic_mode(shape_vm::compiler::TypeDiagnosticMode::RecoverAll);
58    compiler.set_compile_diagnostic_mode(shape_vm::compiler::CompileDiagnosticMode::RecoverAll);
59
60    if let (Some(path), Some(cache)) = (file_path, module_cache) {
61        diagnostics.extend(validate_imports_and_register_items(
62            program,
63            text,
64            path,
65            cache,
66            workspace_root,
67            &mut compiler,
68        ));
69    }
70
71    if let Err(compile_error) = compiler.compile_with_source(program, text) {
72        let mut compile_diagnostics = error_to_diagnostic(&compile_error);
73        combine_same_line_undefined_variable_diagnostics(program, text, &mut compile_diagnostics);
74        diagnostics.extend(compile_diagnostics);
75    }
76
77    dedupe_and_cap_diagnostics(&mut diagnostics);
78    // W2.3 / 1.17 + 1.19 — backfill code_description (book URL) + tags
79    // (UNNECESSARY for unused) on every diagnostic emitted by validators
80    // that construct `Diagnostic { code_description: None, tags: None, .. }`
81    // literals. Single backfill at pipeline exit avoids touching ~19
82    // per-validator construction sites.
83    enrich_diagnostics_with_code_metadata(&mut diagnostics);
84    diagnostics
85}
86
87/// Validate import statements and register imported items in the compiler.
88pub fn validate_imports_and_register_items(
89    program: &Program,
90    text: &str,
91    file_path: &std::path::Path,
92    module_cache: &ModuleCache,
93    workspace_root: Option<&std::path::Path>,
94    compiler: &mut shape_vm::BytecodeCompiler,
95) -> Vec<Diagnostic> {
96    let mut diagnostics = Vec::new();
97    let importable_modules = module_cache.list_importable_modules_with_context_and_source(
98        file_path,
99        workspace_root,
100        Some(text),
101    );
102    let mut known_module_names = crate::completion::imports::module_names_with_context_and_source(
103        Some(file_path),
104        workspace_root,
105        Some(text),
106    );
107    known_module_names.extend(importable_modules.iter().filter_map(|module_path| {
108        module_path
109            .split('.')
110            .next()
111            .map(|segment| segment.to_string())
112    }));
113
114    for item in &program.items {
115        if let Item::Import(import_stmt, import_span) = item {
116            match &import_stmt.items {
117                ImportItems::Named(_) => {
118                    if let Some(module_info) = module_cache
119                        .load_module_by_import_with_context_and_source(
120                            &import_stmt.from,
121                            file_path,
122                            workspace_root,
123                            Some(text),
124                        )
125                    {
126                        compiler.register_imported_items(&module_info.program.items);
127                    } else {
128                        diagnostics.push(make_span_diagnostic(
129                            text,
130                            *import_span,
131                            format!(
132                                "Cannot resolve module '{}'. Verify the import path and declare dependencies in shape.toml when needed.",
133                                import_stmt.from
134                            ),
135                            DiagnosticSeverity::ERROR,
136                        ));
137                    }
138                }
139                ImportItems::Namespace { name, .. } => {
140                    if !known_module_names.iter().any(|module| module == name) {
141                        diagnostics.push(make_span_diagnostic(
142                            text,
143                            *import_span,
144                            format!(
145                                "Cannot resolve module '{}'. Verify the import path and declare dependencies in shape.toml when needed.",
146                                name
147                            ),
148                            DiagnosticSeverity::ERROR,
149                        ));
150                    }
151                }
152            }
153        }
154    }
155
156    diagnostics
157}
158
159fn make_span_diagnostic(
160    text: &str,
161    span: shape_ast::ast::Span,
162    message: String,
163    severity: DiagnosticSeverity,
164) -> Diagnostic {
165    let (start_line, start_col) = offset_to_line_col(text, span.start);
166    let (end_line, end_col) = offset_to_line_col(text, span.end);
167    Diagnostic {
168        range: Range {
169            start: Position {
170                line: start_line,
171                character: start_col,
172            },
173            end: Position {
174                line: end_line,
175                character: end_col,
176            },
177        },
178        severity: Some(severity),
179        message,
180        source: Some("shape".to_string()),
181        ..Default::default()
182    }
183}
184
185fn combine_same_line_undefined_variable_diagnostics(
186    program: &Program,
187    text: &str,
188    diagnostics: &mut Vec<Diagnostic>,
189) {
190    let mut undefined_diag_indices_by_line: HashMap<u32, Vec<usize>> = HashMap::new();
191    for (index, diagnostic) in diagnostics.iter().enumerate() {
192        if is_undefined_variable_message(&diagnostic.message) {
193            undefined_diag_indices_by_line
194                .entry(diagnostic.range.start.line)
195                .or_default()
196                .push(index);
197        }
198    }
199
200    if undefined_diag_indices_by_line.is_empty() {
201        return;
202    }
203
204    let undefined_names_by_line = collect_undefined_identifier_names_by_line(program, text);
205    if undefined_names_by_line.is_empty() {
206        return;
207    }
208
209    let mut indices_to_drop: HashSet<usize> = HashSet::new();
210
211    for (line, diag_indices) in undefined_diag_indices_by_line {
212        let Some(undefined_names) = undefined_names_by_line.get(&line) else {
213            continue;
214        };
215
216        if undefined_names.len() <= 1 {
217            continue;
218        }
219
220        let first_index = diag_indices[0];
221        diagnostics[first_index].message = format!(
222            "Undefined variables: {}",
223            undefined_names
224                .iter()
225                .map(|name| format!("'{}'", name))
226                .collect::<Vec<_>>()
227                .join(", ")
228        );
229
230        for index in diag_indices.into_iter().skip(1) {
231            indices_to_drop.insert(index);
232        }
233    }
234
235    if indices_to_drop.is_empty() {
236        return;
237    }
238
239    let mut filtered = Vec::with_capacity(diagnostics.len().saturating_sub(indices_to_drop.len()));
240    for (index, diagnostic) in diagnostics.drain(..).enumerate() {
241        if !indices_to_drop.contains(&index) {
242            filtered.push(diagnostic);
243        }
244    }
245    *diagnostics = filtered;
246}
247
248fn is_undefined_variable_message(message: &str) -> bool {
249    message.starts_with("Undefined variable: '") || message.starts_with("Undefined variable: ")
250}
251
252#[derive(Default)]
253struct IdentifierCollector {
254    identifiers: Vec<(String, shape_ast::ast::Span)>,
255}
256
257impl Visitor for IdentifierCollector {
258    fn visit_expr(&mut self, expr: &Expr) -> bool {
259        if let Expr::Identifier(name, span) = expr
260            && !span.is_dummy()
261        {
262            self.identifiers.push((name.clone(), *span));
263        }
264        true
265    }
266}
267
268fn collect_undefined_identifier_names_by_line(
269    program: &Program,
270    text: &str,
271) -> HashMap<u32, Vec<String>> {
272    let scope_tree = ScopeTree::build(program, text);
273    let mut collector = IdentifierCollector::default();
274    walk_program(&mut collector, program);
275
276    let mut by_line_with_offsets: HashMap<u32, Vec<(usize, String)>> = HashMap::new();
277    for (name, span) in collector.identifiers {
278        if scope_tree.binding_at(span.start).is_some() {
279            continue;
280        }
281        let (line, _) = offset_to_line_col(text, span.start);
282        by_line_with_offsets
283            .entry(line)
284            .or_default()
285            .push((span.start, name));
286    }
287
288    let mut by_line: HashMap<u32, Vec<String>> = HashMap::new();
289    for (line, mut names_with_offsets) in by_line_with_offsets {
290        names_with_offsets.sort_by_key(|(offset, _)| *offset);
291        let mut seen = HashSet::new();
292        let mut names = Vec::new();
293        for (_, name) in names_with_offsets {
294            if seen.insert(name.clone()) {
295                names.push(name);
296            }
297        }
298        if !names.is_empty() {
299            by_line.insert(line, names);
300        }
301    }
302
303    by_line
304}
305
306fn dedupe_and_cap_diagnostics(diagnostics: &mut Vec<Diagnostic>) {
307    let mut seen = HashSet::new();
308    diagnostics.retain(|diagnostic| seen.insert(diagnostic_dedupe_key(diagnostic)));
309    if diagnostics.len() > MAX_SEMANTIC_DIAGNOSTICS {
310        diagnostics.truncate(MAX_SEMANTIC_DIAGNOSTICS);
311    }
312}
313
314fn diagnostic_dedupe_key(diagnostic: &Diagnostic) -> String {
315    format!(
316        "{}:{}:{}",
317        diagnostic.range.start.line,
318        diagnostic.range.start.character,
319        normalize_diagnostic_message(&diagnostic.message)
320    )
321}
322
323fn normalize_diagnostic_message(message: &str) -> String {
324    if let Some(canonical) = canonicalize_undefined_variable_message(message) {
325        return canonical;
326    }
327    message.split_whitespace().collect::<Vec<_>>().join(" ")
328}
329
330fn canonicalize_undefined_variable_message(message: &str) -> Option<String> {
331    const PREFIX: &str = "Undefined variable:";
332    if !message.starts_with(PREFIX) {
333        return None;
334    }
335    let rest = message[PREFIX.len()..].trim();
336    let trimmed = rest.trim_start_matches('\'');
337    let name: String = trimmed
338        .chars()
339        .take_while(|ch| ch.is_alphanumeric() || *ch == '_')
340        .collect();
341    if name.is_empty() {
342        Some("undefined variable".to_string())
343    } else {
344        Some(format!("undefined variable:{}", name))
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use shape_ast::parser::parse_program;
352
353    #[test]
354    fn semantic_analysis_keeps_named_decomposition_bindings_defined() {
355        let source = r#"let a = { x: 1}
356let b = { z: 3}
357//print(a.y) //compiler error: no y (even though a has y in the shape via optimistic hoisting, see next line)
358a.y = 2
359print(a.y) //works!
360let c = a+b //resulting type is {x: int, y: int, z: int}
361//destructuring works, e.g.
362let (d:{x}, e: {y, z})  = c
363//destructuring to named structs works also but need the as keyword:
364type TypeA {x: int, y: int}
365type TypeB {z: int}
366let (f:TypeA, g: TypeB) = c as (TypeA+TypeB)
367print(f, g)
368"#;
369
370        let program = parse_program(source).expect("program should parse");
371        let symbols = crate::symbols::extract_symbols(&program);
372        assert!(
373            symbols.iter().any(|s| s.name == "f"),
374            "parser/symbol extraction should include decomposition binding f: {:?}",
375            symbols.iter().map(|s| s.name.as_str()).collect::<Vec<_>>()
376        );
377        assert!(
378            symbols.iter().any(|s| s.name == "g"),
379            "parser/symbol extraction should include decomposition binding g: {:?}",
380            symbols.iter().map(|s| s.name.as_str()).collect::<Vec<_>>()
381        );
382        let temp_dir = tempfile::tempdir().expect("tempdir");
383        let file_path = temp_dir.path().join("script.shape");
384        std::fs::write(&file_path, source).expect("write source");
385        let module_cache = ModuleCache::new();
386
387        let diagnostics = analyze_program_semantics(
388            &program,
389            source,
390            Some(&file_path),
391            Some(&module_cache),
392            None,
393        );
394
395        assert!(
396            diagnostics
397                .iter()
398                .all(|diag| !diag.message.contains("Undefined variable: 'f'")),
399            "unexpected diagnostics: {:?}",
400            diagnostics
401                .iter()
402                .map(|d| d.message.as_str())
403                .collect::<Vec<_>>()
404        );
405        assert!(
406            diagnostics
407                .iter()
408                .all(|diag| !diag.message.contains("Undefined variable: 'g'")),
409            "unexpected diagnostics: {:?}",
410            diagnostics
411                .iter()
412                .map(|d| d.message.as_str())
413                .collect::<Vec<_>>()
414        );
415    }
416
417    #[test]
418    fn semantic_analysis_combines_undefined_variables_on_same_line() {
419        let source = "print(h, i)\n";
420        let program = parse_program(source).expect("program should parse");
421
422        let diagnostics = analyze_program_semantics(&program, source, None, None, None);
423
424        let messages: Vec<&str> = diagnostics.iter().map(|d| d.message.as_str()).collect();
425        assert!(
426            messages
427                .iter()
428                .any(|message| message.contains("Undefined variables: 'h', 'i'")),
429            "expected combined undefined variable diagnostic, got {:?}",
430            messages
431        );
432        assert!(
433            messages
434                .iter()
435                .all(|message| !message.contains("Undefined variable: 'h'")),
436            "did not expect singular undefined diagnostic for h, got {:?}",
437            messages
438        );
439    }
440
441    #[test]
442    fn semantic_analysis_reports_undefined_variables_on_multiple_lines() {
443        let source = "print(h)\nprint(i)\n";
444        let program = parse_program(source).expect("program should parse");
445
446        let diagnostics = analyze_program_semantics(&program, source, None, None, None);
447
448        assert!(
449            diagnostics.iter().any(|diag| {
450                diag.range.start.line == 0 && is_undefined_variable_message(&diag.message)
451            }),
452            "expected undefined variable diagnostic on line 0, got {:?}",
453            diagnostics
454                .iter()
455                .map(|d| (d.range.start.line, d.message.as_str()))
456                .collect::<Vec<_>>()
457        );
458        assert!(
459            diagnostics.iter().any(|diag| {
460                diag.range.start.line == 1 && is_undefined_variable_message(&diag.message)
461            }),
462            "expected undefined variable diagnostic on line 1, got {:?}",
463            diagnostics
464                .iter()
465                .map(|d| (d.range.start.line, d.message.as_str()))
466                .collect::<Vec<_>>()
467        );
468    }
469
470    #[test]
471    fn semantic_analysis_combines_same_line_and_keeps_next_line_diagnostic() {
472        let source = "print(h, i)\nprint(j)\n";
473        let program = parse_program(source).expect("program should parse");
474
475        let diagnostics = analyze_program_semantics(&program, source, None, None, None);
476        let messages: Vec<&str> = diagnostics.iter().map(|d| d.message.as_str()).collect();
477
478        assert!(
479            messages
480                .iter()
481                .any(|message| message.contains("Undefined variables: 'h', 'i'")),
482            "expected combined diagnostic for line 0, got {:?}",
483            messages
484        );
485        assert!(
486            diagnostics.iter().any(|diag| {
487                diag.range.start.line == 1 && is_undefined_variable_message(&diag.message)
488            }),
489            "expected undefined diagnostic on line 1, got {:?}",
490            diagnostics
491                .iter()
492                .map(|d| (d.range.start.line, d.message.as_str()))
493                .collect::<Vec<_>>()
494        );
495    }
496
497    #[test]
498    fn semantic_analysis_frontmatter_foreign_function_percentile_call_has_no_type_mismatch() {
499        let source = r#"---
500[[extensions]]
501name = "python"
502path = "/tmp/libshape_ext_python.so"
503---
504fn python percentile(values: Array<number>, pct: number) -> number {
505  sorted_v = sorted(values)
506  k = (len(sorted_v) - 1) * (pct / 100.0)
507  f = int(k)
508  c = f + 1
509  if c >= len(sorted_v):
510    return sorted_v[-1]
511  return sorted_v[f] + (k - f) * (sorted_v[c] - sorted_v[f])
512}
513
514print(percentile([1.0, 2.0, 3.0], 50.0))
515"#;
516
517        let parse_source = crate::util::parser_source(source);
518        let program = parse_program(parse_source.as_ref()).expect("program should parse");
519        let foreign_fn = program
520            .items
521            .iter()
522            .find_map(|item| match item {
523                Item::ForeignFunction(def, _) if def.name == "percentile" => Some(def),
524                _ => None,
525            })
526            .expect("percentile foreign function should be present");
527        let first_param = foreign_fn
528            .params
529            .first()
530            .and_then(|p| p.type_annotation.as_ref())
531            .expect("first param annotation");
532        assert_eq!(
533            first_param.to_type_string(),
534            "Array<number>",
535            "unexpected foreign parameter annotation AST: {:?}",
536            first_param
537        );
538        let diagnostics = analyze_program_semantics(&program, source, None, None, None);
539
540        let mismatch_messages: Vec<&str> = diagnostics
541            .iter()
542            .map(|d| d.message.as_str())
543            .filter(|m| m.contains("Could not solve type constraints"))
544            .collect();
545        assert!(
546            mismatch_messages.is_empty(),
547            "unexpected type constraint diagnostics: {:?}",
548            mismatch_messages
549        );
550    }
551
552    #[test]
553    fn semantic_analysis_foreign_function_accepts_struct_array_argument() {
554        let source = r#"type Measurement {
555  timestamp: string,
556  value: number,
557  sensor_id: string,
558}
559
560fn python outlier_ratio(readings: Array<Measurement>, z_threshold: number) -> number {
561  values = [r['value'] for r in readings]
562  mean = sum(values) / len(values)
563  std = (sum((v - mean) ** 2 for v in values) / len(values)) ** 0.5
564  outliers = [v for v in values if abs(v - mean) > z_threshold * std]
565  return len(outliers) / len(values)
566}
567
568let readings: Array<Measurement> = [
569  { timestamp: "2026-02-22T10:00:00Z", value: 10.0, sensor_id: "A" },
570  { timestamp: "2026-02-22T10:01:00Z", value: 10.5, sensor_id: "A" },
571  { timestamp: "2026-02-22T10:02:00Z", value: 9.8, sensor_id: "A" },
572  { timestamp: "2026-02-22T10:03:00Z", value: 10.2, sensor_id: "A" },
573  { timestamp: "2026-02-22T10:04:00Z", value: 35.0, sensor_id: "A" }
574]
575
576print(outlier_ratio(readings, 1.5))
577"#;
578
579        let program = parse_program(source).expect("program should parse");
580        let diagnostics = analyze_program_semantics(&program, source, None, None, None);
581
582        let mismatch_messages: Vec<&str> = diagnostics
583            .iter()
584            .map(|d| d.message.as_str())
585            .filter(|m| m.contains("Could not solve type constraints"))
586            .collect();
587        assert!(
588            mismatch_messages.is_empty(),
589            "unexpected type constraint diagnostics: {:?}",
590            mismatch_messages
591        );
592    }
593}