1use 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
24pub 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 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 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 enrich_diagnostics_with_code_metadata(&mut diagnostics);
84 diagnostics
85}
86
87pub 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}