Skip to main content

aver/main/
format_cmd.rs

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    // JSON mode implies --check: it's a report of diffs, never writes.
17    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    // Keep original source + formatter violations so JSON/tty modes can
42    // render precise per-rule diagnostics via the canonical factory.
43    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            // verbose=true so violation regions render in tty too —
122            // parity with `--check --json` consumers.
123            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
187/// Normalize a single line's leading indent: expand tabs to 4 spaces,
188/// strip trailing whitespace implicitly by collapsing empty content.
189///
190/// Returns the rewritten line plus an optional violation if the input
191/// carried mixed / tab-based indent. The caller supplies `source_line`
192/// so the violation can point back to the original source.
193fn 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        // Line was only whitespace: not a format violation — formatter
209        // collapses to empty regardless of what the user typed.
210        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
442/// Per-line formatter for function headers.
443///
444/// When `line_offset` is provided, each rewritten line pushes a
445/// `bad-function-header` violation keyed on the original source line.
446/// `line_offset` is a `Vec<usize>` mapping input-line-index → source
447/// line number (1-based) so the factory can point back at the user's
448/// source accurately.
449fn 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    /// Oracle stub fn — first param is `path: BranchPath`, no
585    /// declared effects. These typically sit between a verified fn
586    /// and its `verify ... law` block (the law's `given` clause
587    /// names them); the format reorderer treats them as glue inside
588    /// a fn-and-its-verifies group, not standalone fn definitions
589    /// that would push the verify block out of place.
590    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
607/// Heuristic: is this fn an oracle stub? Stubs start with
608/// `(path: BranchPath, n: Int, ...)` and declare no effects of
609/// their own — they exist so a `verify ... law` block can name
610/// them in a `given` clause. Tagging them lets the format
611/// reorderer keep them in place between the fn under test and its
612/// verify block instead of demanding the verify follow the fn
613/// directly.
614fn 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    // Preserve preamble comments/metadata before first top-level declaration.
687    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    // Remember the original position (0-based index in `blocks`) of
741    // each verify block so we can flag a violation if it ends up moving.
742    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                // Greedy: pull every FnStub block that immediately
771                // follows. They're considered glue between the fn
772                // under test and its verify block, not standalone
773                // fn definitions.
774                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                // Stub not preceded by a verified fn (rare — usually
787                // a top-level helper). Push as-is; treat like Other.
788                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    // Any verify block whose final position (in `out`) differs from its
805    // original position (in `blocks`) is a violation — the formatter
806    // moved it. Key by (name, start_line) to disambiguate duplicates.
807    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
858/// Normalize source lines and accumulate per-rule format violations.
859///
860/// Each violation references the **original** 1-based source line,
861/// tracked through `normalize_leading_indent_tracked`. Rules further
862/// downstream still operate on `Vec<String>` today and remain silent
863/// contributors to the `violations` accumulator — migration is
864/// incremental, one rule at a time.
865fn 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    // Track original source line per position so downstream tracked
873    // passes can keep accurate violation coordinates. Per-line rules
874    // preserve count; reshape rules break this map and fall back to
875    // their own heuristics.
876    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        // Found a module-level `effects [...]`. Span may be inline or
936        // multi-line; collect until the matching `]`.
937        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        // Single-line case: `effects [a, b, c]`.
951        if let Some(before_close) = first_open.strip_suffix(']') {
952            inner.push_str(before_close.trim());
953            found_close = true;
954        } else {
955            // Multi-line: scan subsequent lines until the closing `]`.
956            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        // Find first differing input line and flag it.
1041        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
1126/// Collapse internal blank-line runs to at most 2, strip leading/trailing
1127/// blanks. `block_start_line` is the 1-based source line of the block's
1128/// first line so violations point back at the original source.
1129fn 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            // Require at least two spaces before the next field marker, so
1193            // normal single-space tokens don't split accidentally.
1194            let spaces_before = s[..idx].chars().rev().take_while(|c| *c == ' ').count();
1195            // `needle` starts at one of the separating spaces, so include it.
1196            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
1300/// Format `source` and return the rewritten text plus a list of
1301/// [`FormatViolation`]s — one per rule that fired on a specific
1302/// location. Etap A: violations Vec is allocated but rules don't yet
1303/// populate it; callers must not claim precise line ranges.
1304/// Subsequent commits migrate each `normalize_*` rule to push to this
1305/// vec as they rewrite.
1306pub 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    // 3) Split into top-level blocks and co-locate verify blocks under their functions.
1328    let blocks = split_top_level_blocks(&lines, Some(&ast_info));
1329    let reordered = reorder_verify_blocks_tracked(blocks, &mut violations);
1330
1331    // 4) Rejoin with one blank line between top-level blocks.
1332    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}