1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process;
5
6use aver::ast::TopLevel;
7use aver::diagnostics::model::AnalysisReport;
8use aver::diagnostics::needs_format_diagnostic;
9use aver::lexer::Lexer;
10use aver::parser::Parser;
11use aver::types::{Type, parse_type_str_strict};
12use colored::Colorize;
13
14#[allow(dead_code)]
15pub(super) fn cmd_format(path: &str, check: bool, json: bool) {
16 let check = check || json;
18
19 let root = Path::new(path);
20 let mut files = Vec::new();
21 if let Err(e) = collect_av_files(root, &mut files) {
22 if json {
23 emit_fatal_json("cannot-collect", &e);
24 } else {
25 eprintln!("{}", e.red());
26 }
27 process::exit(1);
28 }
29 files.sort();
30
31 if files.is_empty() {
32 let msg = format!("No .av files found under '{}'", root.display());
33 if json {
34 emit_fatal_json("no-files", &msg);
35 } else {
36 eprintln!("{}", msg.red());
37 }
38 process::exit(1);
39 }
40
41 struct Changed {
44 path: PathBuf,
45 original: String,
46 violations: Vec<aver::diagnostics::model::FormatViolation>,
47 }
48 let mut changed: Vec<Changed> = Vec::new();
49
50 for file in &files {
51 let src = match fs::read_to_string(file) {
52 Ok(s) => s,
53 Err(e) => {
54 let msg = format!("Cannot read '{}': {}", file.display(), e);
55 if json {
56 emit_fatal_json("read-failed", &msg);
57 } else {
58 eprintln!("{}", msg.red());
59 }
60 process::exit(1);
61 }
62 };
63 let (formatted, violations) = match try_format_source(&src) {
64 Ok(pair) => pair,
65 Err(e) => {
66 let msg = format!("Cannot format '{}': {}", file.display(), e);
67 if json {
68 emit_fatal_json("format-failed", &msg);
69 } else {
70 eprintln!("{}", msg.red());
71 }
72 process::exit(1);
73 }
74 };
75 if formatted != src {
76 if !check && let Err(e) = fs::write(file, &formatted) {
77 eprintln!(
78 "{}",
79 format!("Cannot write '{}': {}", file.display(), e).red()
80 );
81 process::exit(1);
82 }
83 changed.push(Changed {
84 path: file.clone(),
85 original: src,
86 violations,
87 });
88 }
89 }
90
91 if json {
92 for c in &changed {
93 let file_label = c.path.display().to_string();
94 let diag = needs_format_diagnostic(&file_label, &c.violations, &c.original);
95 let report = AnalysisReport::with_diagnostics(file_label, vec![diag]);
96 println!("{}", report.to_json());
97 }
98 println!(
99 "{{\"schema_version\":1,\"kind\":\"summary\",\"files\":{},\"format\":{{\"clean\":{},\"needs_format\":{}}}}}",
100 files.len(),
101 files.len() - changed.len(),
102 changed.len()
103 );
104 if !changed.is_empty() {
105 process::exit(1);
106 }
107 return;
108 }
109
110 if check {
111 if changed.is_empty() {
112 println!("{}", "Format check passed".green());
113 return;
114 }
115 for (i, c) in changed.iter().enumerate() {
116 if i > 0 {
117 println!();
118 }
119 let file_label = c.path.display().to_string();
120 let diag = needs_format_diagnostic(&file_label, &c.violations, &c.original);
121 print!("{}", aver::tty_render::render_tty(&diag, true));
124 }
125 println!();
126 println!(
127 "{}: {} file(s) need formatting",
128 "Format check failed".red(),
129 changed.len()
130 );
131 process::exit(1);
132 }
133
134 if changed.is_empty() {
135 println!("{}", "Already formatted".green());
136 } else {
137 for c in &changed {
138 println!("{} {}", "formatted".green(), c.path.display());
139 }
140 println!("{}", format!("Formatted {} file(s)", changed.len()).green());
141 }
142}
143
144fn emit_fatal_json(kind: &str, message: &str) {
145 use aver::diagnostics::json_escape;
146 println!(
147 "{{\"schema_version\":1,\"kind\":\"file-error\",\"error_kind\":\"{}\",\"error\":{}}}",
148 kind,
149 json_escape(message)
150 );
151}
152
153#[allow(dead_code)]
154fn collect_av_files(path: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
155 if !path.exists() {
156 return Err(format!("Path '{}' does not exist", path.display()));
157 }
158
159 if path.is_file() {
160 if is_av_file(path) {
161 out.push(path.to_path_buf());
162 return Ok(());
163 }
164 return Err(format!("'{}' is not an .av file", path.display()));
165 }
166
167 let entries = fs::read_dir(path)
168 .map_err(|e| format!("Cannot read directory '{}': {}", path.display(), e))?;
169 for entry_res in entries {
170 let entry = entry_res
171 .map_err(|e| format!("Cannot read directory entry in '{}': {}", path.display(), e))?;
172 let p = entry.path();
173 if p.is_dir() {
174 collect_av_files(&p, out)?;
175 } else if is_av_file(&p) {
176 out.push(p);
177 }
178 }
179 Ok(())
180}
181
182#[allow(dead_code)]
183fn is_av_file(path: &Path) -> bool {
184 path.extension().and_then(|e| e.to_str()) == Some("av")
185}
186
187fn normalize_leading_indent_tracked(
194 line: &str,
195 source_line: usize,
196) -> (String, Option<aver::diagnostics::model::FormatViolation>) {
197 let mut end = 0usize;
198 for (idx, ch) in line.char_indices() {
199 if ch == ' ' || ch == '\t' {
200 end = idx + ch.len_utf8();
201 } else {
202 break;
203 }
204 }
205
206 let (indent, rest) = line.split_at(end);
207 if rest.is_empty() {
208 return (String::new(), None);
211 }
212
213 let had_tab = indent.contains('\t');
214 let mut out = String::new();
215 for ch in indent.chars() {
216 if ch == '\t' {
217 out.push_str(" ");
218 } else {
219 out.push(ch);
220 }
221 }
222 out.push_str(rest);
223
224 let violation = if had_tab {
225 Some(aver::diagnostics::model::FormatViolation {
226 line: source_line,
227 col: 1,
228 rule: "tab-indent",
229 message: "tab in leading indent; formatter expands to 4 spaces".to_string(),
230 before: Some(indent.replace('\t', "\\t")),
231 after: Some(indent.replace('\t', " ")),
232 })
233 } else {
234 None
235 };
236
237 (out, violation)
238}
239
240fn effect_namespace(effect: &str) -> &str {
241 match effect.split_once('.') {
242 Some((namespace, _)) => namespace,
243 None => effect,
244 }
245}
246
247fn sorted_effects(effects: &[String]) -> Vec<String> {
248 let mut sorted = effects.to_vec();
249 sorted.sort();
250 sorted
251}
252
253fn format_block_effect_declaration(indent: &str, effects: &[String]) -> Vec<String> {
254 format_bracketed_effect_list(indent, "! ", effects)
255}
256
257fn format_module_effects_declaration(indent: &str, effects: &[String]) -> Vec<String> {
258 format_bracketed_effect_list(indent, "effects ", effects)
259}
260
261fn format_bracketed_effect_list(indent: &str, lead: &str, effects: &[String]) -> Vec<String> {
262 let effects = sorted_effects(effects);
263 let inline = format!("{}{}[{}]", indent, lead, effects.join(", "));
264 if inline.len() <= 100 {
265 return vec![inline];
266 }
267
268 let mut out = vec![format!("{}{}[", indent, lead)];
269 let mut start = 0usize;
270 while start < effects.len() {
271 let namespace = effect_namespace(&effects[start]);
272 let mut end = start + 1;
273 while end < effects.len() && effect_namespace(&effects[end]) == namespace {
274 end += 1;
275 }
276 out.push(format!("{} {},", indent, effects[start..end].join(", ")));
277 start = end;
278 }
279 out.push(format!("{}]", indent));
280 out
281}
282
283fn split_top_level(src: &str, delimiter: char) -> Option<Vec<String>> {
284 let mut parts = Vec::new();
285 let mut start = 0usize;
286 let mut paren_depth = 0usize;
287 let mut bracket_depth = 0usize;
288 let mut angle_depth = 0usize;
289 let mut prev = None;
290
291 for (idx, ch) in src.char_indices() {
292 match ch {
293 '(' => paren_depth += 1,
294 ')' => paren_depth = paren_depth.checked_sub(1)?,
295 '[' => bracket_depth += 1,
296 ']' => bracket_depth = bracket_depth.checked_sub(1)?,
297 '<' => angle_depth += 1,
298 '>' if prev != Some('-') && angle_depth > 0 => angle_depth -= 1,
299 _ => {}
300 }
301
302 if ch == delimiter && paren_depth == 0 && bracket_depth == 0 && angle_depth == 0 {
303 parts.push(src[start..idx].to_string());
304 start = idx + ch.len_utf8();
305 }
306 prev = Some(ch);
307 }
308
309 if paren_depth != 0 || bracket_depth != 0 || angle_depth != 0 {
310 return None;
311 }
312
313 parts.push(src[start..].to_string());
314 Some(parts)
315}
316
317fn find_matching_paren(src: &str, open_idx: usize) -> Option<usize> {
318 let mut depth = 0usize;
319 for (idx, ch) in src.char_indices().skip_while(|(idx, _)| *idx < open_idx) {
320 match ch {
321 '(' => depth += 1,
322 ')' => {
323 depth = depth.checked_sub(1)?;
324 if depth == 0 {
325 return Some(idx);
326 }
327 }
328 _ => {}
329 }
330 }
331 None
332}
333
334fn format_type_for_source(ty: &Type) -> String {
335 match ty {
336 Type::Int => "Int".to_string(),
337 Type::Float => "Float".to_string(),
338 Type::Str => "String".to_string(),
339 Type::Bool => "Bool".to_string(),
340 Type::Unit => "Unit".to_string(),
341 Type::Result(ok, err) => format!(
342 "Result<{}, {}>",
343 format_type_for_source(ok),
344 format_type_for_source(err)
345 ),
346 Type::Option(inner) => format!("Option<{}>", format_type_for_source(inner)),
347 Type::List(inner) => format!("List<{}>", format_type_for_source(inner)),
348 Type::Vector(inner) => format!("Vector<{}>", format_type_for_source(inner)),
349 Type::Tuple(items) => format!(
350 "({})",
351 items
352 .iter()
353 .map(format_type_for_source)
354 .collect::<Vec<_>>()
355 .join(", ")
356 ),
357 Type::Map(key, value) => format!(
358 "Map<{}, {}>",
359 format_type_for_source(key),
360 format_type_for_source(value)
361 ),
362 Type::Fn(params, ret, effects) => {
363 let params = params
364 .iter()
365 .map(format_type_for_source)
366 .collect::<Vec<_>>()
367 .join(", ");
368 let ret = format_type_for_source(ret);
369 let effects = sorted_effects(effects);
370 if effects.is_empty() {
371 format!("Fn({params}) -> {ret}")
372 } else {
373 format!("Fn({params}) -> {ret} ! [{}]", effects.join(", "))
374 }
375 }
376 Type::Unknown => "Unknown".to_string(),
377 Type::Named(name) => name.clone(),
378 }
379}
380
381fn normalize_type_annotation(type_src: &str) -> String {
382 let trimmed = type_src.trim();
383 match parse_type_str_strict(trimmed) {
384 Ok(ty) => format_type_for_source(&ty),
385 Err(_) => trimmed.to_string(),
386 }
387}
388
389fn normalize_function_header_effects_line(line: &str) -> String {
390 let indent_len = line.chars().take_while(|c| *c == ' ').count();
391 let indent = " ".repeat(indent_len);
392 let trimmed = line.trim();
393 if !trimmed.starts_with("fn ") {
394 return line.to_string();
395 }
396
397 let open_idx = match trimmed.find('(') {
398 Some(idx) => idx,
399 None => return line.to_string(),
400 };
401 let close_idx = match find_matching_paren(trimmed, open_idx) {
402 Some(idx) => idx,
403 None => return line.to_string(),
404 };
405
406 let params_src = &trimmed[open_idx + 1..close_idx];
407 let params = match split_top_level(params_src, ',') {
408 Some(parts) => parts,
409 None => return line.to_string(),
410 };
411 let formatted_params = params
412 .into_iter()
413 .filter(|part| !part.trim().is_empty())
414 .map(|param| {
415 let (name, ty) = match param.split_once(':') {
416 Some(parts) => parts,
417 None => return param.trim().to_string(),
418 };
419 format!("{}: {}", name.trim(), normalize_type_annotation(ty))
420 })
421 .collect::<Vec<_>>()
422 .join(", ");
423
424 let mut formatted = format!(
425 "{}{}{})",
426 indent,
427 &trimmed[..open_idx + 1],
428 formatted_params
429 );
430 let remainder = trimmed[close_idx + 1..].trim();
431 if let Some(return_type) = remainder.strip_prefix("->") {
432 formatted.push_str(" -> ");
433 formatted.push_str(&normalize_type_annotation(return_type));
434 } else if !remainder.is_empty() {
435 formatted.push(' ');
436 formatted.push_str(remainder);
437 }
438
439 formatted
440}
441
442fn normalize_function_header_effects_tracked(
450 lines: Vec<String>,
451 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
452 line_offset: Option<&[usize]>,
453) -> Vec<String> {
454 lines
455 .into_iter()
456 .enumerate()
457 .map(|(idx, line)| {
458 let rewritten = normalize_function_header_effects_line(&line);
459 if rewritten != line {
460 let source_line = line_offset.and_then(|off| off.get(idx)).copied().unwrap_or(idx + 1);
461 violations.push(aver::diagnostics::model::FormatViolation {
462 line: source_line,
463 col: 1,
464 rule: "bad-function-header",
465 message:
466 "function signature spacing / parameter separator differs from canonical form"
467 .to_string(),
468 before: Some(line.clone()),
469 after: Some(rewritten.clone()),
470 });
471 }
472 rewritten
473 })
474 .collect()
475}
476
477fn normalize_effect_declaration_blocks_tracked(
478 lines: Vec<String>,
479 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
480 line_offset: Option<&[usize]>,
481) -> Vec<String> {
482 let mut out = Vec::with_capacity(lines.len());
483 let mut i = 0usize;
484
485 while i < lines.len() {
486 let line = &lines[i];
487 let trimmed = line.trim();
488 if !trimmed.starts_with("! [") {
489 out.push(line.clone());
490 i += 1;
491 continue;
492 }
493
494 let indent_len = line.chars().take_while(|c| *c == ' ').count();
495 let indent = " ".repeat(indent_len);
496 let mut inner = String::new();
497 let mut consumed = 0usize;
498 let mut found_close = false;
499
500 while i + consumed < lines.len() {
501 let current = &lines[i + consumed];
502 let current_trimmed = current.trim();
503 let segment = if consumed == 0 {
504 current_trimmed.trim_start_matches("! [")
505 } else {
506 current_trimmed
507 };
508
509 if let Some(before_close) = segment.strip_suffix(']') {
510 if !inner.is_empty() && !before_close.trim().is_empty() {
511 inner.push(' ');
512 }
513 inner.push_str(before_close.trim());
514 found_close = true;
515 consumed += 1;
516 break;
517 }
518
519 if !inner.is_empty() && !segment.trim().is_empty() {
520 inner.push(' ');
521 }
522 inner.push_str(segment.trim());
523 consumed += 1;
524 }
525
526 if !found_close {
527 out.push(line.clone());
528 i += 1;
529 continue;
530 }
531
532 let effects: Vec<String> = if inner.trim().is_empty() {
533 vec![]
534 } else {
535 inner
536 .split(',')
537 .map(str::trim)
538 .filter(|part| !part.is_empty())
539 .map(ToString::to_string)
540 .collect()
541 };
542
543 let original_block: Vec<String> = lines[i..i + consumed].to_vec();
544 let rewritten_block = format_block_effect_declaration(&indent, &effects);
545 if original_block != rewritten_block {
546 let source_line = line_offset
547 .and_then(|off| off.get(i))
548 .copied()
549 .unwrap_or(i + 1);
550 let rule = {
551 let mut sorted = effects.clone();
552 sorted.sort();
553 if effects != sorted {
554 "effects-unsorted"
555 } else {
556 "effects-reshape"
557 }
558 };
559 let message = match rule {
560 "effects-unsorted" => {
561 "effect list out of order; formatter sorts alphabetically".to_string()
562 }
563 _ => "effect declaration reshaped to canonical form".to_string(),
564 };
565 violations.push(aver::diagnostics::model::FormatViolation {
566 line: source_line,
567 col: 1,
568 rule,
569 message,
570 before: Some(original_block.join(" | ")),
571 after: Some(rewritten_block.join(" | ")),
572 });
573 }
574 out.extend(rewritten_block);
575 i += consumed;
576 }
577
578 out
579}
580
581#[derive(Clone, Debug, PartialEq, Eq)]
582enum BlockKind {
583 Fn(String),
584 FnStub(String),
591 Verify(String),
592 Other,
593}
594
595#[derive(Clone, Debug, PartialEq, Eq)]
596struct TopBlock {
597 text: String,
598 kind: BlockKind,
599 start_line: usize,
600}
601
602#[derive(Default)]
603struct FormatAstInfo {
604 kind_by_line: HashMap<usize, BlockKind>,
605}
606
607fn is_oracle_stub_fn(fd: &aver::ast::FnDef) -> bool {
615 if !fd.effects.is_empty() {
616 return false;
617 }
618 let Some((_, first_ty)) = fd.params.first() else {
619 return false;
620 };
621 first_ty.trim() == "BranchPath"
622 || first_ty.trim().starts_with("BranchPath ")
623 || first_ty.trim().starts_with("BranchPath\t")
624}
625
626fn classify_block(header_line: &str) -> BlockKind {
627 let trimmed = header_line.trim();
628 if let Some(rest) = trimmed.strip_prefix("fn ") {
629 let name = rest
630 .split(['(', ' ', '\t'])
631 .next()
632 .unwrap_or_default()
633 .to_string();
634 if !name.is_empty() {
635 return BlockKind::Fn(name);
636 }
637 }
638 if let Some(rest) = trimmed.strip_prefix("verify ") {
639 let name = rest
640 .split([' ', '\t'])
641 .next()
642 .unwrap_or_default()
643 .to_string();
644 if !name.is_empty() {
645 return BlockKind::Verify(name);
646 }
647 }
648 BlockKind::Other
649}
650
651fn is_top_level_start(line: &str) -> bool {
652 if line.is_empty() {
653 return false;
654 }
655 if line.starts_with(' ') || line.starts_with('\t') {
656 return false;
657 }
658 !line.trim_start().starts_with("//")
659}
660
661fn split_top_level_blocks(lines: &[String], ast_info: Option<&FormatAstInfo>) -> Vec<TopBlock> {
662 if lines.is_empty() {
663 return Vec::new();
664 }
665
666 let starts: Vec<usize> = lines
667 .iter()
668 .enumerate()
669 .filter_map(|(idx, line)| is_top_level_start(line).then_some(idx))
670 .collect();
671
672 if starts.is_empty() {
673 let text = lines.join("\n").trim_end_matches('\n').to_string();
674 if text.is_empty() {
675 return Vec::new();
676 }
677 return vec![TopBlock {
678 text,
679 kind: BlockKind::Other,
680 start_line: 1,
681 }];
682 }
683
684 let mut blocks = Vec::new();
685
686 let first = starts[0];
688 if first > 0 {
689 let mut pre = lines[..first].to_vec();
690 while pre.last().is_some_and(|l| l.is_empty()) {
691 pre.pop();
692 }
693 if !pre.is_empty() {
694 blocks.push(TopBlock {
695 text: pre.join("\n"),
696 kind: BlockKind::Other,
697 start_line: 1,
698 });
699 }
700 }
701
702 for (i, start) in starts.iter().enumerate() {
703 let end = starts.get(i + 1).copied().unwrap_or(lines.len());
704 let mut segment = lines[*start..end].to_vec();
705 while segment.last().is_some_and(|l| l.is_empty()) {
706 segment.pop();
707 }
708 if segment.is_empty() {
709 continue;
710 }
711 let header = segment[0].clone();
712 let start_line = *start + 1;
713 let kind = ast_info
714 .and_then(|info| info.kind_by_line.get(&start_line).cloned())
715 .unwrap_or_else(|| classify_block(&header));
716 blocks.push(TopBlock {
717 text: segment.join("\n"),
718 kind,
719 start_line,
720 });
721 }
722
723 blocks
724}
725
726fn reorder_verify_blocks_tracked(
727 blocks: Vec<TopBlock>,
728 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
729) -> Vec<TopBlock> {
730 let verify_blocks: Vec<TopBlock> = blocks
731 .iter()
732 .filter(|b| matches!(b.kind, BlockKind::Verify(_)))
733 .cloned()
734 .collect();
735
736 if verify_blocks.is_empty() {
737 return blocks;
738 }
739
740 let mut original_positions: HashMap<(String, usize), usize> = HashMap::new();
743 for (pos, block) in blocks.iter().enumerate() {
744 if let BlockKind::Verify(name) = &block.kind {
745 original_positions.insert((name.clone(), block.start_line), pos);
746 }
747 }
748
749 let mut by_fn: HashMap<String, Vec<usize>> = HashMap::new();
750 for (idx, block) in verify_blocks.iter().enumerate() {
751 if let BlockKind::Verify(name) = &block.kind {
752 by_fn.entry(name.clone()).or_default().push(idx);
753 }
754 }
755
756 let mut used = vec![false; verify_blocks.len()];
757 let mut out: Vec<TopBlock> = Vec::new();
758
759 let blocks_vec: Vec<TopBlock> = blocks;
760 let mut i = 0;
761 while i < blocks_vec.len() {
762 let block = &blocks_vec[i];
763 match block.kind.clone() {
764 BlockKind::Verify(_) => {
765 i += 1;
766 }
767 BlockKind::Fn(name) => {
768 out.push(block.clone());
769 i += 1;
770 while i < blocks_vec.len() && matches!(blocks_vec[i].kind, BlockKind::FnStub(_)) {
775 out.push(blocks_vec[i].clone());
776 i += 1;
777 }
778 if let Some(indices) = by_fn.remove(&name) {
779 for idx in indices {
780 used[idx] = true;
781 out.push(verify_blocks[idx].clone());
782 }
783 }
784 }
785 BlockKind::FnStub(_) => {
786 out.push(block.clone());
789 i += 1;
790 }
791 BlockKind::Other => {
792 out.push(block.clone());
793 i += 1;
794 }
795 }
796 }
797
798 for (idx, block) in verify_blocks.iter().enumerate() {
799 if !used[idx] {
800 out.push(block.clone());
801 }
802 }
803
804 for (new_pos, block) in out.iter().enumerate() {
808 if let BlockKind::Verify(name) = &block.kind {
809 let key = (name.clone(), block.start_line);
810 if let Some(&orig_pos) = original_positions.get(&key)
811 && orig_pos != new_pos
812 {
813 violations.push(aver::diagnostics::model::FormatViolation {
814 line: block.start_line,
815 col: 1,
816 rule: "verify-misplaced",
817 message: format!(
818 "verify block '{}' should be placed immediately after its function",
819 name
820 ),
821 before: None,
822 after: None,
823 });
824 }
825 }
826 }
827
828 out
829}
830
831fn parse_ast_info_checked(source: &str) -> Result<FormatAstInfo, String> {
832 let mut lexer = Lexer::new(source);
833 let tokens = lexer.tokenize().map_err(|e| e.to_string())?;
834 let mut parser = Parser::new(tokens);
835 let items = parser.parse().map_err(|e| e.to_string())?;
836
837 let mut info = FormatAstInfo::default();
838 for item in items {
839 match item {
840 TopLevel::FnDef(fd) => {
841 let kind = if is_oracle_stub_fn(&fd) {
842 BlockKind::FnStub(fd.name.clone())
843 } else {
844 BlockKind::Fn(fd.name.clone())
845 };
846 info.kind_by_line.insert(fd.line, kind);
847 }
848 TopLevel::Verify(vb) => {
849 info.kind_by_line
850 .insert(vb.line, BlockKind::Verify(vb.fn_name.clone()));
851 }
852 _ => {}
853 }
854 }
855 Ok(info)
856}
857
858fn normalize_source_lines_tracked(
866 source: &str,
867 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
868) -> Vec<String> {
869 let normalized = source.replace("\r\n", "\n").replace('\r', "\n");
870
871 let mut lines = Vec::new();
872 let mut line_offset: Vec<usize> = Vec::new();
877 for (idx, raw) in normalized.split('\n').enumerate() {
878 let trimmed = raw.trim_end_matches([' ', '\t']);
879 if trimmed.len() != raw.len() {
880 violations.push(aver::diagnostics::model::FormatViolation {
881 line: idx + 1,
882 col: trimmed.len() + 1,
883 rule: "trailing-whitespace",
884 message: "trailing whitespace".to_string(),
885 before: None,
886 after: None,
887 });
888 }
889 let (line, violation) = normalize_leading_indent_tracked(trimmed, idx + 1);
890 if let Some(v) = violation {
891 violations.push(v);
892 }
893 lines.push(line);
894 line_offset.push(idx + 1);
895 }
896
897 let lines = normalize_effect_declaration_blocks_tracked(lines, violations, Some(&line_offset));
898 let lines = normalize_function_header_effects_tracked(lines, violations, Some(&line_offset));
899 let lines = normalize_module_intent_blocks_tracked(lines, violations, Some(&line_offset));
900 let lines = normalize_module_effects_blocks_tracked(lines, violations, Some(&line_offset));
901 normalize_inline_decision_fields_tracked(lines, violations, Some(&line_offset))
902}
903
904fn normalize_module_effects_blocks_tracked(
905 lines: Vec<String>,
906 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
907 line_offset: Option<&[usize]>,
908) -> Vec<String> {
909 let mut out = Vec::with_capacity(lines.len());
910 let mut in_module_header = false;
911 let mut i = 0usize;
912
913 while i < lines.len() {
914 let line = &lines[i];
915 let trimmed = line.trim();
916 let indent_len = line.chars().take_while(|c| *c == ' ').count();
917
918 if indent_len == 0 && trimmed.starts_with("module ") {
919 in_module_header = true;
920 out.push(line.clone());
921 i += 1;
922 continue;
923 }
924 if in_module_header && indent_len == 0 && !trimmed.is_empty() && !trimmed.starts_with("//")
925 {
926 in_module_header = false;
927 }
928
929 if !(in_module_header && indent_len > 0 && trimmed.starts_with("effects ")) {
930 out.push(line.clone());
931 i += 1;
932 continue;
933 }
934
935 let indent = " ".repeat(indent_len);
938 let head = trimmed.trim_start_matches("effects").trim_start();
939 if !head.starts_with('[') {
940 out.push(line.clone());
941 i += 1;
942 continue;
943 }
944
945 let mut inner = String::new();
946 let mut consumed = 1usize;
947 let mut found_close = false;
948 let first_open = &head[1..];
949
950 if let Some(before_close) = first_open.strip_suffix(']') {
952 inner.push_str(before_close.trim());
953 found_close = true;
954 } else {
955 inner.push_str(first_open.trim());
957 while i + consumed < lines.len() {
958 let next = &lines[i + consumed];
959 let next_trimmed = next.trim();
960 if let Some(before_close) = next_trimmed.strip_suffix(']') {
961 if !inner.is_empty() && !before_close.trim().is_empty() {
962 inner.push(' ');
963 }
964 inner.push_str(before_close.trim());
965 consumed += 1;
966 found_close = true;
967 break;
968 }
969 if !inner.is_empty() && !next_trimmed.is_empty() {
970 inner.push(' ');
971 }
972 inner.push_str(next_trimmed);
973 consumed += 1;
974 }
975 }
976
977 if !found_close {
978 out.push(line.clone());
979 i += 1;
980 continue;
981 }
982
983 let effects: Vec<String> = if inner.trim().is_empty() {
984 vec![]
985 } else {
986 inner
987 .split(',')
988 .map(str::trim)
989 .filter(|part| !part.is_empty())
990 .map(ToString::to_string)
991 .collect()
992 };
993
994 let original_block: Vec<String> = lines[i..i + consumed].to_vec();
995 let rewritten_block = format_module_effects_declaration(&indent, &effects);
996 if original_block != rewritten_block {
997 let source_line = line_offset
998 .and_then(|off| off.get(i))
999 .copied()
1000 .unwrap_or(i + 1);
1001 let rule = {
1002 let mut sorted = effects.clone();
1003 sorted.sort();
1004 if effects != sorted {
1005 "module-effects-unsorted"
1006 } else {
1007 "module-effects-reshape"
1008 }
1009 };
1010 let message = match rule {
1011 "module-effects-unsorted" => {
1012 "module effect list out of order; formatter sorts alphabetically".to_string()
1013 }
1014 _ => "module effect declaration reshaped to canonical form".to_string(),
1015 };
1016 violations.push(aver::diagnostics::model::FormatViolation {
1017 line: source_line,
1018 col: 1,
1019 rule,
1020 message,
1021 before: Some(original_block.join(" | ")),
1022 after: Some(rewritten_block.join(" | ")),
1023 });
1024 }
1025 out.extend(rewritten_block);
1026 i += consumed;
1027 }
1028
1029 out
1030}
1031
1032fn normalize_module_intent_blocks_tracked(
1033 lines: Vec<String>,
1034 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
1035 line_offset: Option<&[usize]>,
1036) -> Vec<String> {
1037 let before = lines.clone();
1038 let after = normalize_module_intent_blocks_impl(lines);
1039 if before != after {
1040 let diff_idx = before
1042 .iter()
1043 .zip(&after)
1044 .position(|(a, b)| a != b)
1045 .unwrap_or(0);
1046 let source_line = line_offset
1047 .and_then(|off| off.get(diff_idx))
1048 .copied()
1049 .unwrap_or(diff_idx + 1);
1050 violations.push(aver::diagnostics::model::FormatViolation {
1051 line: source_line,
1052 col: 1,
1053 rule: "module-intent-reshape",
1054 message: "module intent block reshaped to canonical multiline form".to_string(),
1055 before: None,
1056 after: None,
1057 });
1058 }
1059 after
1060}
1061
1062fn normalize_module_intent_blocks_impl(lines: Vec<String>) -> Vec<String> {
1063 let mut out = Vec::with_capacity(lines.len());
1064 let mut in_module_header = false;
1065 let mut i = 0usize;
1066
1067 while i < lines.len() {
1068 let line = &lines[i];
1069 let trimmed = line.trim();
1070 let indent = line.chars().take_while(|c| *c == ' ').count();
1071
1072 if indent == 0 && trimmed.starts_with("module ") {
1073 in_module_header = true;
1074 out.push(line.clone());
1075 i += 1;
1076 continue;
1077 }
1078
1079 if in_module_header && indent == 0 && !trimmed.is_empty() && !trimmed.starts_with("//") {
1080 in_module_header = false;
1081 }
1082
1083 if in_module_header && indent > 0 {
1084 let head = &line[indent..];
1085 if let Some(rhs) = head.strip_prefix("intent =") {
1086 let rhs_trimmed = rhs.trim_start();
1087 if rhs_trimmed.starts_with('"') {
1088 let mut parts = vec![rhs_trimmed.to_string()];
1089 let mut consumed = 1usize;
1090
1091 while i + consumed < lines.len() {
1092 let next = &lines[i + consumed];
1093 let next_indent = next.chars().take_while(|c| *c == ' ').count();
1094 let next_trimmed = next.trim();
1095
1096 if next_indent <= indent || next_trimmed.is_empty() {
1097 break;
1098 }
1099 if !next_trimmed.starts_with('"') {
1100 break;
1101 }
1102
1103 parts.push(next_trimmed.to_string());
1104 consumed += 1;
1105 }
1106
1107 if parts.len() > 1 {
1108 out.push(format!("{}intent =", " ".repeat(indent)));
1109 for part in parts {
1110 out.push(format!("{}{}", " ".repeat(indent + 4), part));
1111 }
1112 i += consumed;
1113 continue;
1114 }
1115 }
1116 }
1117 }
1118
1119 out.push(line.clone());
1120 i += 1;
1121 }
1122
1123 out
1124}
1125
1126fn normalize_internal_blank_runs_tracked(
1130 text: &str,
1131 block_start_line: usize,
1132 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
1133) -> String {
1134 let mut out = Vec::new();
1135 let mut blank_run = 0usize;
1136 let mut run_start_idx: Option<usize> = None;
1137 for (rel_idx, raw) in text.split('\n').enumerate() {
1138 if raw.is_empty() {
1139 if blank_run == 0 {
1140 run_start_idx = Some(rel_idx);
1141 }
1142 blank_run += 1;
1143 if blank_run <= 2 {
1144 out.push(String::new());
1145 }
1146 } else {
1147 if blank_run > 2
1148 && let Some(start) = run_start_idx
1149 {
1150 let line = block_start_line.saturating_add(start).max(1);
1151 violations.push(aver::diagnostics::model::FormatViolation {
1152 line,
1153 col: 1,
1154 rule: "excess-blank",
1155 message: format!(
1156 "{} consecutive blank lines; formatter collapses to 2",
1157 blank_run
1158 ),
1159 before: None,
1160 after: None,
1161 });
1162 }
1163 blank_run = 0;
1164 run_start_idx = None;
1165 out.push(raw.to_string());
1166 }
1167 }
1168 while out.first().is_some_and(|l| l.is_empty()) {
1169 out.remove(0);
1170 }
1171 while out.last().is_some_and(|l| l.is_empty()) {
1172 out.pop();
1173 }
1174 out.join("\n")
1175}
1176
1177const DECISION_FIELDS: [&str; 6] = ["date", "author", "reason", "chosen", "rejected", "impacts"];
1178
1179fn starts_with_decision_field(content: &str) -> bool {
1180 DECISION_FIELDS
1181 .iter()
1182 .any(|field| content.starts_with(&format!("{field} =")))
1183}
1184
1185fn find_next_decision_field_boundary(s: &str) -> Option<usize> {
1186 let mut best: Option<usize> = None;
1187 for field in DECISION_FIELDS {
1188 let needle = format!(" {field} =");
1189 let mut search_from = 0usize;
1190 while let Some(rel) = s[search_from..].find(&needle) {
1191 let idx = search_from + rel;
1192 let spaces_before = s[..idx].chars().rev().take_while(|c| *c == ' ').count();
1195 let total_separator_spaces = spaces_before + 1;
1197 if total_separator_spaces >= 2 {
1198 let field_start = idx + 1;
1199 best = Some(best.map_or(field_start, |cur| cur.min(field_start)));
1200 break;
1201 }
1202 search_from = idx + 1;
1203 }
1204 }
1205 best
1206}
1207
1208fn split_inline_decision_fields(content: &str) -> Vec<String> {
1209 if !starts_with_decision_field(content) {
1210 return vec![content.to_string()];
1211 }
1212 let mut out = Vec::new();
1213 let mut rest = content.trim_end().to_string();
1214 while let Some(idx) = find_next_decision_field_boundary(&rest) {
1215 let left = rest[..idx].trim_end().to_string();
1216 if left.is_empty() {
1217 break;
1218 }
1219 out.push(left);
1220 rest = rest[idx..].trim_start().to_string();
1221 }
1222 if !rest.is_empty() {
1223 out.push(rest.trim_end().to_string());
1224 }
1225 if out.is_empty() {
1226 vec![content.to_string()]
1227 } else {
1228 out
1229 }
1230}
1231
1232fn normalize_inline_decision_fields_tracked(
1233 lines: Vec<String>,
1234 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
1235 line_offset: Option<&[usize]>,
1236) -> Vec<String> {
1237 let before = lines.clone();
1238 let after = normalize_inline_decision_fields_impl(lines);
1239 if before != after {
1240 let diff_idx = before
1241 .iter()
1242 .zip(&after)
1243 .position(|(a, b)| a != b)
1244 .unwrap_or(0);
1245 let source_line = line_offset
1246 .and_then(|off| off.get(diff_idx))
1247 .copied()
1248 .unwrap_or(diff_idx + 1);
1249 violations.push(aver::diagnostics::model::FormatViolation {
1250 line: source_line,
1251 col: 1,
1252 rule: "decision-inline",
1253 message: "decision fields should each live on their own line".to_string(),
1254 before: None,
1255 after: None,
1256 });
1257 }
1258 after
1259}
1260
1261fn normalize_inline_decision_fields_impl(lines: Vec<String>) -> Vec<String> {
1262 let mut out = Vec::with_capacity(lines.len());
1263 let mut in_decision = false;
1264
1265 for line in lines {
1266 let trimmed = line.trim();
1267 let indent = line.chars().take_while(|c| *c == ' ').count();
1268
1269 if indent == 0 && trimmed.starts_with("decision ") {
1270 in_decision = true;
1271 out.push(line);
1272 continue;
1273 }
1274
1275 if in_decision && indent == 0 && !trimmed.is_empty() && !trimmed.starts_with("//") {
1276 in_decision = false;
1277 }
1278
1279 if in_decision && trimmed.is_empty() {
1280 continue;
1281 }
1282
1283 if in_decision && indent > 0 {
1284 let content = &line[indent..];
1285 let parts = split_inline_decision_fields(content);
1286 if parts.len() > 1 {
1287 for part in parts {
1288 out.push(format!("{}{}", " ".repeat(indent), part));
1289 }
1290 continue;
1291 }
1292 }
1293
1294 out.push(line);
1295 }
1296
1297 out
1298}
1299
1300pub fn try_format_source(
1307 source: &str,
1308) -> Result<(String, Vec<aver::diagnostics::model::FormatViolation>), String> {
1309 let mut violations: Vec<aver::diagnostics::model::FormatViolation> = Vec::new();
1310
1311 if !source.is_empty() && !source.ends_with('\n') {
1312 let last_line = source.lines().count().max(1);
1313 violations.push(aver::diagnostics::model::FormatViolation {
1314 line: last_line,
1315 col: source.lines().last().map(str::len).unwrap_or(0) + 1,
1316 rule: "missing-final-newline",
1317 message: "file must end with a single newline".to_string(),
1318 before: None,
1319 after: None,
1320 });
1321 }
1322
1323 let lines = normalize_source_lines_tracked(source, &mut violations);
1324 let normalized = lines.join("\n");
1325 let ast_info = parse_ast_info_checked(&normalized)?;
1326
1327 let blocks = split_top_level_blocks(&lines, Some(&ast_info));
1329 let reordered = reorder_verify_blocks_tracked(blocks, &mut violations);
1330
1331 let mut non_empty_blocks = Vec::new();
1333 for block in reordered {
1334 let text =
1335 normalize_internal_blank_runs_tracked(&block.text, block.start_line, &mut violations);
1336 let text = text.trim_matches('\n').to_string();
1337 if !text.is_empty() {
1338 non_empty_blocks.push(text);
1339 }
1340 }
1341
1342 if non_empty_blocks.is_empty() {
1343 return Ok(("\n".to_string(), violations));
1344 }
1345 let mut out = non_empty_blocks.join("\n\n");
1346 out.push('\n');
1347 Ok((out, violations))
1348}
1349
1350#[cfg(test)]
1351pub fn format_source(source: &str) -> String {
1352 match try_format_source(source) {
1353 Ok((formatted, _violations)) => formatted,
1354 Err(err) => panic!("format_source received invalid Aver source: {err}"),
1355 }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360 use super::{format_source, try_format_source};
1361
1362 #[test]
1363 fn normalizes_line_endings_and_trailing_ws() {
1364 let src = "module A\r\n fn x() -> Int \r\n 1\t \r\n";
1365 let got = format_source(src);
1366 assert_eq!(got, "module A\n fn x() -> Int\n 1\n");
1367 }
1368
1369 #[test]
1370 fn converts_leading_tabs_only() {
1371 let src = "\tfn x() -> String\n\t\t\"a\\tb\"\n";
1372 let got = format_source(src);
1373 assert_eq!(got, " fn x() -> String\n \"a\\tb\"\n");
1374 }
1375
1376 #[test]
1377 fn collapses_long_blank_runs() {
1378 let src = "module A\n\n\n\nfn x() -> Int\n 1\n";
1379 let got = format_source(src);
1380 assert_eq!(got, "module A\n\nfn x() -> Int\n 1\n");
1381 }
1382
1383 #[test]
1384 fn keeps_single_final_newline() {
1385 let src = "module A\nfn x() -> Int\n 1\n\n\n";
1386 let got = format_source(src);
1387 assert_eq!(got, "module A\n\nfn x() -> Int\n 1\n");
1388 }
1389
1390 #[test]
1391 fn rejects_removed_eq_expr_syntax() {
1392 let src = "fn x() -> Int\n = 1\n";
1393 let err = try_format_source(src).expect_err("old '= expr' syntax should fail");
1394 assert!(
1395 err.contains("no longer use '= expr'"),
1396 "unexpected error: {}",
1397 err
1398 );
1399 }
1400
1401 #[test]
1402 fn moves_verify_directly_under_function() {
1403 let src = r#"module Demo
1404
1405fn a(x: Int) -> Int
1406 x + 1
1407
1408fn b(x: Int) -> Int
1409 x + 2
1410
1411verify a
1412 a(1) => 2
1413
1414verify b
1415 b(1) => 3
1416"#;
1417 let got = format_source(src);
1418 assert_eq!(
1419 got,
1420 r#"module Demo
1421
1422fn a(x: Int) -> Int
1423 x + 1
1424
1425verify a
1426 a(1) => 2
1427
1428fn b(x: Int) -> Int
1429 x + 2
1430
1431verify b
1432 b(1) => 3
1433"#
1434 );
1435 }
1436
1437 #[test]
1438 fn leaves_orphan_verify_at_end() {
1439 let src = r#"module Demo
1440
1441verify missing
1442 missing(1) => 2
1443"#;
1444 let got = format_source(src);
1445 assert_eq!(
1446 got,
1447 r#"module Demo
1448
1449verify missing
1450 missing(1) => 2
1451"#
1452 );
1453 }
1454
1455 #[test]
1456 fn keeps_inline_module_intent_inline() {
1457 let src = r#"module Demo
1458 intent = "Inline intent."
1459 exposes [x]
1460fn x() -> Int
1461 1
1462"#;
1463 let got = format_source(src);
1464 assert_eq!(
1465 got,
1466 r#"module Demo
1467 intent = "Inline intent."
1468 exposes [x]
1469
1470fn x() -> Int
1471 1
1472"#
1473 );
1474 }
1475
1476 #[test]
1477 fn expands_multiline_module_intent_to_block() {
1478 let src = r#"module Demo
1479 intent = "First line."
1480 "Second line."
1481 exposes [x]
1482fn x() -> Int
1483 1
1484"#;
1485 let got = format_source(src);
1486 assert_eq!(
1487 got,
1488 r#"module Demo
1489 intent =
1490 "First line."
1491 "Second line."
1492 exposes [x]
1493
1494fn x() -> Int
1495 1
1496"#
1497 );
1498 }
1499
1500 #[test]
1501 fn splits_inline_decision_fields_to_separate_lines() {
1502 let src = r#"module Demo
1503 intent = "x"
1504 exposes [main]
1505
1506decision D
1507 date = "2026-03-02"
1508 chosen = "A" rejected = ["B"]
1509 impacts = [main]
1510"#;
1511 let got = format_source(src);
1512 assert_eq!(
1513 got,
1514 r#"module Demo
1515 intent = "x"
1516 exposes [main]
1517
1518decision D
1519 date = "2026-03-02"
1520 chosen = "A"
1521 rejected = ["B"]
1522 impacts = [main]
1523"#
1524 );
1525 }
1526
1527 #[test]
1528 fn keeps_inline_function_description_inline() {
1529 let src = r#"fn add(a: Int, b: Int) -> Int
1530 ? "Adds two numbers."
1531 a + b
1532"#;
1533 let got = format_source(src);
1534 assert_eq!(
1535 got,
1536 r#"fn add(a: Int, b: Int) -> Int
1537 ? "Adds two numbers."
1538 a + b
1539"#
1540 );
1541 }
1542
1543 #[test]
1544 fn keeps_short_effect_lists_inline() {
1545 let src = r#"fn apply(f: Fn(Int) -> Int ! [Console.warn, Console.print], x: Int) -> Int
1546 ! [Http.post, Console.print, Http.get, Console.warn]
1547 f(x)
1548"#;
1549 let got = format_source(src);
1550 assert_eq!(
1551 got,
1552 r#"fn apply(f: Fn(Int) -> Int ! [Console.print, Console.warn], x: Int) -> Int
1553 ! [Console.print, Console.warn, Http.get, Http.post]
1554 f(x)
1555"#
1556 );
1557 }
1558
1559 #[test]
1560 fn keeps_medium_effect_lists_inline_when_they_fit() {
1561 let src = r#"fn run() -> Unit
1562 ! [Args, Console, Disk, Http, Random, Tcp, Terminal, Time]
1563 Unit
1564"#;
1565 let got = format_source(src);
1566 assert_eq!(
1567 got,
1568 r#"fn run() -> Unit
1569 ! [Args, Console, Disk, Http, Random, Tcp, Terminal, Time]
1570 Unit
1571"#
1572 );
1573 }
1574
1575 #[test]
1576 fn expands_long_effect_lists_to_multiline_alphabetical_groups() {
1577 let src = r#"fn main() -> Unit
1578 ! [Args.get, Console.print, Console.warn, Time.now, Disk.makeDir, Disk.exists, Disk.readText, Disk.writeText, Disk.appendText]
1579 Unit
1580"#;
1581 let got = format_source(src);
1582 assert_eq!(
1583 got,
1584 r#"fn main() -> Unit
1585 ! [
1586 Args.get,
1587 Console.print, Console.warn,
1588 Disk.appendText, Disk.exists, Disk.makeDir, Disk.readText, Disk.writeText,
1589 Time.now,
1590 ]
1591 Unit
1592"#
1593 );
1594 }
1595
1596 #[test]
1597 fn sorts_function_type_effects_inline() {
1598 let src = r#"fn useHandler(handler: Fn(Int) -> Result<String, String> ! [Time.now, Args.get, Console.warn, Console.print, Disk.readText], value: Int) -> Unit
1599 handler(value)
1600"#;
1601 let got = format_source(src);
1602 assert_eq!(
1603 got,
1604 r#"fn useHandler(handler: Fn(Int) -> Result<String, String> ! [Args.get, Console.print, Console.warn, Disk.readText, Time.now], value: Int) -> Unit
1605 handler(value)
1606"#
1607 );
1608 }
1609
1610 #[test]
1611 fn keeps_long_function_type_effects_inline() {
1612 let src = r#"fn apply(handler: Fn(Int) -> Int ! [Time.now, Args.get, Console.warn, Console.print, Disk.readText], value: Int) -> Int
1613 handler(value)
1614"#;
1615 let got = format_source(src);
1616 assert_eq!(
1617 got,
1618 r#"fn apply(handler: Fn(Int) -> Int ! [Args.get, Console.print, Console.warn, Disk.readText, Time.now], value: Int) -> Int
1619 handler(value)
1620"#
1621 );
1622 }
1623
1624 #[test]
1625 fn sorts_module_effects_inline() {
1626 let src = "module M\n intent = \"t\"\n effects [Time.now, Console.print]\n";
1627 let got = format_source(src);
1628 assert_eq!(
1629 got,
1630 "module M\n intent = \"t\"\n effects [Console.print, Time.now]\n"
1631 );
1632 }
1633
1634 #[test]
1635 fn keeps_short_module_effects_inline() {
1636 let src = "module M\n intent = \"t\"\n effects [Console.print]\n";
1637 let got = format_source(src);
1638 assert_eq!(got, src);
1639 }
1640
1641 #[test]
1642 fn expands_long_module_effects_to_multiline() {
1643 let src = "module M\n intent = \"t\"\n effects [Time.now, Args.get, Console.warn, Console.print, Disk.readText, Disk.writeText, Random.int, Random.float]\n";
1644 let got = format_source(src);
1645 assert_eq!(
1646 got,
1647 "module M\n intent = \"t\"\n effects [\n Args.get,\n Console.print, Console.warn,\n Disk.readText, Disk.writeText,\n Random.float, Random.int,\n Time.now,\n ]\n"
1648 );
1649 }
1650
1651 #[test]
1652 fn collapses_short_multiline_module_effects_back_to_inline() {
1653 let src = "module M\n intent = \"t\"\n effects [\n Console.print,\n Time.now,\n ]\n";
1654 let got = format_source(src);
1655 assert_eq!(
1656 got,
1657 "module M\n intent = \"t\"\n effects [Console.print, Time.now]\n"
1658 );
1659 }
1660}