alef 0.76.0

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
//! `src/snippets/validators/python.rs` runs pyrefly, but only over isolated doc-snippet
//! scratch files -- never over a real generated package. A type checker alef ships and never
//! points at its own generated output is a check that examines nothing: the pyo3 backend's
//! public-dataclass-vs-native-pyclass mismatch (task alef-310, fixed alongside this test) went
//! undetected by every automated gate alef runs on itself, and was only found by a human running
//! pyrefly by hand over a real consumer's `packages/python`. This test closes that gap by driving
//! `alef all` end to end against a real fixture -- the same dispatch path a consumer runs -- and
//! then running pyrefly over the actual `packages/python` directory it wrote, exactly as a
//! consumer's own `pyrefly check` (or `alef lint`'s `typecheck` step, see
//! `core::config::lint_defaults::default_lint_config`) would.
//!
//! This is a single dedicated test, not a step wired into every `cargo test` or `alef build`:
//! pyrefly is an external tool this dev machine happens to have, most CI machines running the
//! full `--lib` suite do not, and a type-checker pass over generated Python is exactly the kind
//! of slow, environment-dependent check that belongs in one targeted place rather than on every
//! build. It follows the same `which::which("pyrefly").is_err() { return }` skip convention
//! already used by `snippets::validators::python`'s own pyrefly-backed tests, rather than
//! inventing a second one.

use crate::bin_cli::args::Commands;
use crate::bin_cli::dispatch::DispatchContext;

const FIXTURE_CARGO_TOML: &str = "[package]\nname = \"test-lib\"\nversion = \"0.1.0\"\nedition = \"2024\"\n";

/// `maybe_result` returns `Option<ResultData>` -- never `ResultData` bare -- so it exercises the
/// extraction fix (`is_return_type` must be set through `Option`/`Vec`/`Map` wrappers, not only
/// on a bare `Named` return) exactly as much as it exercises the pyo3 codegen that reads that
/// flag. `ResultData` has no other use in this fixture, so if the flag were wrong `options.py`
/// would emit it as an input dataclass while `api.py` actually returns/receives the native
/// pyclass -- the mismatch pyrefly's `bad-return` catches.
///
/// The remaining constructs below exist to audit the three still-suppressed pyrefly codes
/// (`bad-argument-count`, `not-iterable`, `missing-attribute`; task alef-334) against real
/// generated output rather than leaving them suppressed on faith. Each targets the specific
/// generated-code shape most likely to trip its code (see `src/backends/pyo3/gen_bindings/
/// functions/converters.rs` for the `options`-dataclass-to-native-pyclass conversion path all
/// three shapes exercise):
///
/// - `Filter` is used ONLY as a function argument (`apply_filter`), so it is emitted as an
///   `options.py` dataclass whose `_to_rust_filter` conversion calls the native `Filter`
///   pyclass constructor by keyword -- the exact call site where a wrapper/native argument-count
///   desync would surface (`bad-argument-count`).
/// - `Status` is a plain (data-less) enum and `BatchInput.statuses` is `Vec<Status>` used only as
///   an argument, which routes through `simple_enum_vec_coerce.jinja`'s
///   `[_coerce_enum(_rust.Status, v) for v in accessor]` list comprehension -- the exact
///   generated shape that needs `accessor` to type as an iterable (`not-iterable`).
/// - `Person`/`Address` is a nested options dataclass (a dataclass field that is itself another
///   dataclass) used only as an argument, so the outer conversion function must chain into the
///   inner one and read fields across that boundary -- the shape most likely to read a field
///   that does not exist on the declared dataclass type (`missing-attribute`).
/// - `ValidationError` is a `#[derive(thiserror::Error)]` enum with a field-carrying variant, the
///   shape `src/extract/extractor/mod.rs`'s `is_thiserror_enum` routes to `surface.errors` (a
///   plain struct implementing `Display`/`Error` by hand is NOT recognized as an error type and
///   was NOT exercising this path before this fixture was written -- confirmed by inspecting the
///   original fixture's generated `exceptions.py`, which was empty). ~keep
const FIXTURE_SOURCE: &str = r#"
#[derive(Default)]
pub struct ResultData {
    pub label: String,
}

pub fn maybe_result(flag: bool) -> Option<ResultData> {
    if flag {
        Some(ResultData { label: "found".to_string() })
    } else {
        None
    }
}

#[derive(Default, Clone)]
pub struct Point {
    pub x: i64,
    pub y: i64,
    pub label: Option<String>,
}

impl Point {
    pub fn new(x: i64, y: i64, label: Option<String>) -> Self {
        Point { x, y, label }
    }

    pub fn translate(&self, dx: i64, dy: i64, scale: Option<i64>) -> Point {
        let factor = scale.unwrap_or(1);
        Point {
            x: self.x + dx * factor,
            y: self.y + dy * factor,
            label: self.label.clone(),
        }
    }
}

pub fn list_points(count: i64) -> Vec<Point> {
    (0..count).map(|i| Point::new(i, i, None)).collect()
}

pub enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
}

pub fn describe_shape(shape: Shape) -> String {
    match shape {
        Shape::Circle { radius } => format!("circle r={radius}"),
        Shape::Rectangle { width, height } => format!("rect {width}x{height}"),
    }
}

#[derive(Default)]
pub struct Filter {
    pub min_value: i64,
    pub max_value: i64,
    pub label: Option<String>,
}

pub fn apply_filter(data: Vec<i64>, filter: Filter) -> Vec<i64> {
    data.into_iter()
        .filter(|value| *value >= filter.min_value && *value <= filter.max_value)
        .collect()
}

pub enum Status {
    Active,
    Inactive,
}

#[derive(Default)]
pub struct BatchInput {
    #[serde(default)]
    pub statuses: Vec<Status>,
}

pub fn count_active(input: BatchInput) -> i64 {
    input.statuses.iter().filter(|status| matches!(status, Status::Active)).count() as i64
}

#[derive(Default)]
pub struct Address {
    pub city: String,
}

#[derive(Default)]
pub struct Person {
    pub name: String,
    pub address: Address,
}

pub fn greet(person: Person) -> String {
    format!("hi {} from {}", person.name, person.address.city)
}

#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
    #[error("{field}: {message}")]
    InvalidField { field: String, message: String },
}

pub fn validate(value: i64) -> Result<i64, ValidationError> {
    if value < 0 {
        Err(ValidationError::InvalidField {
            field: "value".to_string(),
            message: "must be non-negative".to_string(),
        })
    } else {
        Ok(value)
    }
}

// The three constructs below target the three converter-generator defects found auditing
// two consumer repos against 0.67.6 (which removed `bad-argument-type`/`bad-return` from
// the scaffolded pyrefly suppressions on the claim that codegen now emits correct
// `_to_rust_*`/`_from_native_*` conversions for these boundaries -- this fixture proves that
// claim against the specific shapes it did not originally cover). ~keep
//
// - `ResponseTool.tool_type` carries `#[serde(rename = "type")]`, a Python reserved word. The
//   `_to_rust_response_tool` converter and the `.pyi` `__init__` stub must agree on the emitted
//   keyword-argument spelling (`type`, not `type_`) or pyrefly reports `[unexpected-keyword]`.
// - `Recipe.ingredients` is `Vec<Ingredient>` where `Ingredient` is itself a `has_default`
//   struct, so `_to_rust_recipe` must convert each element with `_to_rust_ingredient`, not pass
//   the raw `list[options.Ingredient]` straight through (pyrefly `[bad-argument-type]`).
// - `Task` has two independent optional simple-enum fields (`priority`, `mode`) on one
//   constructor call. Both are `Option<Enum>` in the native binding, so the emitted converter
//   used to route them through a `**({...} if ... else {})` omission trick that isn't needed for
//   an already-optional field -- and two such unpacks in one call is exactly the shape that made
//   pyrefly cross-assign the two enum types between the two parameters.
#[derive(Default)]
pub struct ResponseTool {
    #[serde(rename = "type")]
    pub tool_type: String,
    pub label: Option<String>,
}

pub fn describe_tool(tool: ResponseTool) -> String {
    format!("{}: {}", tool.tool_type, tool.label.unwrap_or_default())
}

#[derive(Default, Clone)]
pub struct Ingredient {
    pub name: String,
}

#[derive(Default)]
pub struct Recipe {
    pub title: String,
    pub ingredients: Vec<Ingredient>,
}

pub fn total_ingredients(recipe: Recipe) -> i64 {
    recipe.ingredients.len() as i64
}

pub enum Priority {
    Low,
    High,
}

pub enum Mode {
    Fast,
    Slow,
}

#[derive(Default)]
pub struct Task {
    pub title: String,
    pub priority: Option<Priority>,
    pub mode: Option<Mode>,
}

pub fn describe_task(task: Task) -> String {
    let priority = match task.priority {
        Some(Priority::Low) => "low",
        Some(Priority::High) => "high",
        None => "unset",
    };
    let mode = match task.mode {
        Some(Mode::Fast) => "fast",
        Some(Mode::Slow) => "slow",
        None => "unset",
    };
    format!("{}: {priority}/{mode}", task.title)
}

// Three NON-`Option` enum fields, each carrying bare `#[serde(default)]`, on one config struct.
// `Task` above covers the `Option<Enum>` form; this covers the form that still routed through the
// `**({...} if x is not None else {})` omission trick. `options.py` renders each of these as the
// enum's `#[default]` variant string and never as `None`, so the guard is statically always true
// -- and pyrefly resolves an unpacked keyword against every remaining parameter, so N unpacks in
// one constructor call cost N*(N-1) `[bad-argument-type]` errors. Three fields is the smallest
// count that makes the cost visible as a cluster rather than a single pair. ~keep
#[derive(Default, Clone, Copy)]
pub enum Alignment {
    #[default]
    Start,
    End,
}

#[derive(Default, Clone, Copy)]
pub enum Density {
    #[default]
    Loose,
    Tight,
}

#[derive(Default, Clone, Copy)]
pub enum Casing {
    #[default]
    Lower,
    Upper,
}

#[derive(Default)]
pub struct LayoutSpec {
    pub title: String,
    #[serde(default)]
    pub alignment: Alignment,
    #[serde(default)]
    pub density: Density,
    #[serde(default)]
    pub casing: Casing,
}

pub fn describe_layout(spec: LayoutSpec) -> String {
    let alignment = match spec.alignment {
        Alignment::Start => "start",
        Alignment::End => "end",
    };
    let density = match spec.density {
        Density::Loose => "loose",
        Density::Tight => "tight",
    };
    let casing = match spec.casing {
        Casing::Lower => "lower",
        Casing::Upper => "upper",
    };
    format!("{}: {alignment}/{density}/{casing}", spec.title)
}
"#;

const FIXTURE_ALEF_TOML: &str = r#"
[workspace]
languages = ["python"]

[[crates]]
name = "test-lib"
sources = ["src/lib.rs"]
version_from = "Cargo.toml"

[crates.python]
module_name = "test_lib"

[crates.python.stubs]
output = "packages/python/test_lib"
"#;

fn write_fixture_workspace(root: &std::path::Path) {
    std::fs::create_dir_all(root.join("src")).expect("create fixture src directory");
    std::fs::write(root.join("src/lib.rs"), FIXTURE_SOURCE).expect("write fixture source");
    std::fs::write(root.join("Cargo.toml"), FIXTURE_CARGO_TOML).expect("write fixture Cargo.toml");
    std::fs::write(root.join("alef.toml"), FIXTURE_ALEF_TOML).expect("write fixture alef.toml");
}

/// The scaffolded `pyproject.toml` lives beside the python package directory (e.g.
/// `packages/python/pyproject.toml` next to `packages/python/test_lib/`), not inside it -- find
/// it by its `[tool.pyrefly]` marker rather than hard-coding the package-name-derived path.
fn find_pyrefly_project_dir(root: &std::path::Path) -> std::path::PathBuf {
    for entry in walkdir_pyproject_tomls(root) {
        let content = std::fs::read_to_string(&entry).unwrap_or_default();
        if content.contains("[tool.pyrefly]") {
            return entry
                .parent()
                .expect("pyproject.toml has a parent directory")
                .to_path_buf();
        }
    }
    panic!("no scaffolded pyproject.toml with a [tool.pyrefly] section found under {root:?}");
}

fn walkdir_pyproject_tomls(root: &std::path::Path) -> Vec<std::path::PathBuf> {
    let mut found = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if path.file_name().is_some_and(|name| name == "pyproject.toml") {
                found.push(path);
            }
        }
    }
    found
}

/// Runs `alef all` against a real fixture, then runs real pyrefly 1.2.0+ over the real
/// `packages/python` output it wrote -- the same directory and the same `pyproject.toml` (with
/// its scaffolded `[[tool.pyrefly.sub-config]]` suppressions) a consumer's own `pyrefly check`
/// would see. Zero errors is the regression gate: a return-type or argument-type boundary
/// mismatch reintroduced into the pyo3 backend surfaces here as a real `bad-return` or
/// `bad-argument-type`, not just in a hand-run consumer audit.
///
/// The fixture also exercises the three codes `src/scaffold/languages/python.rs` still
/// suppresses for every `**/api.py` (`bad-argument-count`, `not-iterable`, `missing-attribute`;
/// task alef-334) through their most plausible generated shapes (see `FIXTURE_SOURCE`'s doc
/// comment). Hand-corrupting the generated `_to_rust_filter`/`_to_rust_batch_input`/
/// `_to_rust_person` call sites this fixture produces (an extra positional arg, an iteration
/// over a non-iterable, a typoed attribute) reliably reproduces `[bad-argument-count]`,
/// `[not-iterable]`, and `[missing-attribute]` respectively -- proof this gate can and does
/// detect each code, not just a vacuous pass. With those codes left enabled and the fixture
/// left uncorrupted, this test currently reports zero errors, i.e. none of the three codes is
/// presently reproducible from real (uncorrupted) codegen output for the shapes this fixture
/// covers.
#[test]
fn alef_all_generated_python_package_type_checks_clean_under_pyrefly() {
    if which::which("pyrefly").is_err() {
        return;
    }

    let dir = tempfile::tempdir().expect("tempdir");
    let root = dir.path().canonicalize().unwrap_or_else(|_| dir.path().to_path_buf());
    write_fixture_workspace(&root);
    let _cwd = crate::test_support::CwdGuard::enter(&root);

    let context = DispatchContext {
        config_path: root.join("alef.toml"),
        crate_filter: Vec::new(),
    };

    super::handle(
        Commands::All {
            clean: false,
            clobber_create_once_seeds: false,
            strict: false,
            skip_frb: false,
            skip_snippet_validation: true,
            skip_compile: false,
        },
        &context,
    )
    .expect("alef all must succeed against a plain python fixture");

    let api_py = root.join("packages/python/test_lib/api.py");
    assert!(
        api_py.is_file(),
        "sanity: alef all must have written api.py, got tree under {root:?}"
    );

    let project_dir = find_pyrefly_project_dir(&root);

    let output = std::process::Command::new("pyrefly")
        .arg("check")
        .arg(&project_dir)
        .output()
        .expect("pyrefly check must run");
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        output.status.success(),
        "pyrefly must report zero errors against alef's own generated package \
         (a `bad-return`/`bad-argument-type` here means the public-dataclass boundary fix \
         regressed); pyrefly stdout:\n{stdout}\npyrefly stderr:\n{stderr}"
    );
}