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::Var(name) => name.clone(),
377 Type::Invalid => "Invalid".to_string(),
378 Type::Named(name) => name.clone(),
379 }
380}
381
382fn normalize_type_annotation(type_src: &str) -> String {
383 let trimmed = type_src.trim();
384 match parse_type_str_strict(trimmed) {
385 Ok(ty) => format_type_for_source(&ty),
386 Err(_) => trimmed.to_string(),
387 }
388}
389
390fn normalize_function_header_effects_line(line: &str) -> String {
391 let indent_len = line.chars().take_while(|c| *c == ' ').count();
392 let indent = " ".repeat(indent_len);
393 let trimmed = line.trim();
394 if !trimmed.starts_with("fn ") {
395 return line.to_string();
396 }
397
398 let open_idx = match trimmed.find('(') {
399 Some(idx) => idx,
400 None => return line.to_string(),
401 };
402 let close_idx = match find_matching_paren(trimmed, open_idx) {
403 Some(idx) => idx,
404 None => return line.to_string(),
405 };
406
407 let params_src = &trimmed[open_idx + 1..close_idx];
408 let params = match split_top_level(params_src, ',') {
409 Some(parts) => parts,
410 None => return line.to_string(),
411 };
412 let formatted_params = params
413 .into_iter()
414 .filter(|part| !part.trim().is_empty())
415 .map(|param| {
416 let (name, ty) = match param.split_once(':') {
417 Some(parts) => parts,
418 None => return param.trim().to_string(),
419 };
420 format!("{}: {}", name.trim(), normalize_type_annotation(ty))
421 })
422 .collect::<Vec<_>>()
423 .join(", ");
424
425 let mut formatted = format!(
426 "{}{}{})",
427 indent,
428 &trimmed[..open_idx + 1],
429 formatted_params
430 );
431 let remainder = trimmed[close_idx + 1..].trim();
432 if let Some(return_type) = remainder.strip_prefix("->") {
433 formatted.push_str(" -> ");
434 formatted.push_str(&normalize_type_annotation(return_type));
435 } else if !remainder.is_empty() {
436 formatted.push(' ');
437 formatted.push_str(remainder);
438 }
439
440 formatted
441}
442
443fn normalize_function_header_effects_tracked(
451 lines: Vec<String>,
452 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
453 line_offset: Option<&[usize]>,
454) -> Vec<String> {
455 lines
456 .into_iter()
457 .enumerate()
458 .map(|(idx, line)| {
459 let rewritten = normalize_function_header_effects_line(&line);
460 if rewritten != line {
461 let source_line = line_offset.and_then(|off| off.get(idx)).copied().unwrap_or(idx + 1);
462 violations.push(aver::diagnostics::model::FormatViolation {
463 line: source_line,
464 col: 1,
465 rule: "bad-function-header",
466 message:
467 "function signature spacing / parameter separator differs from canonical form"
468 .to_string(),
469 before: Some(line.clone()),
470 after: Some(rewritten.clone()),
471 });
472 }
473 rewritten
474 })
475 .collect()
476}
477
478fn normalize_effect_declaration_blocks_tracked(
479 lines: Vec<String>,
480 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
481 line_offset: Option<&[usize]>,
482) -> Vec<String> {
483 let mut out = Vec::with_capacity(lines.len());
484 let mut i = 0usize;
485
486 while i < lines.len() {
487 let line = &lines[i];
488 let trimmed = line.trim();
489 if !trimmed.starts_with("! [") {
490 out.push(line.clone());
491 i += 1;
492 continue;
493 }
494
495 let indent_len = line.chars().take_while(|c| *c == ' ').count();
496 let indent = " ".repeat(indent_len);
497 let mut inner = String::new();
498 let mut consumed = 0usize;
499 let mut found_close = false;
500
501 while i + consumed < lines.len() {
502 let current = &lines[i + consumed];
503 let current_trimmed = current.trim();
504 let segment = if consumed == 0 {
505 current_trimmed.trim_start_matches("! [")
506 } else {
507 current_trimmed
508 };
509
510 if let Some(before_close) = segment.strip_suffix(']') {
511 if !inner.is_empty() && !before_close.trim().is_empty() {
512 inner.push(' ');
513 }
514 inner.push_str(before_close.trim());
515 found_close = true;
516 consumed += 1;
517 break;
518 }
519
520 if !inner.is_empty() && !segment.trim().is_empty() {
521 inner.push(' ');
522 }
523 inner.push_str(segment.trim());
524 consumed += 1;
525 }
526
527 if !found_close {
528 out.push(line.clone());
529 i += 1;
530 continue;
531 }
532
533 let effects: Vec<String> = if inner.trim().is_empty() {
534 vec![]
535 } else {
536 inner
537 .split(',')
538 .map(str::trim)
539 .filter(|part| !part.is_empty())
540 .map(ToString::to_string)
541 .collect()
542 };
543
544 let original_block: Vec<String> = lines[i..i + consumed].to_vec();
545 let rewritten_block = format_block_effect_declaration(&indent, &effects);
546 if original_block != rewritten_block {
547 let source_line = line_offset
548 .and_then(|off| off.get(i))
549 .copied()
550 .unwrap_or(i + 1);
551 let rule = {
552 let mut sorted = effects.clone();
553 sorted.sort();
554 if effects != sorted {
555 "effects-unsorted"
556 } else {
557 "effects-reshape"
558 }
559 };
560 let message = match rule {
561 "effects-unsorted" => {
562 "effect list out of order; formatter sorts alphabetically".to_string()
563 }
564 _ => "effect declaration reshaped to canonical form".to_string(),
565 };
566 violations.push(aver::diagnostics::model::FormatViolation {
567 line: source_line,
568 col: 1,
569 rule,
570 message,
571 before: Some(original_block.join(" | ")),
572 after: Some(rewritten_block.join(" | ")),
573 });
574 }
575 out.extend(rewritten_block);
576 i += consumed;
577 }
578
579 out
580}
581
582#[derive(Clone, Debug, PartialEq, Eq)]
583enum BlockKind {
584 Fn(String),
585 FnStub(String),
592 Verify(String),
593 Other,
594}
595
596#[derive(Clone, Debug, PartialEq, Eq)]
597struct TopBlock {
598 text: String,
599 kind: BlockKind,
600 start_line: usize,
601}
602
603#[derive(Default)]
604struct FormatAstInfo {
605 kind_by_line: HashMap<usize, BlockKind>,
606}
607
608fn is_oracle_stub_fn(fd: &aver::ast::FnDef) -> bool {
616 if !fd.effects.is_empty() {
617 return false;
618 }
619 let Some((_, first_ty)) = fd.params.first() else {
620 return false;
621 };
622 first_ty.trim() == "BranchPath"
623 || first_ty.trim().starts_with("BranchPath ")
624 || first_ty.trim().starts_with("BranchPath\t")
625}
626
627fn classify_block(header_line: &str) -> BlockKind {
628 let trimmed = header_line.trim();
629 if let Some(rest) = trimmed.strip_prefix("fn ") {
630 let name = rest
631 .split(['(', ' ', '\t'])
632 .next()
633 .unwrap_or_default()
634 .to_string();
635 if !name.is_empty() {
636 return BlockKind::Fn(name);
637 }
638 }
639 if let Some(rest) = trimmed.strip_prefix("verify ") {
640 let name = rest
641 .split([' ', '\t'])
642 .next()
643 .unwrap_or_default()
644 .to_string();
645 if !name.is_empty() {
646 return BlockKind::Verify(name);
647 }
648 }
649 BlockKind::Other
650}
651
652fn is_top_level_start(line: &str) -> bool {
653 if line.is_empty() {
654 return false;
655 }
656 if line.starts_with(' ') || line.starts_with('\t') {
657 return false;
658 }
659 !line.trim_start().starts_with("//")
660}
661
662fn split_top_level_blocks(lines: &[String], ast_info: Option<&FormatAstInfo>) -> Vec<TopBlock> {
663 if lines.is_empty() {
664 return Vec::new();
665 }
666
667 let starts: Vec<usize> = lines
668 .iter()
669 .enumerate()
670 .filter_map(|(idx, line)| is_top_level_start(line).then_some(idx))
671 .collect();
672
673 if starts.is_empty() {
674 let text = lines.join("\n").trim_end_matches('\n').to_string();
675 if text.is_empty() {
676 return Vec::new();
677 }
678 return vec![TopBlock {
679 text,
680 kind: BlockKind::Other,
681 start_line: 1,
682 }];
683 }
684
685 let mut blocks = Vec::new();
686
687 let first = starts[0];
689 if first > 0 {
690 let mut pre = lines[..first].to_vec();
691 while pre.last().is_some_and(|l| l.is_empty()) {
692 pre.pop();
693 }
694 if !pre.is_empty() {
695 blocks.push(TopBlock {
696 text: pre.join("\n"),
697 kind: BlockKind::Other,
698 start_line: 1,
699 });
700 }
701 }
702
703 for (i, start) in starts.iter().enumerate() {
704 let end = starts.get(i + 1).copied().unwrap_or(lines.len());
705 let mut segment = lines[*start..end].to_vec();
706 while segment.last().is_some_and(|l| l.is_empty()) {
707 segment.pop();
708 }
709 if segment.is_empty() {
710 continue;
711 }
712 let header = segment[0].clone();
713 let start_line = *start + 1;
714 let kind = ast_info
715 .and_then(|info| info.kind_by_line.get(&start_line).cloned())
716 .unwrap_or_else(|| classify_block(&header));
717 blocks.push(TopBlock {
718 text: segment.join("\n"),
719 kind,
720 start_line,
721 });
722 }
723
724 blocks
725}
726
727fn reorder_verify_blocks_tracked(
728 blocks: Vec<TopBlock>,
729 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
730) -> Vec<TopBlock> {
731 let verify_blocks: Vec<TopBlock> = blocks
732 .iter()
733 .filter(|b| matches!(b.kind, BlockKind::Verify(_)))
734 .cloned()
735 .collect();
736
737 if verify_blocks.is_empty() {
738 return blocks;
739 }
740
741 let mut original_positions: HashMap<(String, usize), usize> = HashMap::new();
744 for (pos, block) in blocks.iter().enumerate() {
745 if let BlockKind::Verify(name) = &block.kind {
746 original_positions.insert((name.clone(), block.start_line), pos);
747 }
748 }
749
750 let mut by_fn: HashMap<String, Vec<usize>> = HashMap::new();
751 for (idx, block) in verify_blocks.iter().enumerate() {
752 if let BlockKind::Verify(name) = &block.kind {
753 by_fn.entry(name.clone()).or_default().push(idx);
754 }
755 }
756
757 let mut used = vec![false; verify_blocks.len()];
758 let mut out: Vec<TopBlock> = Vec::new();
759
760 let blocks_vec: Vec<TopBlock> = blocks;
761 let mut i = 0;
762 while i < blocks_vec.len() {
763 let block = &blocks_vec[i];
764 match block.kind.clone() {
765 BlockKind::Verify(_) => {
766 i += 1;
767 }
768 BlockKind::Fn(name) => {
769 out.push(block.clone());
770 i += 1;
771 while i < blocks_vec.len() && matches!(blocks_vec[i].kind, BlockKind::FnStub(_)) {
776 out.push(blocks_vec[i].clone());
777 i += 1;
778 }
779 if let Some(indices) = by_fn.remove(&name) {
780 for idx in indices {
781 used[idx] = true;
782 out.push(verify_blocks[idx].clone());
783 }
784 }
785 }
786 BlockKind::FnStub(_) => {
787 out.push(block.clone());
790 i += 1;
791 }
792 BlockKind::Other => {
793 out.push(block.clone());
794 i += 1;
795 }
796 }
797 }
798
799 for (idx, block) in verify_blocks.iter().enumerate() {
800 if !used[idx] {
801 out.push(block.clone());
802 }
803 }
804
805 for (new_pos, block) in out.iter().enumerate() {
809 if let BlockKind::Verify(name) = &block.kind {
810 let key = (name.clone(), block.start_line);
811 if let Some(&orig_pos) = original_positions.get(&key)
812 && orig_pos != new_pos
813 {
814 violations.push(aver::diagnostics::model::FormatViolation {
815 line: block.start_line,
816 col: 1,
817 rule: "verify-misplaced",
818 message: format!(
819 "verify block '{}' should be placed immediately after its function",
820 name
821 ),
822 before: None,
823 after: None,
824 });
825 }
826 }
827 }
828
829 out
830}
831
832fn parse_ast_info_checked(source: &str) -> Result<FormatAstInfo, String> {
833 let mut lexer = Lexer::new(source);
834 let tokens = lexer.tokenize().map_err(|e| e.to_string())?;
835 let mut parser = Parser::new(tokens);
836 let items = parser.parse().map_err(|e| e.to_string())?;
837
838 let mut info = FormatAstInfo::default();
839 for item in items {
840 match item {
841 TopLevel::FnDef(fd) => {
842 let kind = if is_oracle_stub_fn(&fd) {
843 BlockKind::FnStub(fd.name.clone())
844 } else {
845 BlockKind::Fn(fd.name.clone())
846 };
847 info.kind_by_line.insert(fd.line, kind);
848 }
849 TopLevel::Verify(vb) => {
850 info.kind_by_line
851 .insert(vb.line, BlockKind::Verify(vb.fn_name.clone()));
852 }
853 _ => {}
854 }
855 }
856 Ok(info)
857}
858
859fn normalize_source_lines_tracked(
867 source: &str,
868 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
869) -> Vec<String> {
870 let normalized = source.replace("\r\n", "\n").replace('\r', "\n");
871
872 let mut lines = Vec::new();
873 let mut line_offset: Vec<usize> = Vec::new();
878 for (idx, raw) in normalized.split('\n').enumerate() {
879 let trimmed = raw.trim_end_matches([' ', '\t']);
880 if trimmed.len() != raw.len() {
881 violations.push(aver::diagnostics::model::FormatViolation {
882 line: idx + 1,
883 col: trimmed.len() + 1,
884 rule: "trailing-whitespace",
885 message: "trailing whitespace".to_string(),
886 before: None,
887 after: None,
888 });
889 }
890 let (line, violation) = normalize_leading_indent_tracked(trimmed, idx + 1);
891 if let Some(v) = violation {
892 violations.push(v);
893 }
894 lines.push(line);
895 line_offset.push(idx + 1);
896 }
897
898 let lines = normalize_effect_declaration_blocks_tracked(lines, violations, Some(&line_offset));
899 let lines = normalize_function_header_effects_tracked(lines, violations, Some(&line_offset));
900 let lines = normalize_module_intent_blocks_tracked(lines, violations, Some(&line_offset));
901 let lines = normalize_module_effects_blocks_tracked(lines, violations, Some(&line_offset));
902 normalize_inline_decision_fields_tracked(lines, violations, Some(&line_offset))
903}
904
905fn normalize_module_effects_blocks_tracked(
906 lines: Vec<String>,
907 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
908 line_offset: Option<&[usize]>,
909) -> Vec<String> {
910 let mut out = Vec::with_capacity(lines.len());
911 let mut in_module_header = false;
912 let mut i = 0usize;
913
914 while i < lines.len() {
915 let line = &lines[i];
916 let trimmed = line.trim();
917 let indent_len = line.chars().take_while(|c| *c == ' ').count();
918
919 if indent_len == 0 && trimmed.starts_with("module ") {
920 in_module_header = true;
921 out.push(line.clone());
922 i += 1;
923 continue;
924 }
925 if in_module_header && indent_len == 0 && !trimmed.is_empty() && !trimmed.starts_with("//")
926 {
927 in_module_header = false;
928 }
929
930 if !(in_module_header && indent_len > 0 && trimmed.starts_with("effects ")) {
931 out.push(line.clone());
932 i += 1;
933 continue;
934 }
935
936 let indent = " ".repeat(indent_len);
939 let head = trimmed.trim_start_matches("effects").trim_start();
940 if !head.starts_with('[') {
941 out.push(line.clone());
942 i += 1;
943 continue;
944 }
945
946 let mut inner = String::new();
947 let mut consumed = 1usize;
948 let mut found_close = false;
949 let first_open = &head[1..];
950
951 if let Some(before_close) = first_open.strip_suffix(']') {
953 inner.push_str(before_close.trim());
954 found_close = true;
955 } else {
956 inner.push_str(first_open.trim());
958 while i + consumed < lines.len() {
959 let next = &lines[i + consumed];
960 let next_trimmed = next.trim();
961 if let Some(before_close) = next_trimmed.strip_suffix(']') {
962 if !inner.is_empty() && !before_close.trim().is_empty() {
963 inner.push(' ');
964 }
965 inner.push_str(before_close.trim());
966 consumed += 1;
967 found_close = true;
968 break;
969 }
970 if !inner.is_empty() && !next_trimmed.is_empty() {
971 inner.push(' ');
972 }
973 inner.push_str(next_trimmed);
974 consumed += 1;
975 }
976 }
977
978 if !found_close {
979 out.push(line.clone());
980 i += 1;
981 continue;
982 }
983
984 let effects: Vec<String> = if inner.trim().is_empty() {
985 vec![]
986 } else {
987 inner
988 .split(',')
989 .map(str::trim)
990 .filter(|part| !part.is_empty())
991 .map(ToString::to_string)
992 .collect()
993 };
994
995 let original_block: Vec<String> = lines[i..i + consumed].to_vec();
996 let rewritten_block = format_module_effects_declaration(&indent, &effects);
997 if original_block != rewritten_block {
998 let source_line = line_offset
999 .and_then(|off| off.get(i))
1000 .copied()
1001 .unwrap_or(i + 1);
1002 let rule = {
1003 let mut sorted = effects.clone();
1004 sorted.sort();
1005 if effects != sorted {
1006 "module-effects-unsorted"
1007 } else {
1008 "module-effects-reshape"
1009 }
1010 };
1011 let message = match rule {
1012 "module-effects-unsorted" => {
1013 "module effect list out of order; formatter sorts alphabetically".to_string()
1014 }
1015 _ => "module effect declaration reshaped to canonical form".to_string(),
1016 };
1017 violations.push(aver::diagnostics::model::FormatViolation {
1018 line: source_line,
1019 col: 1,
1020 rule,
1021 message,
1022 before: Some(original_block.join(" | ")),
1023 after: Some(rewritten_block.join(" | ")),
1024 });
1025 }
1026 out.extend(rewritten_block);
1027 i += consumed;
1028 }
1029
1030 out
1031}
1032
1033fn normalize_module_intent_blocks_tracked(
1034 lines: Vec<String>,
1035 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
1036 line_offset: Option<&[usize]>,
1037) -> Vec<String> {
1038 let before = lines.clone();
1039 let after = normalize_module_intent_blocks_impl(lines);
1040 if before != after {
1041 let diff_idx = before
1043 .iter()
1044 .zip(&after)
1045 .position(|(a, b)| a != b)
1046 .unwrap_or(0);
1047 let source_line = line_offset
1048 .and_then(|off| off.get(diff_idx))
1049 .copied()
1050 .unwrap_or(diff_idx + 1);
1051 violations.push(aver::diagnostics::model::FormatViolation {
1052 line: source_line,
1053 col: 1,
1054 rule: "module-intent-reshape",
1055 message: "module intent block reshaped to canonical multiline form".to_string(),
1056 before: None,
1057 after: None,
1058 });
1059 }
1060 after
1061}
1062
1063fn normalize_module_intent_blocks_impl(lines: Vec<String>) -> Vec<String> {
1064 let mut out = Vec::with_capacity(lines.len());
1065 let mut in_module_header = false;
1066 let mut i = 0usize;
1067
1068 while i < lines.len() {
1069 let line = &lines[i];
1070 let trimmed = line.trim();
1071 let indent = line.chars().take_while(|c| *c == ' ').count();
1072
1073 if indent == 0 && trimmed.starts_with("module ") {
1074 in_module_header = true;
1075 out.push(line.clone());
1076 i += 1;
1077 continue;
1078 }
1079
1080 if in_module_header && indent == 0 && !trimmed.is_empty() && !trimmed.starts_with("//") {
1081 in_module_header = false;
1082 }
1083
1084 if in_module_header && indent > 0 {
1085 let head = &line[indent..];
1086 if let Some(rhs) = head.strip_prefix("intent =") {
1087 let rhs_trimmed = rhs.trim_start();
1088 if rhs_trimmed.starts_with('"') {
1089 let mut parts = vec![rhs_trimmed.to_string()];
1090 let mut consumed = 1usize;
1091
1092 while i + consumed < lines.len() {
1093 let next = &lines[i + consumed];
1094 let next_indent = next.chars().take_while(|c| *c == ' ').count();
1095 let next_trimmed = next.trim();
1096
1097 if next_indent <= indent || next_trimmed.is_empty() {
1098 break;
1099 }
1100 if !next_trimmed.starts_with('"') {
1101 break;
1102 }
1103
1104 parts.push(next_trimmed.to_string());
1105 consumed += 1;
1106 }
1107
1108 if parts.len() > 1 {
1109 out.push(format!("{}intent =", " ".repeat(indent)));
1110 for part in parts {
1111 out.push(format!("{}{}", " ".repeat(indent + 4), part));
1112 }
1113 i += consumed;
1114 continue;
1115 }
1116 }
1117 }
1118 }
1119
1120 out.push(line.clone());
1121 i += 1;
1122 }
1123
1124 out
1125}
1126
1127fn normalize_internal_blank_runs_tracked(
1131 text: &str,
1132 block_start_line: usize,
1133 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
1134) -> String {
1135 let mut out = Vec::new();
1136 let mut blank_run = 0usize;
1137 let mut run_start_idx: Option<usize> = None;
1138 for (rel_idx, raw) in text.split('\n').enumerate() {
1139 if raw.is_empty() {
1140 if blank_run == 0 {
1141 run_start_idx = Some(rel_idx);
1142 }
1143 blank_run += 1;
1144 if blank_run <= 2 {
1145 out.push(String::new());
1146 }
1147 } else {
1148 if blank_run > 2
1149 && let Some(start) = run_start_idx
1150 {
1151 let line = block_start_line.saturating_add(start).max(1);
1152 violations.push(aver::diagnostics::model::FormatViolation {
1153 line,
1154 col: 1,
1155 rule: "excess-blank",
1156 message: format!(
1157 "{} consecutive blank lines; formatter collapses to 2",
1158 blank_run
1159 ),
1160 before: None,
1161 after: None,
1162 });
1163 }
1164 blank_run = 0;
1165 run_start_idx = None;
1166 out.push(raw.to_string());
1167 }
1168 }
1169 while out.first().is_some_and(|l| l.is_empty()) {
1170 out.remove(0);
1171 }
1172 while out.last().is_some_and(|l| l.is_empty()) {
1173 out.pop();
1174 }
1175 out.join("\n")
1176}
1177
1178const DECISION_FIELDS: [&str; 6] = ["date", "author", "reason", "chosen", "rejected", "impacts"];
1179
1180fn starts_with_decision_field(content: &str) -> bool {
1181 DECISION_FIELDS
1182 .iter()
1183 .any(|field| content.starts_with(&format!("{field} =")))
1184}
1185
1186fn find_next_decision_field_boundary(s: &str) -> Option<usize> {
1187 let mut best: Option<usize> = None;
1188 for field in DECISION_FIELDS {
1189 let needle = format!(" {field} =");
1190 let mut search_from = 0usize;
1191 while let Some(rel) = s[search_from..].find(&needle) {
1192 let idx = search_from + rel;
1193 let spaces_before = s[..idx].chars().rev().take_while(|c| *c == ' ').count();
1196 let total_separator_spaces = spaces_before + 1;
1198 if total_separator_spaces >= 2 {
1199 let field_start = idx + 1;
1200 best = Some(best.map_or(field_start, |cur| cur.min(field_start)));
1201 break;
1202 }
1203 search_from = idx + 1;
1204 }
1205 }
1206 best
1207}
1208
1209fn split_inline_decision_fields(content: &str) -> Vec<String> {
1210 if !starts_with_decision_field(content) {
1211 return vec![content.to_string()];
1212 }
1213 let mut out = Vec::new();
1214 let mut rest = content.trim_end().to_string();
1215 while let Some(idx) = find_next_decision_field_boundary(&rest) {
1216 let left = rest[..idx].trim_end().to_string();
1217 if left.is_empty() {
1218 break;
1219 }
1220 out.push(left);
1221 rest = rest[idx..].trim_start().to_string();
1222 }
1223 if !rest.is_empty() {
1224 out.push(rest.trim_end().to_string());
1225 }
1226 if out.is_empty() {
1227 vec![content.to_string()]
1228 } else {
1229 out
1230 }
1231}
1232
1233fn normalize_inline_decision_fields_tracked(
1234 lines: Vec<String>,
1235 violations: &mut Vec<aver::diagnostics::model::FormatViolation>,
1236 line_offset: Option<&[usize]>,
1237) -> Vec<String> {
1238 let before = lines.clone();
1239 let after = normalize_inline_decision_fields_impl(lines);
1240 if before != after {
1241 let diff_idx = before
1242 .iter()
1243 .zip(&after)
1244 .position(|(a, b)| a != b)
1245 .unwrap_or(0);
1246 let source_line = line_offset
1247 .and_then(|off| off.get(diff_idx))
1248 .copied()
1249 .unwrap_or(diff_idx + 1);
1250 violations.push(aver::diagnostics::model::FormatViolation {
1251 line: source_line,
1252 col: 1,
1253 rule: "decision-inline",
1254 message: "decision fields should each live on their own line".to_string(),
1255 before: None,
1256 after: None,
1257 });
1258 }
1259 after
1260}
1261
1262fn normalize_inline_decision_fields_impl(lines: Vec<String>) -> Vec<String> {
1263 let mut out = Vec::with_capacity(lines.len());
1264 let mut in_decision = false;
1265
1266 for line in lines {
1267 let trimmed = line.trim();
1268 let indent = line.chars().take_while(|c| *c == ' ').count();
1269
1270 if indent == 0 && trimmed.starts_with("decision ") {
1271 in_decision = true;
1272 out.push(line);
1273 continue;
1274 }
1275
1276 if in_decision && indent == 0 && !trimmed.is_empty() && !trimmed.starts_with("//") {
1277 in_decision = false;
1278 }
1279
1280 if in_decision && trimmed.is_empty() {
1281 continue;
1282 }
1283
1284 if in_decision && indent > 0 {
1285 let content = &line[indent..];
1286 let parts = split_inline_decision_fields(content);
1287 if parts.len() > 1 {
1288 for part in parts {
1289 out.push(format!("{}{}", " ".repeat(indent), part));
1290 }
1291 continue;
1292 }
1293 }
1294
1295 out.push(line);
1296 }
1297
1298 out
1299}
1300
1301pub fn try_format_source(
1308 source: &str,
1309) -> Result<(String, Vec<aver::diagnostics::model::FormatViolation>), String> {
1310 let mut violations: Vec<aver::diagnostics::model::FormatViolation> = Vec::new();
1311
1312 if !source.is_empty() && !source.ends_with('\n') {
1313 let last_line = source.lines().count().max(1);
1314 violations.push(aver::diagnostics::model::FormatViolation {
1315 line: last_line,
1316 col: source.lines().last().map(str::len).unwrap_or(0) + 1,
1317 rule: "missing-final-newline",
1318 message: "file must end with a single newline".to_string(),
1319 before: None,
1320 after: None,
1321 });
1322 }
1323
1324 let lines = normalize_source_lines_tracked(source, &mut violations);
1325 let normalized = lines.join("\n");
1326 let ast_info = parse_ast_info_checked(&normalized)?;
1327
1328 let blocks = split_top_level_blocks(&lines, Some(&ast_info));
1330 let reordered = reorder_verify_blocks_tracked(blocks, &mut violations);
1331
1332 let mut non_empty_blocks = Vec::new();
1334 for block in reordered {
1335 let text =
1336 normalize_internal_blank_runs_tracked(&block.text, block.start_line, &mut violations);
1337 let text = text.trim_matches('\n').to_string();
1338 if !text.is_empty() {
1339 non_empty_blocks.push(text);
1340 }
1341 }
1342
1343 if non_empty_blocks.is_empty() {
1344 return Ok(("\n".to_string(), violations));
1345 }
1346 let mut out = non_empty_blocks.join("\n\n");
1347 out.push('\n');
1348 Ok((out, violations))
1349}
1350
1351#[cfg(test)]
1352pub fn format_source(source: &str) -> String {
1353 match try_format_source(source) {
1354 Ok((formatted, _violations)) => formatted,
1355 Err(err) => panic!("format_source received invalid Aver source: {err}"),
1356 }
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 use super::{format_source, try_format_source};
1362
1363 #[test]
1364 fn normalizes_line_endings_and_trailing_ws() {
1365 let src = "module A\r\n fn x() -> Int \r\n 1\t \r\n";
1366 let got = format_source(src);
1367 assert_eq!(got, "module A\n fn x() -> Int\n 1\n");
1368 }
1369
1370 #[test]
1371 fn converts_leading_tabs_only() {
1372 let src = "\tfn x() -> String\n\t\t\"a\\tb\"\n";
1373 let got = format_source(src);
1374 assert_eq!(got, " fn x() -> String\n \"a\\tb\"\n");
1375 }
1376
1377 #[test]
1378 fn collapses_long_blank_runs() {
1379 let src = "module A\n\n\n\nfn x() -> Int\n 1\n";
1380 let got = format_source(src);
1381 assert_eq!(got, "module A\n\nfn x() -> Int\n 1\n");
1382 }
1383
1384 #[test]
1385 fn keeps_single_final_newline() {
1386 let src = "module A\nfn x() -> Int\n 1\n\n\n";
1387 let got = format_source(src);
1388 assert_eq!(got, "module A\n\nfn x() -> Int\n 1\n");
1389 }
1390
1391 #[test]
1392 fn rejects_removed_eq_expr_syntax() {
1393 let src = "fn x() -> Int\n = 1\n";
1394 let err = try_format_source(src).expect_err("old '= expr' syntax should fail");
1395 assert!(
1396 err.contains("no longer use '= expr'"),
1397 "unexpected error: {}",
1398 err
1399 );
1400 }
1401
1402 #[test]
1403 fn moves_verify_directly_under_function() {
1404 let src = r#"module Demo
1405
1406fn a(x: Int) -> Int
1407 x + 1
1408
1409fn b(x: Int) -> Int
1410 x + 2
1411
1412verify a
1413 a(1) => 2
1414
1415verify b
1416 b(1) => 3
1417"#;
1418 let got = format_source(src);
1419 assert_eq!(
1420 got,
1421 r#"module Demo
1422
1423fn a(x: Int) -> Int
1424 x + 1
1425
1426verify a
1427 a(1) => 2
1428
1429fn b(x: Int) -> Int
1430 x + 2
1431
1432verify b
1433 b(1) => 3
1434"#
1435 );
1436 }
1437
1438 #[test]
1439 fn leaves_orphan_verify_at_end() {
1440 let src = r#"module Demo
1441
1442verify missing
1443 missing(1) => 2
1444"#;
1445 let got = format_source(src);
1446 assert_eq!(
1447 got,
1448 r#"module Demo
1449
1450verify missing
1451 missing(1) => 2
1452"#
1453 );
1454 }
1455
1456 #[test]
1457 fn keeps_inline_module_intent_inline() {
1458 let src = r#"module Demo
1459 intent = "Inline intent."
1460 exposes [x]
1461fn x() -> Int
1462 1
1463"#;
1464 let got = format_source(src);
1465 assert_eq!(
1466 got,
1467 r#"module Demo
1468 intent = "Inline intent."
1469 exposes [x]
1470
1471fn x() -> Int
1472 1
1473"#
1474 );
1475 }
1476
1477 #[test]
1478 fn expands_multiline_module_intent_to_block() {
1479 let src = r#"module Demo
1480 intent = "First line."
1481 "Second line."
1482 exposes [x]
1483fn x() -> Int
1484 1
1485"#;
1486 let got = format_source(src);
1487 assert_eq!(
1488 got,
1489 r#"module Demo
1490 intent =
1491 "First line."
1492 "Second line."
1493 exposes [x]
1494
1495fn x() -> Int
1496 1
1497"#
1498 );
1499 }
1500
1501 #[test]
1502 fn splits_inline_decision_fields_to_separate_lines() {
1503 let src = r#"module Demo
1504 intent = "x"
1505 exposes [main]
1506
1507decision D
1508 date = "2026-03-02"
1509 chosen = "A" rejected = ["B"]
1510 impacts = [main]
1511"#;
1512 let got = format_source(src);
1513 assert_eq!(
1514 got,
1515 r#"module Demo
1516 intent = "x"
1517 exposes [main]
1518
1519decision D
1520 date = "2026-03-02"
1521 chosen = "A"
1522 rejected = ["B"]
1523 impacts = [main]
1524"#
1525 );
1526 }
1527
1528 #[test]
1529 fn keeps_inline_function_description_inline() {
1530 let src = r#"fn add(a: Int, b: Int) -> Int
1531 ? "Adds two numbers."
1532 a + b
1533"#;
1534 let got = format_source(src);
1535 assert_eq!(
1536 got,
1537 r#"fn add(a: Int, b: Int) -> Int
1538 ? "Adds two numbers."
1539 a + b
1540"#
1541 );
1542 }
1543
1544 #[test]
1545 fn keeps_short_effect_lists_inline() {
1546 let src = r#"fn apply(f: Fn(Int) -> Int ! [Console.warn, Console.print], x: Int) -> Int
1547 ! [Http.post, Console.print, Http.get, Console.warn]
1548 f(x)
1549"#;
1550 let got = format_source(src);
1551 assert_eq!(
1552 got,
1553 r#"fn apply(f: Fn(Int) -> Int ! [Console.print, Console.warn], x: Int) -> Int
1554 ! [Console.print, Console.warn, Http.get, Http.post]
1555 f(x)
1556"#
1557 );
1558 }
1559
1560 #[test]
1561 fn keeps_medium_effect_lists_inline_when_they_fit() {
1562 let src = r#"fn run() -> Unit
1563 ! [Args, Console, Disk, Http, Random, Tcp, Terminal, Time]
1564 Unit
1565"#;
1566 let got = format_source(src);
1567 assert_eq!(
1568 got,
1569 r#"fn run() -> Unit
1570 ! [Args, Console, Disk, Http, Random, Tcp, Terminal, Time]
1571 Unit
1572"#
1573 );
1574 }
1575
1576 #[test]
1577 fn expands_long_effect_lists_to_multiline_alphabetical_groups() {
1578 let src = r#"fn main() -> Unit
1579 ! [Args.get, Console.print, Console.warn, Time.now, Disk.makeDir, Disk.exists, Disk.readText, Disk.writeText, Disk.appendText]
1580 Unit
1581"#;
1582 let got = format_source(src);
1583 assert_eq!(
1584 got,
1585 r#"fn main() -> Unit
1586 ! [
1587 Args.get,
1588 Console.print, Console.warn,
1589 Disk.appendText, Disk.exists, Disk.makeDir, Disk.readText, Disk.writeText,
1590 Time.now,
1591 ]
1592 Unit
1593"#
1594 );
1595 }
1596
1597 #[test]
1598 fn sorts_function_type_effects_inline() {
1599 let src = r#"fn useHandler(handler: Fn(Int) -> Result<String, String> ! [Time.now, Args.get, Console.warn, Console.print, Disk.readText], value: Int) -> Unit
1600 handler(value)
1601"#;
1602 let got = format_source(src);
1603 assert_eq!(
1604 got,
1605 r#"fn useHandler(handler: Fn(Int) -> Result<String, String> ! [Args.get, Console.print, Console.warn, Disk.readText, Time.now], value: Int) -> Unit
1606 handler(value)
1607"#
1608 );
1609 }
1610
1611 #[test]
1612 fn keeps_long_function_type_effects_inline() {
1613 let src = r#"fn apply(handler: Fn(Int) -> Int ! [Time.now, Args.get, Console.warn, Console.print, Disk.readText], value: Int) -> Int
1614 handler(value)
1615"#;
1616 let got = format_source(src);
1617 assert_eq!(
1618 got,
1619 r#"fn apply(handler: Fn(Int) -> Int ! [Args.get, Console.print, Console.warn, Disk.readText, Time.now], value: Int) -> Int
1620 handler(value)
1621"#
1622 );
1623 }
1624
1625 #[test]
1626 fn sorts_module_effects_inline() {
1627 let src = "module M\n intent = \"t\"\n effects [Time.now, Console.print]\n";
1628 let got = format_source(src);
1629 assert_eq!(
1630 got,
1631 "module M\n intent = \"t\"\n effects [Console.print, Time.now]\n"
1632 );
1633 }
1634
1635 #[test]
1636 fn keeps_short_module_effects_inline() {
1637 let src = "module M\n intent = \"t\"\n effects [Console.print]\n";
1638 let got = format_source(src);
1639 assert_eq!(got, src);
1640 }
1641
1642 #[test]
1643 fn expands_long_module_effects_to_multiline() {
1644 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";
1645 let got = format_source(src);
1646 assert_eq!(
1647 got,
1648 "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"
1649 );
1650 }
1651
1652 #[test]
1653 fn collapses_short_multiline_module_effects_back_to_inline() {
1654 let src = "module M\n intent = \"t\"\n effects [\n Console.print,\n Time.now,\n ]\n";
1655 let got = format_source(src);
1656 assert_eq!(
1657 got,
1658 "module M\n intent = \"t\"\n effects [Console.print, Time.now]\n"
1659 );
1660 }
1661}