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