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