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