1use super::factories::{from_check_finding, from_type_error, unused_binding_diagnostic};
9use super::model::{AnalysisReport, Diagnostic, Severity, Span};
10use crate::checker::{
11 CheckFinding, check_module_intent_with_sigs_in, collect_cse_warnings_in,
12 collect_independence_warnings_in, collect_module_effects_warnings_in,
13 collect_naming_warnings_in, collect_perf_warnings_in,
14 collect_plain_cases_effectful_warnings_in, collect_traversal_warnings_in,
15 collect_verify_coverage_warnings_in,
16};
17#[cfg(feature = "runtime")]
18use crate::checker::{FindingSpan, collect_verify_law_dependency_warnings_in};
19use crate::source::{LoadedModule, parse_source};
20#[cfg(feature = "runtime")]
21use crate::tail_check::collect_non_tail_recursion_warnings_with_sigs;
22use crate::tco;
23use crate::types::checker::{run_type_check_full, run_type_check_with_loaded};
24
25#[derive(Clone, Debug)]
27pub struct AnalyzeOptions {
28 pub file_label: String,
29 pub module_base_dir: Option<String>,
30 pub loaded_modules: Option<Vec<LoadedModule>>,
35 pub include_intent_warnings: bool,
36 pub include_coverage_warnings: bool,
37 pub include_law_dependency_warnings: bool,
38 pub include_cse_warnings: bool,
39 pub include_perf_warnings: bool,
40 pub include_traversal_warnings: bool,
46 pub include_independence_warnings: bool,
47 pub include_naming_warnings: bool,
48 pub include_non_tail_warnings: bool,
49 pub include_unused_bindings: bool,
50 pub include_verify_effectful_warnings: bool,
54 pub include_verify_run: bool,
59 pub verify_run_hostile: bool,
66 pub include_why_summary: bool,
69 pub include_context_summary: bool,
72}
73
74impl Default for AnalyzeOptions {
75 fn default() -> Self {
76 Self {
77 file_label: "<input>".to_string(),
78 module_base_dir: None,
79 loaded_modules: None,
80 include_intent_warnings: true,
81 include_coverage_warnings: true,
82 include_law_dependency_warnings: true,
83 include_cse_warnings: true,
84 include_perf_warnings: true,
85 include_traversal_warnings: true,
86 include_independence_warnings: true,
87 include_naming_warnings: true,
88 include_non_tail_warnings: true,
89 include_unused_bindings: true,
90 include_verify_effectful_warnings: true,
91 include_verify_run: false,
92 verify_run_hostile: false,
93 include_why_summary: false,
94 include_context_summary: false,
95 }
96 }
97}
98
99impl AnalyzeOptions {
100 pub fn new(file_label: impl Into<String>) -> Self {
101 Self {
102 file_label: file_label.into(),
103 ..Default::default()
104 }
105 }
106
107 pub fn with_module_base_dir(mut self, dir: impl Into<String>) -> Self {
108 self.module_base_dir = Some(dir.into());
109 self
110 }
111
112 pub fn with_loaded_modules(mut self, loaded: Vec<LoadedModule>) -> Self {
113 self.loaded_modules = Some(loaded);
114 self
115 }
116}
117
118pub fn analyze_source(source: &str, options: &AnalyzeOptions) -> AnalysisReport {
123 let items = match parse_source(source) {
124 Ok(items) => items,
125 Err(e) => {
126 return AnalysisReport::with_diagnostics(
127 options.file_label.clone(),
128 vec![parse_error_diagnostic(&e, source, &options.file_label)],
129 );
130 }
131 };
132
133 let mut transformed = items.clone();
134 tco::transform_program(&mut transformed);
135
136 let tc_result = if let Some(loaded) = options.loaded_modules.as_deref() {
137 run_type_check_with_loaded(&items, loaded)
138 } else {
139 run_type_check_full(&items, options.module_base_dir.as_deref())
140 };
141
142 let mut diagnostics: Vec<Diagnostic> = Vec::new();
143
144 for te in &tc_result.errors {
145 diagnostics.push(from_type_error(te, source, &options.file_label));
146 }
147
148 let findings = if options.include_intent_warnings {
149 Some(check_module_intent_with_sigs_in(
150 &items,
151 Some(&tc_result.fn_sigs),
152 None,
153 ))
154 } else {
155 None
156 };
157
158 if let Some(ref findings) = findings {
159 for e in &findings.errors {
160 diagnostics.push(from_check_finding(
161 Severity::Error,
162 e,
163 source,
164 &options.file_label,
165 ));
166 }
167 }
168
169 if options.include_unused_bindings {
170 for (binding, fn_name, line) in &tc_result.unused_bindings {
171 diagnostics.push(unused_binding_diagnostic(
172 binding,
173 fn_name,
174 *line,
175 source,
176 &options.file_label,
177 ));
178 }
179 }
180
181 if let Some(ref findings) = findings {
182 for w in &findings.warnings {
183 diagnostics.push(from_check_finding(
184 Severity::Warning,
185 w,
186 source,
187 &options.file_label,
188 ));
189 }
190 }
191
192 if options.include_coverage_warnings {
193 for w in collect_verify_coverage_warnings_in(&items, None) {
194 diagnostics.push(from_check_finding(
195 Severity::Warning,
196 &w,
197 source,
198 &options.file_label,
199 ));
200 }
201 }
202
203 for w in collect_module_effects_warnings_in(&items, None) {
209 diagnostics.push(from_check_finding(
210 Severity::Warning,
211 &w,
212 source,
213 &options.file_label,
214 ));
215 }
216
217 #[cfg(feature = "runtime")]
218 if options.include_law_dependency_warnings {
219 for w in collect_verify_law_dependency_warnings_in(&items, &tc_result.fn_sigs, None) {
220 diagnostics.push(from_check_finding(
221 Severity::Warning,
222 &w,
223 source,
224 &options.file_label,
225 ));
226 }
227 }
228
229 if options.include_cse_warnings {
230 for w in collect_cse_warnings_in(&transformed, None) {
231 diagnostics.push(from_check_finding(
232 Severity::Warning,
233 &w,
234 source,
235 &options.file_label,
236 ));
237 }
238 }
239
240 if options.include_perf_warnings {
241 for w in collect_perf_warnings_in(&transformed, None) {
242 diagnostics.push(from_check_finding(
243 Severity::Warning,
244 &w,
245 source,
246 &options.file_label,
247 ));
248 }
249 }
250
251 if options.include_traversal_warnings {
252 for w in collect_traversal_warnings_in(&transformed, None) {
253 diagnostics.push(from_check_finding(
254 Severity::Warning,
255 &w,
256 source,
257 &options.file_label,
258 ));
259 }
260 }
261
262 if options.include_independence_warnings {
263 for w in collect_independence_warnings_in(&transformed, &tc_result.fn_sigs, None) {
264 diagnostics.push(from_check_finding(
265 Severity::Warning,
266 &w,
267 source,
268 &options.file_label,
269 ));
270 }
271 }
272
273 if options.include_verify_effectful_warnings {
274 for w in collect_plain_cases_effectful_warnings_in(&transformed, &tc_result.fn_sigs, None) {
275 diagnostics.push(from_check_finding(
276 Severity::Warning,
277 &w,
278 source,
279 &options.file_label,
280 ));
281 }
282 }
283
284 if options.include_naming_warnings {
285 for w in collect_naming_warnings_in(&items, None) {
286 diagnostics.push(from_check_finding(
287 Severity::Warning,
288 &w,
289 source,
290 &options.file_label,
291 ));
292 }
293 }
294
295 #[cfg(feature = "runtime")]
296 let verify_summary_opt = if options.include_verify_run && tc_result.errors.is_empty() {
297 let runnable_items = items.clone();
302 let mode = if options.verify_run_hostile {
303 crate::verify_law::expand::ExpansionMode::Hostile
304 } else {
305 crate::verify_law::expand::ExpansionMode::Declared
306 };
307 let (verify_diags, verify_summary) = if let Some(loaded) = options.loaded_modules.clone() {
308 super::verify_run::run_verify_blocks_with_loaded_and_mode(
309 runnable_items,
310 loaded,
311 &options.file_label,
312 source,
313 mode,
314 )
315 } else {
316 super::verify_run::run_verify_blocks_with_mode(
317 runnable_items,
318 options.module_base_dir.as_deref(),
319 &options.file_label,
320 source,
321 mode,
322 )
323 };
324 for diag in verify_diags {
325 diagnostics.push(diag);
326 }
327 Some(verify_summary)
328 } else {
329 None
330 };
331 #[cfg(not(feature = "runtime"))]
332 let verify_summary_opt: Option<super::model::VerifySummary> = None;
333
334 #[cfg(feature = "runtime")]
335 if options.include_non_tail_warnings {
336 let non_tail =
337 collect_non_tail_recursion_warnings_with_sigs(&transformed, &tc_result.fn_sigs);
338 for w in &non_tail {
339 let mut line_counts: Vec<(usize, usize)> = Vec::new();
340 for &ln in &w.callsite_lines {
341 if let Some(entry) = line_counts.iter_mut().find(|(l, _)| *l == ln) {
342 entry.1 += 1;
343 } else {
344 line_counts.push((ln, 1));
345 }
346 }
347 let max_shown = 3;
348 let extra_spans: Vec<FindingSpan> = line_counts
349 .iter()
350 .take(max_shown)
351 .map(|&(ln, count)| {
352 let label = if count > 1 {
353 format!("{} non-tail calls", count)
354 } else {
355 "non-tail call".to_string()
356 };
357 FindingSpan {
358 line: ln,
359 col: 0,
360 len: 0,
361 label,
362 }
363 })
364 .collect();
365 let finding = CheckFinding {
366 line: w.line,
367 module: None,
368 file: None,
369 fn_name: Some(w.fn_name.clone()),
370 message: w.message.clone(),
371 extra_spans,
372 };
373 diagnostics.push(from_check_finding(
374 Severity::Warning,
375 &finding,
376 source,
377 &options.file_label,
378 ));
379 }
380 }
381
382 let mut report = AnalysisReport::with_diagnostics(options.file_label.clone(), diagnostics);
383 report.verify_summary = verify_summary_opt;
384
385 if options.include_why_summary {
386 report.why_summary = Some(super::why::summarize(
387 &items,
388 source,
389 options.file_label.clone(),
390 ));
391 }
392
393 if options.include_context_summary {
394 let ctx = super::context::build_context_for_items(
395 &items,
396 source,
397 options.file_label.clone(),
398 options.module_base_dir.as_deref(),
399 );
400 report.context_summary = Some(super::context::summarize(&ctx));
401 }
402
403 report
404}
405
406fn parse_error_diagnostic(msg: &str, source: &str, file: &str) -> Diagnostic {
414 use super::classify::{estimate_span_len, extract_source_lines_range};
415 use super::model::{AnnotatedRegion, Underline};
416 let (line, col, body) = strip_parse_error_prefix(msg);
417 let regions = if line > 0 {
418 let start = line.saturating_sub(1).max(1);
423 let source_lines = extract_source_lines_range(source, start, line);
424 if source_lines.is_empty() {
425 Vec::new()
426 } else {
427 let underline = source.lines().nth(line.saturating_sub(1)).map(|l| {
433 let line_chars = l.chars().count();
434 let anchor = if col > line_chars && line_chars > 0 {
435 line_chars
436 } else {
437 col.max(1)
438 };
439 Underline {
440 col: anchor,
441 len: estimate_span_len(l, anchor),
442 label: String::new(),
443 }
444 });
445 vec![AnnotatedRegion {
446 source_lines,
447 underline,
448 }]
449 }
450 } else {
451 Vec::new()
452 };
453 Diagnostic {
454 severity: Severity::Error,
455 slug: "parse-error",
456 summary: body.to_string(),
457 span: Span {
458 file: file.to_string(),
459 line: line.max(1),
460 col: col.max(1),
461 },
462 fn_name: None,
463 intent: None,
464 fields: Vec::new(),
465 conflict: None,
466 repair: parse_error_repair(body),
467 regions,
468 related: Vec::new(),
469 from_hostile: false,
470 }
471}
472
473fn strip_parse_error_prefix(msg: &str) -> (usize, usize, &str) {
474 let Some(rest) = msg.strip_prefix("error[") else {
477 return (0, 0, msg);
478 };
479 let Some(close) = rest.find("]: ") else {
480 return (0, 0, msg);
481 };
482 let (coord, tail) = rest.split_at(close);
483 let body = &tail[3..];
484 let Some((line_s, col_s)) = coord.split_once(':') else {
485 return (0, 0, body);
486 };
487 let line = line_s.parse::<usize>().unwrap_or(0);
488 let col = col_s.parse::<usize>().unwrap_or(0);
489 (line, col, body)
490}
491
492fn parse_error_repair(body: &str) -> super::model::Repair {
493 use super::model::Repair;
499 let hint = if body.contains("after '?'") {
500 Some("Description needs a string literal: `? \"what this does\"`")
501 } else if body.contains("after 'intent ='") {
502 Some(
503 "Module intent is a string or an indented block of strings: `intent = \"one line\"` or `intent =\\n \"line one\"\\n \"line two\"`",
504 )
505 } else if body.contains("Expected '[' after '!'") {
506 Some("Effects are a bracketed list: `! [Console.print, Random.int]`")
507 } else if body.contains("Expected '=>' between key and value in map literal") {
508 Some("Map literal uses `=>`: `{\"k\" => 1, \"other\" => 2}`")
509 } else if body.contains("Tuple type must have at least 2 elements") {
510 Some("Single-element tuples aren't allowed — use the bare type, or add a second element.")
511 } else if body.contains("Constructor patterns must be qualified") {
512 Some(
513 "Qualify variant patterns with the type name: `Shape.Circle(r) ->` not `Circle(r) ->`.",
514 )
515 } else if body.contains("bind the whole value with a lower-case name") {
516 Some(
517 "Record patterns don't take positional args — bind the whole record: `match user ... u -> u.name`.",
518 )
519 } else if body.starts_with("Expected ") && body.contains(", found ") {
520 Some(
521 "Replace the unexpected token with the expected form; check for a missing keyword, bracket, or separator above.",
522 )
523 } else if body.contains("must place `module <Name>`") {
524 Some("Move `module <Name>` so it's the very first top-level item in the file.")
525 } else if body.contains("must declare `module <Name>`") {
526 Some("Add `module <Name>` as the first line of the file.")
527 } else if body.contains("must contain exactly one module declaration") {
528 Some("Keep one `module` per file — split multi-module files into one file each.")
529 } else {
530 None
531 };
532 Repair {
533 primary: hint.map(String::from),
534 ..Repair::default()
535 }
536}