alef 0.67.6

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
475
476
477
478
479
480
481
482
483
484
485
486
//! Post-generation formatter support for e2e test projects.
//!
//! Formatting is delegated to the `poly` (polylint) CLI as a system dependency —
//! the same tool the main generate pipeline uses (see `cli::pipeline::format`).
//! For each language directory that had files generated, `run_formatters` runs a
//! single `poly fmt --fix` pass, which formats every language poly supports
//! (Python via ruff, JS/TS/JSON via oxc, Rust via rustfmt, Go via gofmt, …).
//! Languages are formatted in sorted order, and every language is attempted
//! before failures are reported, so which languages get formatted never depends
//! on iteration order or on whether an earlier one failed. A formatter that RUNS
//! and rejects the code still fails generation, naming every language that failed;
//! a formatter whose executable is absent is recorded and survived instead, unless
//! `--strict` asks for the old behaviour (see [`ShellFailure`]).
//!
//! Two escape hatches remain:
//! * a per-language `E2eConfig.format` override (`sh -c`, with `{dir}` expanded)
//!   replaces the poly pass for that language;
//! * a residual `go mod tidy` runs for Go directories — it is not formatting but
//!   is required to populate `go.sum` from `go.mod` so the e2e Go suite builds.
//!
//! That second escape hatch is why this stage distinguishes *formatting* from
//! *dependency resolution*. Under [`DependencyMode::Registry`] the generated
//! manifests pin the very version the current run produces, so any step that
//! resolves them against a registry cannot succeed until that version is
//! published — see [`DeferredFormatting`]. ~keep

use crate::core::backend::GeneratedFile;
use crate::core::config::e2e::DependencyMode;
use crate::e2e::config::E2eConfig;
use anyhow::Context as _;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tracing::warn;

/// A dependency-resolving step that could not run because the version the
/// generated manifests pin is not published yet.
///
/// Registry mode exists to exercise *published* artifacts, so during the run that
/// produces those artifacts the pinned version does not exist in any registry.
/// Resolving it is therefore a post-publish activity, and letting it abort the
/// generation that must happen *first* makes the release unreachable: publishing
/// needs a complete run, and a complete run needs the publish. Deferring breaks
/// that cycle without skipping any actual formatting. ~keep
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeferredFormatting {
    /// e2e language directory the step belonged to.
    pub language: String,
    /// The command, or built-in step name, that was deferred.
    pub step: String,
    /// Why it could not run now.
    pub reason: String,
}

impl DeferredFormatting {
    /// Whether this entry records an absent executable rather than an unpublished version.
    ///
    /// The two whys demand opposite operator actions — "install the toolchain" versus "wait
    /// for the release" — so every reporter has to tell them apart. [`MISSING_TOOLCHAIN_REASON`]
    /// is written verbatim at both sites that record an absent executable, and is the only
    /// reason that is, which is what makes the comparison the classification rather than a
    /// heuristic over free text. ~keep
    pub fn is_missing_toolchain(&self) -> bool {
        self.reason == MISSING_TOOLCHAIN_REASON
    }
}

impl std::fmt::Display for DeferredFormatting {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(formatter, "[{}] {}{}", self.language, self.step, self.reason)
    }
}

/// Recorded when a resolver is skipped outright because its input cannot exist yet.
const UNPUBLISHED_VERSION_REASON: &str = "registry-mode manifests pin the version this run produces, which is not \
                                          published yet; re-run after publishing";

/// Run per-language formatters for all languages that had files generated.
///
/// E2e files are written to `{output}/{lang}/...`, so the language is the first
/// path component after the output prefix. For each language directory: a user
/// `E2eConfig.format[lang]` override runs as a shell command (`{dir}` expanded);
/// otherwise poly formats the directory in-process. Languages run in sorted
/// order and each is attempted regardless of earlier failures; the run then
/// fails with every failing language named.
///
/// Actual formatting (poly, `mix format`) aborts generation in every mode — it
/// needs no registry and so has no pre-release excuse. Only *dependency
/// resolution* is treated differently: under [`DependencyMode::Registry`] a
/// resolving step is recorded as a [`DeferredFormatting`] instead of failing the
/// run, because the version it would resolve is the one this run is producing.
/// [`DependencyMode::Local`] behaviour is unchanged and always yields an empty
/// list. ~keep
pub fn run_formatters(
    files: &[GeneratedFile],
    e2e_config: &E2eConfig,
    strict: bool,
) -> anyhow::Result<Vec<DeferredFormatting>> {
    let defer_resolution = e2e_config.dep_mode == DependencyMode::Registry;
    let mut deferred = Vec::new();
    let output_prefix = Path::new(e2e_config.effective_output());
    let current_dir = std::env::current_dir().context("failed to resolve formatter working directory")?;
    // Sorted, not `HashSet` iteration order. `HashSet` is randomly seeded per process, so the
    // order languages were formatted in varied run to run; combined with the abort-on-first-
    // failure below, a single failing language left a *different* arbitrary subset of the others
    // unformatted each time, and regenerating an unchanged tree produced different bytes. ~keep
    let mut languages: Vec<String> = files
        .iter()
        .filter_map(|f| {
            let remainder = f.path.strip_prefix(output_prefix).ok()?;
            let first = remainder.components().next()?;
            Some(first.as_os_str().to_string_lossy().into_owned())
        })
        .collect::<HashSet<String>>()
        .into_iter()
        .collect();
    languages.sort();

    // Format every language before reporting rather than aborting on the first failure. Whether
    // one language's formatter fails must not decide whether the others run at all -- that made
    // the emitted tree depend on ordering. The run still fails, naming every failure. ~keep
    let mut failures: Vec<String> = Vec::new();
    for lang in &languages {
        if let Err(error) = format_language(lang, e2e_config, &current_dir, defer_resolution, strict, &mut deferred) {
            failures.push(format!("{lang}: {error:#}"));
        }
    }

    // poly (and user format overrides) rewrite files via atomic rename, which
    // resets Unix permissions to 0644 — clobbering the executable bit the scaffold
    // writer set on shebang scripts (e.g. `run_tests.php`). Re-assert it so shebang
    // e2e scripts stay executable after formatting. Paths are relative to the
    // process cwd (the repo root), matching where the writer/poly operate.
    for file in files {
        if file.content.starts_with("#!")
            && let Err(e) = crate::cli::pipeline::apply_shebang_chmod(&file.path, &file.content)
        {
            warn!("failed to restore exec bit on {}: {e}", file.path.display());
        }
    }
    if !failures.is_empty() {
        anyhow::bail!(
            "formatting failed for {} of {} language(s): {}",
            failures.len(),
            languages.len(),
            failures.join("; ")
        );
    }
    Ok(deferred)
}

/// Run the configured formatter for one generated language directory.
///
/// Split out of [`run_formatters`] so a failure can be collected per language instead of
/// aborting the whole pass -- see the ordering note there. ~keep
fn format_language(
    lang: &str,
    e2e_config: &E2eConfig,
    current_dir: &Path,
    defer_resolution: bool,
    strict: bool,
    deferred: &mut Vec<DeferredFormatting>,
) -> anyhow::Result<()> {
    let configured_dir = PathBuf::from(format!("{}/{}", e2e_config.effective_output(), lang));
    let dir_path = resolve_formatter_directory(&configured_dir, current_dir)?;
    let dir = shell_directory(&dir_path);

    // User override takes precedence and replaces the poly pass entirely. Its
    // contents are opaque to us, so registry-mode failures are deferred.
    if let Some(custom) = e2e_config.format.get(lang) {
        let cmd = custom.replace("{dir}", &dir);
        tracing::debug!("Formatting {lang}: {cmd}");
        return match run_shell(&cmd, lang) {
            Ok(()) => Ok(()),
            // A missing executable is an environment gap in every mode, so it is
            // resolved first — deferring it as an unpublished-version problem would
            // record a reason that is simply untrue. ~keep
            Err(failure) if failure.executable_missing => resolve_shell_failure(failure, lang, &cmd, strict, deferred),
            Err(failure) if defer_resolution => {
                let error = failure.error;
                warn!("deferring {lang} format override until after publish: {error}");
                deferred.push(DeferredFormatting {
                    language: lang.to_owned(),
                    step: cmd,
                    reason: format!("{UNPUBLISHED_VERSION_REASON} (failed with: {error})"),
                });
                Ok(())
            }
            Err(failure) => Err(failure.error),
        };
    }

    // Default: shell out to `poly fmt --fix` over the directory. poly walks up
    // from `dir_path` for a `poly.toml` (falling back to poly's zero-config
    // defaults when none is found).
    //
    // A missing `poly` executable is the same "environment gap" the override branch
    // above already treats leniently under non-`--strict` mode -- checked explicitly
    // here (rather than routed through `poly_format_strict`'s own bail, which cannot
    // tell a missing executable from poly running and rejecting the code) so this
    // branch honors `strict` exactly the way the override branch and this module's own
    // doc comment promise, instead of always failing hard regardless of `strict`. ~keep
    tracing::debug!("Formatting {lang} with poly: {dir}");
    if !crate::cli::pipeline::is_tool_available("poly") {
        if strict {
            anyhow::bail!("poly not found on PATH; generated output cannot be formatted");
        }
        warn!("{lang}: poly fmt skipped — executable not found; continuing so the run reaches finalisation");
        deferred.push(DeferredFormatting {
            language: lang.to_owned(),
            step: "poly fmt --fix".to_owned(),
            reason: MISSING_TOOLCHAIN_REASON.to_owned(),
        });
    } else {
        crate::cli::pipeline::poly_format_strict(std::slice::from_ref(&dir_path), &dir_path)?;
    }

    // Residual: `go mod tidy` populates `go.sum` from `go.mod` (poly cannot —
    // it is dependency resolution, not formatting) so the Go suite builds.
    if lang == "go" {
        if defer_resolution {
            warn!("skipping `go mod tidy` for {lang}: {UNPUBLISHED_VERSION_REASON}");
            deferred.push(DeferredFormatting {
                language: lang.to_owned(),
                step: GO_MOD_TIDY_STEP.to_owned(),
                reason: UNPUBLISHED_VERSION_REASON.to_owned(),
            });
        } else {
            run_go_mod_tidy(&dir_path, lang, strict, deferred)?;
        }
    }

    // Residual: `mix format` is the SOLE formatter for `.ex`/`.exs` — the poly
    // pass above excludes them (see `POLY_ELIXIR_EXCLUDE_GLOBS`), so without
    // this the generated Elixir suite is never formatted at all and ships with
    // the emitter's unwrapped long lines.
    if lang == "elixir" {
        run_mix_format(&dir_path, lang, strict, deferred)?;
    }
    Ok(())
}

fn resolve_formatter_directory(path: &Path, current_dir: &Path) -> anyhow::Result<PathBuf> {
    let absolute_path = if path.is_absolute() {
        path.to_path_buf()
    } else {
        current_dir.join(path)
    };
    absolute_path
        .canonicalize()
        .with_context(|| format!("generated formatter path does not exist: {}", absolute_path.display()))
}

/// Format files restored from the generation-stage cache.
///
/// Cached paths are absolute while e2e generation records paths relative to the
/// consumer root. Rebuilding the lightweight file descriptors keeps cache hits on
/// the same formatter path as fresh generation, including custom format commands
/// and executable-bit restoration. ~keep
pub fn run_formatters_for_cached_paths(
    paths: &[PathBuf],
    base_dir: &Path,
    e2e_config: &E2eConfig,
    strict: bool,
) -> anyhow::Result<Vec<DeferredFormatting>> {
    let output_is_absolute = Path::new(e2e_config.effective_output()).is_absolute();
    let files: Vec<GeneratedFile> = paths
        .iter()
        .filter_map(|path| {
            let formatter_path = if output_is_absolute {
                path.clone()
            } else {
                path.strip_prefix(base_dir).ok()?.to_path_buf()
            };
            let content = std::fs::read_to_string(path).unwrap_or_default();
            Some(GeneratedFile {
                path: formatter_path,
                content,
                generated_header: true,
            })
        })
        .collect();
    run_formatters(&files, e2e_config, strict)
}

/// The status a POSIX shell exits with when the command it was asked to run does not
/// exist. ~keep
const SHELL_COMMAND_NOT_FOUND: i32 = 127;

/// Render a canonicalized directory for interpolation into the `{dir}` placeholder of a
/// user `format` override, which [`run_shell`] hands to `sh -c`.
///
/// [`run_in_dir`] escapes this problem entirely by never going through a shell; a user
/// override *is* a shell line, so the path itself has to be shell-usable. On Windows
/// `canonicalize` returns the extended-length form `\\?\C:\...`, and a POSIX shell reads
/// every `\` as an escape -- `cd \\?\C:\Users\...` becomes `cd \?C:Users...`, which fails
/// before the formatter is ever reached. The shell then exits 1, and 1 is not
/// [`SHELL_COMMAND_NOT_FOUND`], so an absent formatter arrived as "the formatter ran and
/// rejected the code" and killed the run. ~keep
fn shell_directory(path: &Path) -> String {
    let text = path.to_string_lossy();
    if cfg!(windows) {
        posix_shell_path(&text)
    } else {
        text.into_owned()
    }
}

/// Strip Windows' extended-length prefix and switch to forward slashes, the drive-letter
/// form `sh` accepts. Kept free of `cfg` so it is unit-testable on every platform.
fn posix_shell_path(text: &str) -> String {
    let stripped = match text.strip_prefix(r"\\?\UNC\") {
        Some(rest) => format!(r"\\{rest}"),
        None => text.strip_prefix(r"\\?\").unwrap_or(text).to_owned(),
    };
    stripped.replace('\\', "/")
}

/// A shell-invoked formatter that did not succeed, and whether the executable was
/// even there.
///
/// `sh -c` starts fine whether or not the formatter exists, so both cases arrive as a
/// non-zero exit and the `Err` arm of `Command::status()` is never reached. Only a
/// formatter that RAN is a verdict on the generated code; a missing one is an
/// environment gap. Conflating them made the pipeline fresh-clone hostile — `vendor/`
/// is gitignored in consumers, so a fresh clone has no php-cs-fixer and the run died
/// before `finalize_hashes`, leaving a correctly generated but entirely unstamped tree:
/// invisible to `alef verify`, failing the consumer's poly gate, and
/// byte-indistinguishable from a marker-stripping bug. ~keep
struct ShellFailure {
    executable_missing: bool,
    error: anyhow::Error,
}

fn run_shell(cmd: &str, lang: &str) -> Result<(), ShellFailure> {
    match std::process::Command::new("sh").args(["-c", cmd]).status() {
        Ok(status) if status.success() => Ok(()),
        Ok(status) => Err(ShellFailure {
            executable_missing: status.code() == Some(SHELL_COMMAND_NOT_FOUND),
            error: anyhow::anyhow!("formatter for {lang} exited with {status}: {cmd}"),
        }),
        Err(error) => Err(ShellFailure {
            executable_missing: error.kind() == std::io::ErrorKind::NotFound,
            error: anyhow::Error::new(error).context(format!("failed to run formatter for {lang}: {cmd}")),
        }),
    }
}

/// Reason recorded when a formatter's executable is not installed on this machine.
const MISSING_TOOLCHAIN_REASON: &str = "the formatter's executable is not installed on this machine; generation \
                                        continued so the run still reaches finalisation. Install the toolchain, or \
                                        re-run with --strict to make this fatal";

/// Decide what an unsuccessful shell formatter means for the run.
///
/// A formatter that ran and rejected the code always fails the run — that is what
/// actually gates correctness, and it is unchanged. A formatter that is simply absent
/// is recorded and survived, unless `strict` asks for the old behaviour back.
///
/// Recording rather than skipping quietly is the whole point: an absent formatter that
/// nothing reports is the same shape as a check that passes while examining nothing. ~keep
fn resolve_shell_failure(
    failure: ShellFailure,
    lang: &str,
    step: &str,
    strict: bool,
    deferred: &mut Vec<DeferredFormatting>,
) -> anyhow::Result<()> {
    if !failure.executable_missing || strict {
        return Err(failure.error);
    }
    warn!("{lang}: `{step}` skipped — executable not found; continuing so the run reaches finalisation");
    deferred.push(DeferredFormatting {
        language: lang.to_owned(),
        step: step.to_owned(),
        reason: MISSING_TOOLCHAIN_REASON.to_owned(),
    });
    Ok(())
}

/// Step name recorded when `go mod tidy` is deferred.
const GO_MOD_TIDY_STEP: &str = "go mod tidy";

/// Log any deferred resolution steps, for standalone stage commands — which have no
/// later pipeline phase to report from the way `alef all` does.
pub fn warn_deferred(deferred: &[DeferredFormatting]) {
    report_deferred(None, deferred);
}

/// [`warn_deferred`] for a pipeline that reports per crate (`alef all`).
///
/// Shares one implementation with the standalone reporter deliberately: the two used to
/// classify the same `DeferredFormatting` list differently, and only one of them was ever
/// fixed. ~keep
pub fn warn_deferred_for_crate(crate_name: &str, deferred: &[DeferredFormatting]) {
    report_deferred(Some(crate_name), deferred);
}

/// Report deferred steps grouped by WHY they were deferred.
///
/// ~keep There are two distinct whys and they demand opposite operator actions:
/// [`UNPUBLISHED_VERSION_REASON`] (registry mode resolving a version this run itself
/// produces — wait for the release) and [`MISSING_TOOLCHAIN_REASON`] (the formatter's
/// executable is absent — install it, and treat this run's output as unformatted). A
/// heading that hard-coded "until the pinned version is published" was correct for the
/// first and actively false for the second, and it is the heading an operator triages on:
/// filed under benign release-cycle noise, a skipped formatter's own entry goes unread and
/// the unformatted tree gets committed. Missing toolchains therefore get their own heading
/// that says the output was left unformatted.
fn report_deferred(crate_name: Option<&str>, deferred: &[DeferredFormatting]) {
    let scope = crate_name.map_or_else(String::new, |name| format!("[{name}] "));
    let (missing, unpublished): (Vec<_>, Vec<_>) = deferred.iter().partition(|entry| entry.is_missing_toolchain());
    if !missing.is_empty() {
        warn!(
            "{scope}{} formatting step(s) skipped — the executable is not installed, so that \
             output is NOT formatted. Install the toolchain, or re-run with --strict to make \
             this fatal:",
            missing.len()
        );
        for entry in missing {
            warn!("  {entry}");
        }
    }
    if !unpublished.is_empty() {
        warn!(
            "{scope}{} dependency-resolution step(s) deferred until the pinned version is published:",
            unpublished.len()
        );
        for entry in unpublished {
            warn!("  {entry}");
        }
    }
}

/// Run a built-in residual step as a direct child process rooted at `dir`.
///
/// Deliberately not routed through [`run_shell`]: `sh -c "(cd {dir} && ...)"` cannot
/// carry a Windows path. `canonicalize` yields the extended-length form `\\?\C:\...`,
/// which a POSIX `cd` rejects, so the shell exits 1 from the failed `cd` before the
/// tool is ever reached -- and 1 is not [`SHELL_COMMAND_NOT_FOUND`], so an absent
/// toolchain arrived as "the formatter ran and rejected the code" and killed the run.
/// Letting the OS set the working directory also drops the unquoted `{dir}`
/// interpolation, so a path containing a space no longer truncates the command. ~keep
fn run_in_dir(program: &str, args: &[&str], dir: &Path, lang: &str) -> Result<(), ShellFailure> {
    let step = std::iter::once(program)
        .chain(args.iter().copied())
        .collect::<Vec<_>>()
        .join(" ");
    match std::process::Command::new(program).args(args).current_dir(dir).status() {
        Ok(status) if status.success() => Ok(()),
        Ok(status) => Err(ShellFailure {
            executable_missing: false,
            error: anyhow::anyhow!(
                "formatter for {lang} exited with {status}: {step} (in {})",
                dir.display()
            ),
        }),
        Err(error) => Err(ShellFailure {
            executable_missing: error.kind() == std::io::ErrorKind::NotFound,
            error: anyhow::Error::new(error).context(format!("failed to run {step} for {lang} in {}", dir.display())),
        }),
    }
}

/// Populate `go.sum` from `go.mod` in the e2e Go directory.
fn run_go_mod_tidy(dir: &Path, lang: &str, strict: bool, deferred: &mut Vec<DeferredFormatting>) -> anyhow::Result<()> {
    match run_in_dir("go", &["mod", "tidy"], dir, "go") {
        Ok(()) => Ok(()),
        Err(failure) => resolve_shell_failure(failure, lang, GO_MOD_TIDY_STEP, strict, deferred),
    }
}

/// Format `.ex`/`.exs` in the e2e Elixir directory with `mix format`.
///
/// Must run from `dir` so mix reads that project's own `.formatter.exs` (emitted
/// alongside `mix.exs`) — a bare `mix format` has no `inputs:` without it. That
/// file deliberately omits `import_deps`, so this needs no prior `mix deps.get`.
fn run_mix_format(dir: &Path, lang: &str, strict: bool, deferred: &mut Vec<DeferredFormatting>) -> anyhow::Result<()> {
    match run_in_dir("mix", &["format"], dir, "elixir") {
        Ok(()) => Ok(()),
        Err(failure) => resolve_shell_failure(failure, lang, "mix format", strict, deferred),
    }
}

#[cfg(test)]
#[path = "format_tests.rs"]
mod tests;