1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process;
5
6use aver::ast::TopLevel;
7use aver::lexer::Lexer;
8use aver::parser::Parser;
9use aver::types::{Type, parse_type_str_strict};
10use colored::Colorize;
11
12#[allow(dead_code)]
13pub(super) fn cmd_format(path: &str, check: bool) {
14 let root = Path::new(path);
15 let mut files = Vec::new();
16 if let Err(e) = collect_av_files(root, &mut files) {
17 eprintln!("{}", e.red());
18 process::exit(1);
19 }
20 files.sort();
21
22 if files.is_empty() {
23 eprintln!(
24 "{}",
25 format!("No .av files found under '{}'", root.display()).red()
26 );
27 process::exit(1);
28 }
29
30 let mut changed = Vec::new();
31 for file in &files {
32 let src = match fs::read_to_string(file) {
33 Ok(s) => s,
34 Err(e) => {
35 eprintln!(
36 "{}",
37 format!("Cannot read '{}': {}", file.display(), e).red()
38 );
39 process::exit(1);
40 }
41 };
42 let formatted = match try_format_source(&src) {
43 Ok(s) => s,
44 Err(e) => {
45 eprintln!(
46 "{}",
47 format!("Cannot format '{}': {}", file.display(), e).red()
48 );
49 process::exit(1);
50 }
51 };
52 if formatted != src {
53 changed.push(file.clone());
54 if !check && let Err(e) = fs::write(file, formatted) {
55 eprintln!(
56 "{}",
57 format!("Cannot write '{}': {}", file.display(), e).red()
58 );
59 process::exit(1);
60 }
61 }
62 }
63
64 if check {
65 if changed.is_empty() {
66 println!("{}", "Format check passed".green());
67 return;
68 }
69 println!("{}", "Format check failed".red());
70 println!("Files that need formatting:");
71 for f in &changed {
72 println!(" {}", f.display());
73 }
74 process::exit(1);
75 }
76
77 if changed.is_empty() {
78 println!("{}", "Already formatted".green());
79 } else {
80 for f in &changed {
81 println!("{} {}", "formatted".green(), f.display());
82 }
83 println!("{}", format!("Formatted {} file(s)", changed.len()).green());
84 }
85}
86
87#[allow(dead_code)]
88fn collect_av_files(path: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
89 if !path.exists() {
90 return Err(format!("Path '{}' does not exist", path.display()));
91 }
92
93 if path.is_file() {
94 if is_av_file(path) {
95 out.push(path.to_path_buf());
96 return Ok(());
97 }
98 return Err(format!("'{}' is not an .av file", path.display()));
99 }
100
101 let entries = fs::read_dir(path)
102 .map_err(|e| format!("Cannot read directory '{}': {}", path.display(), e))?;
103 for entry_res in entries {
104 let entry = entry_res
105 .map_err(|e| format!("Cannot read directory entry in '{}': {}", path.display(), e))?;
106 let p = entry.path();
107 if p.is_dir() {
108 collect_av_files(&p, out)?;
109 } else if is_av_file(&p) {
110 out.push(p);
111 }
112 }
113 Ok(())
114}
115
116#[allow(dead_code)]
117fn is_av_file(path: &Path) -> bool {
118 path.extension().and_then(|e| e.to_str()) == Some("av")
119}
120
121fn normalize_leading_indent(line: &str) -> String {
122 let mut end = 0usize;
123 for (idx, ch) in line.char_indices() {
124 if ch == ' ' || ch == '\t' {
125 end = idx + ch.len_utf8();
126 } else {
127 break;
128 }
129 }
130
131 let (indent, rest) = line.split_at(end);
132 if rest.is_empty() {
133 return String::new();
134 }
135
136 let mut out = String::new();
137 for ch in indent.chars() {
138 if ch == '\t' {
139 out.push_str(" ");
140 } else {
141 out.push(ch);
142 }
143 }
144 out.push_str(rest);
145 out
146}
147
148fn effect_namespace(effect: &str) -> &str {
149 match effect.split_once('.') {
150 Some((namespace, _)) => namespace,
151 None => effect,
152 }
153}
154
155fn sorted_effects(effects: &[String]) -> Vec<String> {
156 let mut sorted = effects.to_vec();
157 sorted.sort();
158 sorted
159}
160
161fn format_block_effect_declaration(indent: &str, effects: &[String]) -> Vec<String> {
162 let effects = sorted_effects(effects);
163 if effects.len() <= 4 {
164 return vec![format!("{}! [{}]", indent, effects.join(", "))];
165 }
166
167 let mut out = vec![format!("{}! [", indent)];
168 let mut start = 0usize;
169 while start < effects.len() {
170 let namespace = effect_namespace(&effects[start]);
171 let mut end = start + 1;
172 while end < effects.len() && effect_namespace(&effects[end]) == namespace {
173 end += 1;
174 }
175 out.push(format!("{} {},", indent, effects[start..end].join(", ")));
176 start = end;
177 }
178 out.push(format!("{}]", indent));
179 out
180}
181
182fn split_top_level(src: &str, delimiter: char) -> Option<Vec<String>> {
183 let mut parts = Vec::new();
184 let mut start = 0usize;
185 let mut paren_depth = 0usize;
186 let mut bracket_depth = 0usize;
187 let mut angle_depth = 0usize;
188 let mut prev = None;
189
190 for (idx, ch) in src.char_indices() {
191 match ch {
192 '(' => paren_depth += 1,
193 ')' => paren_depth = paren_depth.checked_sub(1)?,
194 '[' => bracket_depth += 1,
195 ']' => bracket_depth = bracket_depth.checked_sub(1)?,
196 '<' => angle_depth += 1,
197 '>' if prev != Some('-') && angle_depth > 0 => angle_depth -= 1,
198 _ => {}
199 }
200
201 if ch == delimiter && paren_depth == 0 && bracket_depth == 0 && angle_depth == 0 {
202 parts.push(src[start..idx].to_string());
203 start = idx + ch.len_utf8();
204 }
205 prev = Some(ch);
206 }
207
208 if paren_depth != 0 || bracket_depth != 0 || angle_depth != 0 {
209 return None;
210 }
211
212 parts.push(src[start..].to_string());
213 Some(parts)
214}
215
216fn find_matching_paren(src: &str, open_idx: usize) -> Option<usize> {
217 let mut depth = 0usize;
218 for (idx, ch) in src.char_indices().skip_while(|(idx, _)| *idx < open_idx) {
219 match ch {
220 '(' => depth += 1,
221 ')' => {
222 depth = depth.checked_sub(1)?;
223 if depth == 0 {
224 return Some(idx);
225 }
226 }
227 _ => {}
228 }
229 }
230 None
231}
232
233fn format_type_for_source(ty: &Type) -> String {
234 match ty {
235 Type::Int => "Int".to_string(),
236 Type::Float => "Float".to_string(),
237 Type::Str => "String".to_string(),
238 Type::Bool => "Bool".to_string(),
239 Type::Unit => "Unit".to_string(),
240 Type::Result(ok, err) => format!(
241 "Result<{}, {}>",
242 format_type_for_source(ok),
243 format_type_for_source(err)
244 ),
245 Type::Option(inner) => format!("Option<{}>", format_type_for_source(inner)),
246 Type::List(inner) => format!("List<{}>", format_type_for_source(inner)),
247 Type::Vector(inner) => format!("Vector<{}>", format_type_for_source(inner)),
248 Type::Tuple(items) => format!(
249 "({})",
250 items
251 .iter()
252 .map(format_type_for_source)
253 .collect::<Vec<_>>()
254 .join(", ")
255 ),
256 Type::Map(key, value) => format!(
257 "Map<{}, {}>",
258 format_type_for_source(key),
259 format_type_for_source(value)
260 ),
261 Type::Fn(params, ret, effects) => {
262 let params = params
263 .iter()
264 .map(format_type_for_source)
265 .collect::<Vec<_>>()
266 .join(", ");
267 let ret = format_type_for_source(ret);
268 let effects = sorted_effects(effects);
269 if effects.is_empty() {
270 format!("Fn({params}) -> {ret}")
271 } else {
272 format!("Fn({params}) -> {ret} ! [{}]", effects.join(", "))
273 }
274 }
275 Type::Unknown => "Unknown".to_string(),
276 Type::Named(name) => name.clone(),
277 }
278}
279
280fn normalize_type_annotation(type_src: &str) -> String {
281 let trimmed = type_src.trim();
282 match parse_type_str_strict(trimmed) {
283 Ok(ty) => format_type_for_source(&ty),
284 Err(_) => trimmed.to_string(),
285 }
286}
287
288fn normalize_function_header_effects_line(line: &str) -> String {
289 let indent_len = line.chars().take_while(|c| *c == ' ').count();
290 let indent = " ".repeat(indent_len);
291 let trimmed = line.trim();
292 if !trimmed.starts_with("fn ") {
293 return line.to_string();
294 }
295
296 let open_idx = match trimmed.find('(') {
297 Some(idx) => idx,
298 None => return line.to_string(),
299 };
300 let close_idx = match find_matching_paren(trimmed, open_idx) {
301 Some(idx) => idx,
302 None => return line.to_string(),
303 };
304
305 let params_src = &trimmed[open_idx + 1..close_idx];
306 let params = match split_top_level(params_src, ',') {
307 Some(parts) => parts,
308 None => return line.to_string(),
309 };
310 let formatted_params = params
311 .into_iter()
312 .filter(|part| !part.trim().is_empty())
313 .map(|param| {
314 let (name, ty) = match param.split_once(':') {
315 Some(parts) => parts,
316 None => return param.trim().to_string(),
317 };
318 format!("{}: {}", name.trim(), normalize_type_annotation(ty))
319 })
320 .collect::<Vec<_>>()
321 .join(", ");
322
323 let mut formatted = format!(
324 "{}{}{})",
325 indent,
326 &trimmed[..open_idx + 1],
327 formatted_params
328 );
329 let remainder = trimmed[close_idx + 1..].trim();
330 if let Some(return_type) = remainder.strip_prefix("->") {
331 formatted.push_str(" -> ");
332 formatted.push_str(&normalize_type_annotation(return_type));
333 } else if !remainder.is_empty() {
334 formatted.push(' ');
335 formatted.push_str(remainder);
336 }
337
338 formatted
339}
340
341fn normalize_function_header_effects(lines: Vec<String>) -> Vec<String> {
342 lines
343 .into_iter()
344 .map(|line| normalize_function_header_effects_line(&line))
345 .collect()
346}
347
348fn normalize_effect_declaration_blocks(lines: Vec<String>) -> Vec<String> {
349 let mut out = Vec::with_capacity(lines.len());
350 let mut i = 0usize;
351
352 while i < lines.len() {
353 let line = &lines[i];
354 let trimmed = line.trim();
355 if !trimmed.starts_with("! [") {
356 out.push(line.clone());
357 i += 1;
358 continue;
359 }
360
361 let indent_len = line.chars().take_while(|c| *c == ' ').count();
362 let indent = " ".repeat(indent_len);
363 let mut inner = String::new();
364 let mut consumed = 0usize;
365 let mut found_close = false;
366
367 while i + consumed < lines.len() {
368 let current = &lines[i + consumed];
369 let current_trimmed = current.trim();
370 let segment = if consumed == 0 {
371 current_trimmed.trim_start_matches("! [")
372 } else {
373 current_trimmed
374 };
375
376 if let Some(before_close) = segment.strip_suffix(']') {
377 if !inner.is_empty() && !before_close.trim().is_empty() {
378 inner.push(' ');
379 }
380 inner.push_str(before_close.trim());
381 found_close = true;
382 consumed += 1;
383 break;
384 }
385
386 if !inner.is_empty() && !segment.trim().is_empty() {
387 inner.push(' ');
388 }
389 inner.push_str(segment.trim());
390 consumed += 1;
391 }
392
393 if !found_close {
394 out.push(line.clone());
395 i += 1;
396 continue;
397 }
398
399 let effects: Vec<String> = if inner.trim().is_empty() {
400 vec![]
401 } else {
402 inner
403 .split(',')
404 .map(str::trim)
405 .filter(|part| !part.is_empty())
406 .map(ToString::to_string)
407 .collect()
408 };
409
410 out.extend(format_block_effect_declaration(&indent, &effects));
411 i += consumed;
412 }
413
414 out
415}
416
417#[derive(Clone, Debug, PartialEq, Eq)]
418enum BlockKind {
419 Fn(String),
420 Verify(String),
421 Other,
422}
423
424#[derive(Clone, Debug, PartialEq, Eq)]
425struct TopBlock {
426 text: String,
427 kind: BlockKind,
428 start_line: usize,
429}
430
431#[derive(Default)]
432struct FormatAstInfo {
433 kind_by_line: HashMap<usize, BlockKind>,
434}
435
436fn classify_block(header_line: &str) -> BlockKind {
437 let trimmed = header_line.trim();
438 if let Some(rest) = trimmed.strip_prefix("fn ") {
439 let name = rest
440 .split(['(', ' ', '\t'])
441 .next()
442 .unwrap_or_default()
443 .to_string();
444 if !name.is_empty() {
445 return BlockKind::Fn(name);
446 }
447 }
448 if let Some(rest) = trimmed.strip_prefix("verify ") {
449 let name = rest
450 .split([' ', '\t'])
451 .next()
452 .unwrap_or_default()
453 .to_string();
454 if !name.is_empty() {
455 return BlockKind::Verify(name);
456 }
457 }
458 BlockKind::Other
459}
460
461fn is_top_level_start(line: &str) -> bool {
462 if line.is_empty() {
463 return false;
464 }
465 if line.starts_with(' ') || line.starts_with('\t') {
466 return false;
467 }
468 !line.trim_start().starts_with("//")
469}
470
471fn split_top_level_blocks(lines: &[String], ast_info: Option<&FormatAstInfo>) -> Vec<TopBlock> {
472 if lines.is_empty() {
473 return Vec::new();
474 }
475
476 let starts: Vec<usize> = lines
477 .iter()
478 .enumerate()
479 .filter_map(|(idx, line)| is_top_level_start(line).then_some(idx))
480 .collect();
481
482 if starts.is_empty() {
483 let text = lines.join("\n").trim_end_matches('\n').to_string();
484 if text.is_empty() {
485 return Vec::new();
486 }
487 return vec![TopBlock {
488 text,
489 kind: BlockKind::Other,
490 start_line: 1,
491 }];
492 }
493
494 let mut blocks = Vec::new();
495
496 let first = starts[0];
498 if first > 0 {
499 let mut pre = lines[..first].to_vec();
500 while pre.last().is_some_and(|l| l.is_empty()) {
501 pre.pop();
502 }
503 if !pre.is_empty() {
504 blocks.push(TopBlock {
505 text: pre.join("\n"),
506 kind: BlockKind::Other,
507 start_line: 1,
508 });
509 }
510 }
511
512 for (i, start) in starts.iter().enumerate() {
513 let end = starts.get(i + 1).copied().unwrap_or(lines.len());
514 let mut segment = lines[*start..end].to_vec();
515 while segment.last().is_some_and(|l| l.is_empty()) {
516 segment.pop();
517 }
518 if segment.is_empty() {
519 continue;
520 }
521 let header = segment[0].clone();
522 let start_line = *start + 1;
523 let kind = ast_info
524 .and_then(|info| info.kind_by_line.get(&start_line).cloned())
525 .unwrap_or_else(|| classify_block(&header));
526 blocks.push(TopBlock {
527 text: segment.join("\n"),
528 kind,
529 start_line,
530 });
531 }
532
533 blocks
534}
535
536fn reorder_verify_blocks(blocks: Vec<TopBlock>) -> Vec<TopBlock> {
537 let verify_blocks: Vec<TopBlock> = blocks
538 .iter()
539 .filter(|b| matches!(b.kind, BlockKind::Verify(_)))
540 .cloned()
541 .collect();
542
543 if verify_blocks.is_empty() {
544 return blocks;
545 }
546
547 let mut by_fn: HashMap<String, Vec<usize>> = HashMap::new();
548 for (idx, block) in verify_blocks.iter().enumerate() {
549 if let BlockKind::Verify(name) = &block.kind {
550 by_fn.entry(name.clone()).or_default().push(idx);
551 }
552 }
553
554 let mut used = vec![false; verify_blocks.len()];
555 let mut out = Vec::new();
556
557 for block in blocks {
558 match block.kind.clone() {
559 BlockKind::Verify(_) => {}
560 BlockKind::Fn(name) => {
561 out.push(block);
562 if let Some(indices) = by_fn.remove(&name) {
563 for idx in indices {
564 used[idx] = true;
565 out.push(verify_blocks[idx].clone());
566 }
567 }
568 }
569 BlockKind::Other => out.push(block),
570 }
571 }
572
573 for (idx, block) in verify_blocks.iter().enumerate() {
574 if !used[idx] {
575 out.push(block.clone());
576 }
577 }
578
579 out
580}
581
582fn parse_ast_info_checked(source: &str) -> Result<FormatAstInfo, String> {
583 let mut lexer = Lexer::new(source);
584 let tokens = lexer.tokenize().map_err(|e| e.to_string())?;
585 let mut parser = Parser::new(tokens);
586 let items = parser.parse().map_err(|e| e.to_string())?;
587
588 let mut info = FormatAstInfo::default();
589 for item in items {
590 match item {
591 TopLevel::FnDef(fd) => {
592 info.kind_by_line
593 .insert(fd.line, BlockKind::Fn(fd.name.clone()));
594 }
595 TopLevel::Verify(vb) => {
596 info.kind_by_line
597 .insert(vb.line, BlockKind::Verify(vb.fn_name.clone()));
598 }
599 _ => {}
600 }
601 }
602 Ok(info)
603}
604
605fn normalize_source_lines(source: &str) -> Vec<String> {
606 let normalized = source.replace("\r\n", "\n").replace('\r', "\n");
607
608 let mut lines = Vec::new();
609 for raw in normalized.split('\n') {
610 let trimmed = raw.trim_end_matches([' ', '\t']);
611 let line = normalize_leading_indent(trimmed);
612 lines.push(line);
613 }
614
615 let lines = normalize_effect_declaration_blocks(lines);
616 let lines = normalize_function_header_effects(lines);
617 let lines = normalize_module_intent_blocks(lines);
618 normalize_inline_decision_fields(lines)
619}
620
621fn normalize_module_intent_blocks(lines: Vec<String>) -> Vec<String> {
622 let mut out = Vec::with_capacity(lines.len());
623 let mut in_module_header = false;
624 let mut i = 0usize;
625
626 while i < lines.len() {
627 let line = &lines[i];
628 let trimmed = line.trim();
629 let indent = line.chars().take_while(|c| *c == ' ').count();
630
631 if indent == 0 && trimmed.starts_with("module ") {
632 in_module_header = true;
633 out.push(line.clone());
634 i += 1;
635 continue;
636 }
637
638 if in_module_header && indent == 0 && !trimmed.is_empty() && !trimmed.starts_with("//") {
639 in_module_header = false;
640 }
641
642 if in_module_header && indent > 0 {
643 let head = &line[indent..];
644 if let Some(rhs) = head.strip_prefix("intent =") {
645 let rhs_trimmed = rhs.trim_start();
646 if rhs_trimmed.starts_with('"') {
647 let mut parts = vec![rhs_trimmed.to_string()];
648 let mut consumed = 1usize;
649
650 while i + consumed < lines.len() {
651 let next = &lines[i + consumed];
652 let next_indent = next.chars().take_while(|c| *c == ' ').count();
653 let next_trimmed = next.trim();
654
655 if next_indent <= indent || next_trimmed.is_empty() {
656 break;
657 }
658 if !next_trimmed.starts_with('"') {
659 break;
660 }
661
662 parts.push(next_trimmed.to_string());
663 consumed += 1;
664 }
665
666 if parts.len() > 1 {
667 out.push(format!("{}intent =", " ".repeat(indent)));
668 for part in parts {
669 out.push(format!("{}{}", " ".repeat(indent + 4), part));
670 }
671 i += consumed;
672 continue;
673 }
674 }
675 }
676 }
677
678 out.push(line.clone());
679 i += 1;
680 }
681
682 out
683}
684
685fn normalize_internal_blank_runs(text: &str) -> String {
686 let mut out = Vec::new();
687 let mut blank_run = 0usize;
688 for raw in text.split('\n') {
689 if raw.is_empty() {
690 blank_run += 1;
691 if blank_run <= 2 {
692 out.push(String::new());
693 }
694 } else {
695 blank_run = 0;
696 out.push(raw.to_string());
697 }
698 }
699 while out.first().is_some_and(|l| l.is_empty()) {
700 out.remove(0);
701 }
702 while out.last().is_some_and(|l| l.is_empty()) {
703 out.pop();
704 }
705 out.join("\n")
706}
707
708const DECISION_FIELDS: [&str; 6] = ["date", "author", "reason", "chosen", "rejected", "impacts"];
709
710fn starts_with_decision_field(content: &str) -> bool {
711 DECISION_FIELDS
712 .iter()
713 .any(|field| content.starts_with(&format!("{field} =")))
714}
715
716fn find_next_decision_field_boundary(s: &str) -> Option<usize> {
717 let mut best: Option<usize> = None;
718 for field in DECISION_FIELDS {
719 let needle = format!(" {field} =");
720 let mut search_from = 0usize;
721 while let Some(rel) = s[search_from..].find(&needle) {
722 let idx = search_from + rel;
723 let spaces_before = s[..idx].chars().rev().take_while(|c| *c == ' ').count();
726 let total_separator_spaces = spaces_before + 1;
728 if total_separator_spaces >= 2 {
729 let field_start = idx + 1;
730 best = Some(best.map_or(field_start, |cur| cur.min(field_start)));
731 break;
732 }
733 search_from = idx + 1;
734 }
735 }
736 best
737}
738
739fn split_inline_decision_fields(content: &str) -> Vec<String> {
740 if !starts_with_decision_field(content) {
741 return vec![content.to_string()];
742 }
743 let mut out = Vec::new();
744 let mut rest = content.trim_end().to_string();
745 while let Some(idx) = find_next_decision_field_boundary(&rest) {
746 let left = rest[..idx].trim_end().to_string();
747 if left.is_empty() {
748 break;
749 }
750 out.push(left);
751 rest = rest[idx..].trim_start().to_string();
752 }
753 if !rest.is_empty() {
754 out.push(rest.trim_end().to_string());
755 }
756 if out.is_empty() {
757 vec![content.to_string()]
758 } else {
759 out
760 }
761}
762
763fn normalize_inline_decision_fields(lines: Vec<String>) -> Vec<String> {
764 let mut out = Vec::with_capacity(lines.len());
765 let mut in_decision = false;
766
767 for line in lines {
768 let trimmed = line.trim();
769 let indent = line.chars().take_while(|c| *c == ' ').count();
770
771 if indent == 0 && trimmed.starts_with("decision ") {
772 in_decision = true;
773 out.push(line);
774 continue;
775 }
776
777 if in_decision && indent == 0 && !trimmed.is_empty() && !trimmed.starts_with("//") {
778 in_decision = false;
779 }
780
781 if in_decision && trimmed.is_empty() {
782 continue;
783 }
784
785 if in_decision && indent > 0 {
786 let content = &line[indent..];
787 let parts = split_inline_decision_fields(content);
788 if parts.len() > 1 {
789 for part in parts {
790 out.push(format!("{}{}", " ".repeat(indent), part));
791 }
792 continue;
793 }
794 }
795
796 out.push(line);
797 }
798
799 out
800}
801
802pub fn try_format_source(source: &str) -> Result<String, String> {
803 let lines = normalize_source_lines(source);
804 let normalized = lines.join("\n");
805 let ast_info = parse_ast_info_checked(&normalized)?;
806
807 let blocks = split_top_level_blocks(&lines, Some(&ast_info));
809 let reordered = reorder_verify_blocks(blocks);
810
811 let mut non_empty_blocks = Vec::new();
813 for block in reordered {
814 let text = normalize_internal_blank_runs(&block.text);
815 let text = text.trim_matches('\n').to_string();
816 if !text.is_empty() {
817 non_empty_blocks.push(text);
818 }
819 }
820
821 if non_empty_blocks.is_empty() {
822 return Ok("\n".to_string());
823 }
824 let mut out = non_empty_blocks.join("\n\n");
825 out.push('\n');
826 Ok(out)
827}
828
829#[cfg(test)]
830pub fn format_source(source: &str) -> String {
831 match try_format_source(source) {
832 Ok(formatted) => formatted,
833 Err(err) => panic!("format_source received invalid Aver source: {err}"),
834 }
835}
836
837#[cfg(test)]
838mod tests {
839 use super::{format_source, try_format_source};
840
841 #[test]
842 fn normalizes_line_endings_and_trailing_ws() {
843 let src = "module A\r\n fn x() -> Int \r\n 1\t \r\n";
844 let got = format_source(src);
845 assert_eq!(got, "module A\n fn x() -> Int\n 1\n");
846 }
847
848 #[test]
849 fn converts_leading_tabs_only() {
850 let src = "\tfn x() -> String\n\t\t\"a\\tb\"\n";
851 let got = format_source(src);
852 assert_eq!(got, " fn x() -> String\n \"a\\tb\"\n");
853 }
854
855 #[test]
856 fn collapses_long_blank_runs() {
857 let src = "module A\n\n\n\nfn x() -> Int\n 1\n";
858 let got = format_source(src);
859 assert_eq!(got, "module A\n\nfn x() -> Int\n 1\n");
860 }
861
862 #[test]
863 fn keeps_single_final_newline() {
864 let src = "module A\nfn x() -> Int\n 1\n\n\n";
865 let got = format_source(src);
866 assert_eq!(got, "module A\n\nfn x() -> Int\n 1\n");
867 }
868
869 #[test]
870 fn rejects_removed_eq_expr_syntax() {
871 let src = "fn x() -> Int\n = 1\n";
872 let err = try_format_source(src).expect_err("old '= expr' syntax should fail");
873 assert!(
874 err.contains("no longer use '= expr'"),
875 "unexpected error: {}",
876 err
877 );
878 }
879
880 #[test]
881 fn moves_verify_directly_under_function() {
882 let src = r#"module Demo
883
884fn a(x: Int) -> Int
885 x + 1
886
887fn b(x: Int) -> Int
888 x + 2
889
890verify a
891 a(1) => 2
892
893verify b
894 b(1) => 3
895"#;
896 let got = format_source(src);
897 assert_eq!(
898 got,
899 r#"module Demo
900
901fn a(x: Int) -> Int
902 x + 1
903
904verify a
905 a(1) => 2
906
907fn b(x: Int) -> Int
908 x + 2
909
910verify b
911 b(1) => 3
912"#
913 );
914 }
915
916 #[test]
917 fn leaves_orphan_verify_at_end() {
918 let src = r#"module Demo
919
920verify missing
921 missing(1) => 2
922"#;
923 let got = format_source(src);
924 assert_eq!(
925 got,
926 r#"module Demo
927
928verify missing
929 missing(1) => 2
930"#
931 );
932 }
933
934 #[test]
935 fn keeps_inline_module_intent_inline() {
936 let src = r#"module Demo
937 intent = "Inline intent."
938 exposes [x]
939fn x() -> Int
940 1
941"#;
942 let got = format_source(src);
943 assert_eq!(
944 got,
945 r#"module Demo
946 intent = "Inline intent."
947 exposes [x]
948
949fn x() -> Int
950 1
951"#
952 );
953 }
954
955 #[test]
956 fn expands_multiline_module_intent_to_block() {
957 let src = r#"module Demo
958 intent = "First line."
959 "Second line."
960 exposes [x]
961fn x() -> Int
962 1
963"#;
964 let got = format_source(src);
965 assert_eq!(
966 got,
967 r#"module Demo
968 intent =
969 "First line."
970 "Second line."
971 exposes [x]
972
973fn x() -> Int
974 1
975"#
976 );
977 }
978
979 #[test]
980 fn splits_inline_decision_fields_to_separate_lines() {
981 let src = r#"module Demo
982 intent = "x"
983 exposes [main]
984
985decision D
986 date = "2026-03-02"
987 chosen = "A" rejected = ["B"]
988 impacts = [main]
989"#;
990 let got = format_source(src);
991 assert_eq!(
992 got,
993 r#"module Demo
994 intent = "x"
995 exposes [main]
996
997decision D
998 date = "2026-03-02"
999 chosen = "A"
1000 rejected = ["B"]
1001 impacts = [main]
1002"#
1003 );
1004 }
1005
1006 #[test]
1007 fn keeps_inline_function_description_inline() {
1008 let src = r#"fn add(a: Int, b: Int) -> Int
1009 ? "Adds two numbers."
1010 a + b
1011"#;
1012 let got = format_source(src);
1013 assert_eq!(
1014 got,
1015 r#"fn add(a: Int, b: Int) -> Int
1016 ? "Adds two numbers."
1017 a + b
1018"#
1019 );
1020 }
1021
1022 #[test]
1023 fn keeps_short_effect_lists_inline() {
1024 let src = r#"fn apply(f: Fn(Int) -> Int ! [Console.warn, Console.print], x: Int) -> Int
1025 ! [Http.post, Console.print, Http.get, Console.warn]
1026 f(x)
1027"#;
1028 let got = format_source(src);
1029 assert_eq!(
1030 got,
1031 r#"fn apply(f: Fn(Int) -> Int ! [Console.print, Console.warn], x: Int) -> Int
1032 ! [Console.print, Console.warn, Http.get, Http.post]
1033 f(x)
1034"#
1035 );
1036 }
1037
1038 #[test]
1039 fn expands_long_effect_lists_to_multiline_alphabetical_groups() {
1040 let src = r#"fn main() -> Unit
1041 ! [Args.get, Console.print, Console.warn, Time.now, Disk.makeDir, Disk.exists, Disk.readText, Disk.writeText, Disk.appendText]
1042 Unit
1043"#;
1044 let got = format_source(src);
1045 assert_eq!(
1046 got,
1047 r#"fn main() -> Unit
1048 ! [
1049 Args.get,
1050 Console.print, Console.warn,
1051 Disk.appendText, Disk.exists, Disk.makeDir, Disk.readText, Disk.writeText,
1052 Time.now,
1053 ]
1054 Unit
1055"#
1056 );
1057 }
1058
1059 #[test]
1060 fn sorts_function_type_effects_inline() {
1061 let src = r#"fn useHandler(handler: Fn(Int) -> Result<String, String> ! [Time.now, Args.get, Console.warn, Console.print, Disk.readText], value: Int) -> Unit
1062 handler(value)
1063"#;
1064 let got = format_source(src);
1065 assert_eq!(
1066 got,
1067 r#"fn useHandler(handler: Fn(Int) -> Result<String, String> ! [Args.get, Console.print, Console.warn, Disk.readText, Time.now], value: Int) -> Unit
1068 handler(value)
1069"#
1070 );
1071 }
1072
1073 #[test]
1074 fn keeps_long_function_type_effects_inline() {
1075 let src = r#"fn apply(handler: Fn(Int) -> Int ! [Time.now, Args.get, Console.warn, Console.print, Disk.readText], value: Int) -> Int
1076 handler(value)
1077"#;
1078 let got = format_source(src);
1079 assert_eq!(
1080 got,
1081 r#"fn apply(handler: Fn(Int) -> Int ! [Args.get, Console.print, Console.warn, Disk.readText, Time.now], value: Int) -> Int
1082 handler(value)
1083"#
1084 );
1085 }
1086}