alef 0.48.5

Opinionated polyglot binding generator for Rust libraries
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
use crate::core::config::{Language, ResolvedCrateConfig};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tracing::{debug, warn};

/// One residual formatter invocation poly cannot perform (project-wide tools that
/// don't fit poly's per-file model): `cargo sort`, `mix format`, `dotnet format`.
struct ResidualStep {
    command: String,
    args: Vec<String>,
    work_dir: PathBuf,
}

/// A code formatter that shapes generated output and must be present for a run.
#[derive(Clone, Copy)]
struct RequiredFormatter {
    tool: &'static str,
    install_hint: &'static str,
}

/// The formatters whose presence is required for deterministic generation of
/// `languages`.
///
/// `rustfmt` and `poly` are always required: every binding emits a Rust glue
/// crate that rustfmt reflows, and poly formats each language's emitted package.
/// `cargo-sort` is required only when a language whose residual pass runs
/// `cargo sort` is generated (wasm, ffi, ruby, elixir, r).
fn required_formatters(languages: &[Language]) -> Vec<RequiredFormatter> {
    let mut required = vec![
        RequiredFormatter {
            tool: "rustfmt",
            install_hint: "rustup component add rustfmt",
        },
        RequiredFormatter {
            tool: "poly",
            install_hint: "install polylint (`poly`) and put it on PATH",
        },
    ];
    let needs_cargo_sort = languages.iter().any(|language| {
        matches!(
            language,
            Language::Wasm | Language::Ffi | Language::Ruby | Language::Elixir | Language::R
        )
    });
    if needs_cargo_sort {
        required.push(RequiredFormatter {
            tool: "cargo-sort",
            install_hint: "cargo install cargo-sort",
        });
    }
    required
}

/// Warn (never fail) when a formatter that shapes generated output is missing
/// from PATH.
///
/// alef always applies formatting when the tools are present — poly in
/// particular formats through `poly fmt` whenever it is on PATH and the pass is
/// skipped otherwise. A missing formatter (`rustfmt`, `poly`, or `cargo-sort`)
/// can leave output un(der)-formatted and host-dependent, which may trip the
/// freshness check (#184); rather than abort generation, warn and name each
/// missing tool and how to install it so the operator can restore deterministic
/// output.
pub fn warn_missing_formatters(languages: &[Language]) {
    let missing: Vec<RequiredFormatter> = required_formatters(languages)
        .into_iter()
        .filter(|formatter| !is_tool_available(formatter.tool))
        .collect();
    if missing.is_empty() {
        return;
    }
    let details = missing
        .iter()
        .map(|formatter| format!("  - {}: {}", formatter.tool, formatter.install_hint))
        .collect::<Vec<_>>()
        .join("\n");
    warn!(
        "code formatter(s) not found on PATH; generated output may be un(der)-formatted and \
         host-dependent (#184). Install to restore deterministic formatting:\n{details}"
    );
}

/// Run language-native formatters on emitted packages after generation.
///
/// Formatting is always delegated to the `poly` (polylint) CLI. On a full regen
/// (`only_languages = None`, the `alef all` path) this converges to a fixed point:
/// see [`converge_full_regen_formatting`]. On a partial regen (a single language's
/// files changed) a single `poly fmt --fix` pass runs over the changed language's
/// package directory, followed by that language's residual native pass for the
/// project-wide tools poly cannot wrap (wasm/ruby/elixir/R native crate sort).
///
/// Best-effort: a missing `poly` binary, a poly error, or a missing residual tool
/// is logged as a warning and never aborts the generate command.
pub fn format_generated(
    files: &[(Language, Vec<crate::core::backend::GeneratedFile>)],
    config: &ResolvedCrateConfig,
    base_dir: &Path,
    only_languages: Option<&HashSet<Language>>,
) {
    let mut seen = HashSet::new();
    let poly_langs: Vec<Language> = files
        .iter()
        .map(|(lang, _)| *lang)
        .filter(|lang| seen.insert(*lang) && only_languages.is_none_or(|filter| filter.contains(lang)))
        .collect();

    if poly_langs.is_empty() {
        return;
    }

    match only_languages {
        None => converge_full_regen_formatting(base_dir),
        Some(_) => {
            let paths = poly_paths(config, base_dir, only_languages, &poly_langs);
            poly_format(&paths, base_dir);
            for &lang in &poly_langs {
                let lang_str = lang.to_string().to_lowercase();
                for step in language_residuals(config, lang, base_dir) {
                    run_residual(&step, &lang_str);
                }
            }
        }
    }
}

/// Maximum `poly fmt --fix` passes attempted while converging a full regen.
///
/// Some poly-bundled engines (`.cs`, `.java`, `.json` today) are not single-pass
/// idempotent on freshly generated output: a first `poly fmt --fix` pass can still
/// leave `poly fmt --check` reporting drift. Looping converges them so a full
/// regen is committable without a manual cleanup pass downstream (see #184-style
/// freshness-check failures).
const MAX_POLY_FMT_PASSES: u32 = 3;

/// Self-cleaning full-regen formatting pass, used on the `alef all` path
/// (`only_languages = None`).
///
/// Loops `poly fmt --fix <base_dir>` to a fixed point (detected via `poly fmt
/// --check`, bounded by [`MAX_POLY_FMT_PASSES`]), folding a workspace-wide
/// `cargo fmt --all` and a workspace-wide `cargo sort -n -w` into *every* pass of
/// the same loop. Running them inside the loop — rather than once, after —
/// means that if either tool disagrees with poly's own formatting, the next
/// pass's `poly fmt --fix`/`--check` observes and reconciles the drift instead of
/// leaving the tree dirty.
///
/// This replaces the old per-language cargo-sort residuals on a full regen: those
/// only covered the language whose crate directory they targeted (and the
/// workspace-wide `-w` variant ran only when the ffi target was generated),
/// leaving other generated crates (python, node, php, swift, dart, …) unsorted —
/// exactly the gap that trips poly's own workspace-wide cargo-sort check
/// downstream. A single `cargo sort -n -w` at the repo root covers every crate in
/// the workspace regardless of which languages this run generated.
///
/// Best-effort throughout: a missing `poly`, `cargo`, `rustfmt`, or `cargo-sort`
/// is a warning, never a failure, and generation is never aborted.
fn converge_full_regen_formatting(base_dir: &Path) {
    let poly_present = is_tool_available("poly");
    if !poly_present {
        warn!("poly not found on PATH (skipping post-generation formatting)");
    }
    let root = vec![base_dir.to_path_buf()];

    for _pass in 1..=MAX_POLY_FMT_PASSES {
        if poly_present {
            poly_format(&root, base_dir);
        }
        run_cargo_fmt(base_dir);
        run_workspace_cargo_sort(base_dir);

        if !poly_present || poly_fmt_is_clean(base_dir) {
            return;
        }
    }
    warn!(
        "poly fmt did not converge after {MAX_POLY_FMT_PASSES} passes (non-fatal); generated \
         output may have residual formatting drift"
    );
}

/// Check `poly fmt --check <base_dir>` for a clean (already-formatted) tree. Used
/// only to detect convergence inside [`converge_full_regen_formatting`]'s loop.
fn poly_fmt_is_clean(base_dir: &Path) -> bool {
    let path_str = base_dir.to_string_lossy().into_owned();
    run_formatter("poly", &["fmt", "--check", &path_str], base_dir).is_ok()
}

/// Run `cargo fmt --all` at the workspace root, when `cargo`, `rustfmt`, and a
/// root `Cargo.toml` are all present. Folded into
/// [`converge_full_regen_formatting`]'s loop rather than run once afterward, so a
/// later `poly fmt` pass reconciles anything cargo fmt changes that poly's own
/// per-file rustfmt invocation did not already produce. Best-effort: a missing
/// root `Cargo.toml` is a debug/skip (not every generated tree is a cargo
/// workspace); a missing tool is a warning/skip; a non-zero exit is a warning.
fn run_cargo_fmt(base_dir: &Path) {
    if !base_dir.join("Cargo.toml").exists() {
        debug!(
            "no root Cargo.toml at {}, skipping workspace cargo fmt",
            base_dir.display()
        );
        return;
    }
    if !is_tool_available("cargo") || !is_tool_available("rustfmt") {
        warn!("cargo/rustfmt not found on PATH (skipping workspace cargo fmt)");
        return;
    }
    match run_formatter("cargo", &["fmt", "--all"], base_dir) {
        Ok(()) => debug!("cargo fmt --all ok"),
        Err(e) => warn!("cargo fmt --all failed (non-fatal): {e}"),
    }
}

/// Run `cargo sort -n -w` once at the workspace root, covering every crate in the
/// workspace regardless of which languages this run generated. See
/// [`converge_full_regen_formatting`] for why this replaces the per-language
/// residuals on a full regen. The `-n` flag skips cargo-sort's own post-sort
/// formatting pass (which would otherwise fight poly's TOML formatter over
/// whitespace/quote style); it does not affect table or dependency ordering, so
/// it does not change what poly's bundled cargo-sort check accepts as sorted.
/// Best-effort: a missing root `Cargo.toml` is a debug/skip; a missing
/// `cargo-sort` binary is a warning/skip; a non-zero exit is a warning.
fn run_workspace_cargo_sort(base_dir: &Path) {
    if !base_dir.join("Cargo.toml").exists() {
        debug!(
            "no root Cargo.toml at {}, skipping workspace cargo sort",
            base_dir.display()
        );
        return;
    }
    if !is_tool_available("cargo-sort") {
        warn!("cargo-sort not found on PATH (skipping workspace cargo sort)");
        return;
    }
    match run_formatter("cargo", &["sort", "-n", "-w"], base_dir) {
        Ok(()) => debug!("cargo sort -n -w ok"),
        Err(e) => warn!("cargo sort -n -w failed (non-fatal): {e}"),
    }
}

/// Run `poly fmt --fix <base_dir>`. Best-effort: a missing `poly` binary or
/// non-zero exit is logged as a warning and never propagated.
pub fn poly_fmt(base_dir: &Path) {
    let paths = vec![base_dir.to_path_buf()];
    poly_format(&paths, base_dir);
}

/// Run `poly lint <base_dir>`. Propagates failure — a non-zero exit is an error.
pub fn poly_lint(base_dir: &Path) -> anyhow::Result<()> {
    if !is_tool_available("poly") {
        warn!("poly not found on PATH (skipping lint)");
        return Ok(());
    }
    let path_str = base_dir.to_string_lossy().into_owned();
    let arg_refs: Vec<&str> = vec!["lint", &path_str];
    match run_formatter("poly", &arg_refs, base_dir) {
        Ok(()) => {
            debug!("poly lint ok");
            Ok(())
        }
        Err(e) => Err(anyhow::anyhow!("poly lint failed: {e}")),
    }
}

/// Return the fixed set of all cargo-sort residual steps that alef always runs
/// after formatting, regardless of which languages the config targets.
///
/// The fixed set covers: workspace-wide (via ffi), wasm, ruby, elixir, R.
/// Dart and swift have no cargo residuals (poly covers them).
fn cargo_sort_residuals(config: &ResolvedCrateConfig, base_dir: &Path) -> Vec<ResidualStep> {
    let mut steps = Vec::new();
    steps.extend(language_residuals(config, Language::Ffi, base_dir));
    steps.extend(language_residuals(config, Language::Wasm, base_dir));
    steps.extend(language_residuals(config, Language::Ruby, base_dir));
    steps.extend(language_residuals(config, Language::Elixir, base_dir));
    steps.extend(language_residuals(config, Language::R, base_dir));
    steps
}

/// Run all cargo-sort residuals (ffi workspace, wasm, ruby, elixir, R). Best-effort.
pub(crate) fn run_cargo_sort_residuals(config: &ResolvedCrateConfig, base_dir: &Path) {
    for step in cargo_sort_residuals(config, base_dir) {
        run_residual(&step, "residual");
    }
}

/// Paths to hand to poly. Full regen → the repo root (one pass). Partial regen →
/// the package directory of each changed language (existing dirs only, deduped).
fn poly_paths(
    config: &ResolvedCrateConfig,
    base_dir: &Path,
    only_languages: Option<&HashSet<Language>>,
    poly_langs: &[Language],
) -> Vec<PathBuf> {
    match only_languages {
        None => vec![base_dir.to_path_buf()],
        Some(_) => {
            let mut seen = HashSet::new();
            let mut dirs = Vec::new();
            for &lang in poly_langs {
                let dir = base_dir.join(config.package_dir(lang));
                if seen.insert(dir.clone()) && dir.exists() {
                    dirs.push(dir);
                }
            }
            dirs
        }
    }
}

/// Format `paths` by invoking the `poly` CLI (`poly fmt --fix`), rewriting changed
/// files in place. `config_start` is poly's working directory; it walks up from
/// there for `poly.toml`. Best-effort: a missing `poly` binary or a non-zero exit
/// is logged and never propagated (matching the per-language formatter contract).
pub(crate) fn poly_format(paths: &[PathBuf], config_start: &Path) {
    if paths.is_empty() {
        return;
    }
    if !is_tool_available("poly") {
        warn!("poly not found on PATH (skipping post-generation formatting)");
        return;
    }
    let mut args: Vec<String> = vec!["fmt".to_owned(), "--fix".to_owned()];
    args.extend(paths.iter().map(|path| path.to_string_lossy().into_owned()));
    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
    match run_formatter("poly", &arg_refs, config_start) {
        Ok(()) => debug!("poly fmt over {} path(s) ok", paths.len()),
        Err(e) => warn!("poly fmt failed (non-fatal): {e}"),
    }
}

/// Best-effort wiring of poly's git-hook shims (`poly hooks install`) into the
/// generated repo. This installs the pre-commit + commit-msg stages declared in
/// the scaffolded `poly.toml` `[hooks]` section — polylint, polyfmt, file_safety,
/// the `cargo` builtin (clippy / cargo-sort / machete / deny), and the
/// conventional-commit `commit` hook — so every generated repository lints,
/// formats, and validates on commit without any per-repo manual setup.
///
/// No-op when `poly` is absent from PATH or `base_dir` is not a git repository.
/// Idempotent — `poly hooks install` re-writes the same shims, so it is safe to
/// run on every scaffold pass. Never aborts generation.
pub(crate) fn install_poly_hooks(base_dir: &Path) {
    if !base_dir.join(".git").exists() {
        debug!(
            "not a git repository at {}, skipping poly hooks install",
            base_dir.display()
        );
        return;
    }
    if !is_tool_available("poly") {
        warn!("poly not found on PATH (skipping poly hooks install)");
        return;
    }
    match run_formatter("poly", &["hooks", "install"], base_dir) {
        Ok(()) => debug!("poly hooks install ok"),
        Err(e) => warn!("poly hooks install failed (non-fatal): {e}"),
    }
}

/// Build the residual formatter steps for a language. The only residual is
/// `cargo sort -n` for binding crates whose `Cargo.toml` is excluded from the poly
/// pass — a dependency-ordering tool (not a formatter) that ships with cargo and
/// is always present in alef's build environment. Everything else, including
/// Elixir and C#, is formatted by poly's deterministic pure-Rust tier-2 tier
/// (no `mix format` / `dotnet format` system-toolchain dependency).
fn language_residuals(config: &ResolvedCrateConfig, lang: Language, base_dir: &Path) -> Vec<ResidualStep> {
    match lang {
        Language::Wasm => {
            let crate_dir = config
                .output_for("wasm")
                .map(resolve_crate_dir)
                .unwrap_or_else(|| Path::new("crates").join(format!("{}-wasm", config.name)));
            let crate_dir_str = crate_dir.to_string_lossy().into_owned().replace('\\', "/");
            vec![cargo_sort(vec![crate_dir_str], base_dir.to_path_buf())]
        }
        Language::Ffi => vec![cargo_sort(vec!["-w".to_owned()], base_dir.to_path_buf())],
        Language::Ruby => {
            let gem_name = config.ruby_gem_name();
            let native_subdir = format!("ext/{gem_name}/native");
            vec![cargo_sort(vec![native_subdir], base_dir.join("packages/ruby"))]
        }
        Language::Elixir => {
            let app_name = config.elixir_app_name();
            let native_subdir = format!("native/{app_name}_nif");
            vec![cargo_sort(vec![native_subdir], base_dir.join("packages/elixir"))]
        }
        Language::R => vec![cargo_sort(
            vec!["packages/r/src/rust".to_owned()],
            base_dir.to_path_buf(),
        )],
        _ => vec![],
    }
}

/// Construct a `cargo sort -n` residual step. The `-n` flag preserves single-line
/// array formatting, preventing cargo-sort from expanding dependency arrays that
/// alef emits on one line for readability.
fn cargo_sort(mut sort_args: Vec<String>, work_dir: PathBuf) -> ResidualStep {
    let mut args = vec!["sort".to_owned(), "-n".to_owned()];
    args.append(&mut sort_args);
    ResidualStep {
        command: "cargo".to_owned(),
        args,
        work_dir,
    }
}

/// Run a single residual step, best-effort: a missing work dir or tool is a
/// warning/skip, a non-zero exit is a warning. Never aborts generation.
fn run_residual(step: &ResidualStep, lang_str: &str) {
    if !step.work_dir.exists() {
        debug!(
            "  [{lang_str}] residual work dir does not exist: {}, skipping",
            step.work_dir.display()
        );
        return;
    }
    if !is_tool_available(&step.command) {
        warn!("[{lang_str}] residual formatter not found: {} (skipping)", step.command);
        return;
    }
    let args: Vec<&str> = step.args.iter().map(String::as_str).collect();
    match run_formatter(&step.command, &args, &step.work_dir) {
        Ok(()) => debug!("  [{lang_str}] {} {:?} ok", step.command, args),
        Err(e) => warn!("[{lang_str}] {} {:?} failed: {e}", step.command, args),
    }
}

/// Check if a tool is available on PATH.
fn is_tool_available(tool: &str) -> bool {
    Command::new("which")
        .arg(tool)
        .output()
        .map(|output| output.status.success())
        .unwrap_or(false)
}

/// Run a formatter command with arguments in a specific directory.
fn run_formatter(command: &str, args: &[&str], work_dir: &Path) -> anyhow::Result<()> {
    let output = Command::new(command).args(args).current_dir(work_dir).output()?;

    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "formatter exited with code {:?}: {}",
            output.status.code(),
            format_command_output(&output)
        ));
    }

    Ok(())
}

fn format_command_output(output: &Output) -> String {
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let stdout = stdout.trim();
    let stderr = stderr.trim();

    match (stdout.is_empty(), stderr.is_empty()) {
        (false, false) => format!("stdout:\n{stdout}\nstderr:\n{stderr}"),
        (false, true) => format!("stdout:\n{stdout}"),
        (true, false) => format!("stderr:\n{stderr}"),
        (true, true) => "<no output>".to_string(),
    }
}

fn resolve_crate_dir(output_path: &Path) -> PathBuf {
    output_path
        .parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| output_path.to_path_buf())
}

#[cfg(test)]
mod tests;