Skip to main content

alef_e2e/
format.rs

1//! Post-generation formatter support for e2e test projects.
2//!
3//! Reads formatter commands from `E2eConfig.format` and runs them for each
4//! language that had files generated. The `{dir}` placeholder in the command
5//! is replaced with the actual output directory.
6
7use crate::config::E2eConfig;
8use alef_core::backend::GeneratedFile;
9use std::collections::HashSet;
10use std::path::Path;
11use tracing::warn;
12
13/// Default per-language formatter commands for e2e directories.
14///
15/// These are run automatically when the language's directory was generated and
16/// no custom override is present in `e2e_config.format`. The `{dir}` placeholder
17/// is replaced with the actual output directory path before execution.
18///
19/// * `rust` — `(cd {dir} && cargo fmt --all && cargo sort .)` formats the
20///   standalone e2e crate. `cargo fmt` normalises `.rs` files; `cargo sort`
21///   normalises `Cargo.toml` dependency-table ordering and feature indentation
22///   so prek's `cargo-sort` hook is a no-op. Without `cargo sort` the hook
23///   reformats feature indentation after the hash is finalised, making
24///   `alef verify` report the Cargo.toml as stale on every run.
25///   `cargo fmt --manifest-path` is *not* a global cargo flag (it's an
26///   unstable cargo-fmt-only flag in nightly), so running cargo fmt from the
27///   e2e crate's own directory is the portable way to format a
28///   non-workspace-member crate.
29/// * `python` — `ruff check --fix {dir} && ruff format {dir}` runs lint
30///   autofixes (unused imports, import sorting, TypeAlias annotations) then
31///   whitespace normalisation so both prek's `ruff check` and `ruff format`
32///   hooks are no-ops after generation.
33/// * `node` — `npx oxfmt {dir}` normalises TypeScript test files so prek's
34///   oxfmt hook is a no-op. Without this, hashes are computed over raw codegen
35///   output and reformatted by prek, causing `alef verify` to report stale files.
36/// * `wasm` — same as `node`; the wasm e2e suite uses the same TypeScript
37///   toolchain and oxfmt produces identical normalisation requirements.
38fn default_formatter(lang: &str) -> Option<&'static str> {
39    match lang {
40        "rust" => Some("(cd {dir} && cargo fmt --all && cargo sort .)"),
41        "python" => Some("ruff check --fix {dir} && ruff format {dir}"),
42        "node" | "wasm" => Some("pnpm dlx oxfmt {dir}"),
43        _ => None,
44    }
45}
46
47/// Run per-language formatters for all languages that had files generated.
48///
49/// For each language present in `files`, picks the command from
50/// `e2e_config.format[lang]` when available, then falls back to
51/// [`default_formatter`] for languages that have a built-in default (rust,
52/// python). The `{dir}` placeholder is replaced with `{output}/{lang}`.
53/// Failures are logged as warnings and do not abort the process.
54pub fn run_formatters(files: &[GeneratedFile], e2e_config: &E2eConfig) {
55    // Collect the set of languages that had files generated by inspecting
56    // file paths. E2e files are written to `{output}/{lang}/...`, so the
57    // first path component after the output prefix is the language name.
58    let output_prefix = Path::new(e2e_config.effective_output());
59    let languages: HashSet<String> = files
60        .iter()
61        .filter_map(|f| {
62            let remainder = f.path.strip_prefix(output_prefix).ok()?;
63            let first = remainder.components().next()?;
64            Some(first.as_os_str().to_string_lossy().into_owned())
65        })
66        .collect();
67
68    for lang in &languages {
69        // User override takes precedence; then built-in default.
70        let cmd_template: &str = if let Some(custom) = e2e_config.format.get(lang.as_str()) {
71            custom.as_str()
72        } else if let Some(builtin) = default_formatter(lang.as_str()) {
73            builtin
74        } else {
75            continue;
76        };
77
78        let dir = format!("{}/{}", e2e_config.effective_output(), lang);
79        let cmd = cmd_template.replace("{dir}", &dir);
80
81        eprintln!("  Formatting {lang}: {cmd}");
82        let status = std::process::Command::new("sh").args(["-c", &cmd]).status();
83
84        match status {
85            Ok(s) if s.success() => {}
86            Ok(s) => {
87                warn!("Formatter for {lang} exited with {s}: {cmd}");
88            }
89            Err(e) => {
90                warn!("Failed to run formatter for {lang}: {e}");
91            }
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn test_default_formatter_rust_uses_cd_into_dir() {
102        let cmd = default_formatter("rust").expect("rust must have a default formatter");
103        assert!(
104            cmd.contains("cd {dir}") && cmd.contains("cargo fmt"),
105            "rust formatter must cd into {{dir}} before invoking cargo fmt so it works on \
106             standalone (non-workspace-member) e2e crates: {cmd}"
107        );
108        assert!(
109            !cmd.contains("--manifest-path"),
110            "rust formatter must not use --manifest-path; cargo-fmt does not accept it as a \
111             global flag: {cmd}"
112        );
113        assert!(
114            cmd.contains("{dir}"),
115            "rust formatter must include {{dir}} placeholder: {cmd}"
116        );
117        assert!(
118            cmd.contains("cargo sort"),
119            "rust formatter must run cargo sort to normalise Cargo.toml before hash \
120             finalisation so prek's cargo-sort hook is a no-op: {cmd}"
121        );
122    }
123
124    #[test]
125    fn test_default_formatter_python_uses_ruff_check_and_format() {
126        let cmd = default_formatter("python").expect("python must have a default formatter");
127        assert!(
128            cmd.contains("ruff check --fix"),
129            "python formatter must run ruff check --fix before ruff format: {cmd}"
130        );
131        assert!(
132            cmd.contains("ruff format"),
133            "python formatter must run ruff format: {cmd}"
134        );
135        assert!(
136            cmd.contains("{dir}"),
137            "python formatter must include {{dir}} placeholder: {cmd}"
138        );
139    }
140
141    #[test]
142    fn test_default_formatter_node_uses_oxfmt() {
143        let cmd = default_formatter("node").expect("node must have a default formatter");
144        assert!(cmd.contains("oxfmt"), "node formatter must use oxfmt: {cmd}");
145        assert!(
146            cmd.contains("{dir}"),
147            "node formatter must include {{dir}} placeholder: {cmd}"
148        );
149    }
150
151    #[test]
152    fn test_default_formatter_wasm_uses_oxfmt() {
153        let cmd = default_formatter("wasm").expect("wasm must have a default formatter");
154        assert!(cmd.contains("oxfmt"), "wasm formatter must use oxfmt: {cmd}");
155        assert!(
156            cmd.contains("{dir}"),
157            "wasm formatter must include {{dir}} placeholder: {cmd}"
158        );
159    }
160
161    #[test]
162    fn test_default_formatter_unknown_lang_returns_none() {
163        assert!(default_formatter("gleam").is_none());
164        assert!(default_formatter("zig").is_none());
165        assert!(default_formatter("java").is_none());
166    }
167}