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