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