use crate::core::backend::{Backend, GeneratedFile};
use crate::core::config::{Language, ResolvedCrateConfig};
use crate::core::ir::{
ApiSurface, EnumDef, EnumVariant, ErrorDef, ErrorVariant, FieldDef, FunctionDef, MethodDef, ParamDef,
PrimitiveType, ReceiverKind, TypeDef, TypeRef, VersionAnnotation,
};
const NOT_YET_ORDER_INVARIANT: &[(Language, &str)] = &[(
Language::Dart,
"dart's emission loops still iterate api.types/enums/functions/errors in raw Vec order",
)];
fn payload_type(name: &str, field: &str) -> TypeDef {
TypeDef {
name: name.to_string(),
rust_path: format!("test_lib::{name}"),
fields: vec![FieldDef {
name: field.to_string(),
ty: TypeRef::Primitive(PrimitiveType::U32),
..Default::default()
}],
methods: vec![MethodDef {
name: format!("{field}_doubled"),
return_type: TypeRef::Primitive(PrimitiveType::U32),
receiver: Some(ReceiverKind::Ref),
..Default::default()
}],
has_serde: true,
..Default::default()
}
}
fn flat_data_enum(name: &str, tag: Option<&str>, variants: &[(&str, &str)]) -> EnumDef {
EnumDef {
name: name.to_string(),
rust_path: format!("test_lib::{name}"),
variants: variants
.iter()
.map(|(variant_name, field_type)| EnumVariant {
name: (*variant_name).to_string(),
fields: vec![FieldDef {
name: "_0".to_string(),
ty: TypeRef::Named((*field_type).to_string()),
..Default::default()
}],
is_tuple: true,
..Default::default()
})
.collect(),
serde_tag: tag.map(str::to_string),
has_serde: true,
..Default::default()
}
}
fn free_function(name: &str, param_type: &str) -> FunctionDef {
FunctionDef {
name: name.to_string(),
rust_path: format!("test_lib::{name}"),
params: vec![ParamDef {
name: "input".to_string(),
ty: TypeRef::Named(param_type.to_string()),
..Default::default()
}],
return_type: TypeRef::Primitive(PrimitiveType::U32),
..Default::default()
}
}
fn error_def(name: &str, variants: &[&str]) -> ErrorDef {
ErrorDef {
name: name.to_string(),
rust_path: format!("test_lib::{name}"),
original_rust_path: String::new(),
variants: variants
.iter()
.map(|variant| ErrorVariant {
name: (*variant).to_string(),
message_template: Some(format!("{variant} failed")),
..Default::default()
})
.collect(),
doc: String::new(),
methods: Vec::new(),
binding_excluded: false,
binding_exclusion_reason: None,
version: VersionAnnotation::default(),
}
}
fn determinism_api() -> ApiSurface {
ApiSurface {
crate_name: "test-lib".to_string(),
version: "0.1.0".to_string(),
types: vec![
payload_type("PdfMetadata", "pages"),
payload_type("DocxMetadata", "words"),
payload_type("NodeMetadata", "depth"),
payload_type("LeafMetadata", "value"),
payload_type("AnnotationDataA", "note"),
payload_type("AnnotationDataB", "tag"),
],
enums: vec![
flat_data_enum(
"FormatMetadata",
Some("format_type"),
&[("Pdf", "PdfMetadata"), ("Docx", "DocxMetadata")],
),
flat_data_enum(
"NodeKind",
None,
&[("Branch", "NodeMetadata"), ("Leaf", "LeafMetadata")],
),
flat_data_enum(
"AnnotationKind",
None,
&[("Comment", "AnnotationDataA"), ("Highlight", "AnnotationDataB")],
),
],
functions: vec![
free_function("summarize_pdf", "PdfMetadata"),
free_function("summarize_docx", "DocxMetadata"),
free_function("summarize_node", "NodeMetadata"),
],
errors: vec![
error_def("ParseError", &["Malformed", "Truncated"]),
error_def("IoError", &["NotFound", "Denied"]),
],
..Default::default()
}
}
fn every_language_config() -> ResolvedCrateConfig {
let cfg: crate::core::config::new_config::NewAlefConfig = toml::from_str(
r#"
[workspace]
languages = [
"python", "node", "ruby", "php", "elixir", "wasm", "ffi", "go", "java", "csharp",
"r", "kotlin", "kotlin_android", "swift", "dart", "gleam", "zig", "jni",
]
[[crates]]
name = "test-lib"
sources = ["src/lib.rs"]
[crates.kotlin_android]
package = "dev.sample_crate"
namespace = "dev.sample_crate"
"#,
)
.expect("every-language test config must parse");
cfg.resolve()
.expect("every-language test config must resolve")
.remove(0)
}
fn generated_files_sorted(
backend: &dyn Backend,
api: &ApiSurface,
config: &ResolvedCrateConfig,
) -> Vec<(String, String)> {
let mut files: Vec<(String, String)> = Vec::new();
let produced: [anyhow::Result<Vec<GeneratedFile>>; 4] = [
backend.generate_bindings(api, config),
backend.generate_public_api(api, config),
backend.generate_type_stubs(api, config),
backend.generate_service_api(api, config),
];
for result in produced {
let generated = result.unwrap_or_else(|error| panic!("{} generation failed: {error}", backend.name()));
files.extend(
generated
.into_iter()
.map(|file| (file.path.to_string_lossy().into_owned(), file.content)),
);
}
files.sort_by(|a, b| a.0.cmp(&b.0));
files
}
fn describe_first_difference(left: &[(String, String)], right: &[(String, String)]) -> Option<String> {
let left_paths: Vec<&str> = left.iter().map(|(path, _)| path.as_str()).collect();
let right_paths: Vec<&str> = right.iter().map(|(path, _)| path.as_str()).collect();
if left_paths != right_paths {
return Some(format!(
"generated file sets differ:\n forward: {left_paths:?}\n reversed: {right_paths:?}"
));
}
let (path, left_content, right_content) = left
.iter()
.zip(right)
.find(|((_, left_content), (_, right_content))| left_content != right_content)
.map(|((path, left_content), (_, right_content))| (path, left_content, right_content))?;
let left_lines: Vec<&str> = left_content.lines().collect();
let right_lines: Vec<&str> = right_content.lines().collect();
let first_diff = left_lines
.iter()
.zip(&right_lines)
.position(|(a, b)| a != b)
.unwrap_or_else(|| left_lines.len().min(right_lines.len()));
let window = first_diff.saturating_sub(2)..(first_diff + 5);
let render = |lines: &[&str]| {
lines
.iter()
.enumerate()
.filter(|(index, _)| window.contains(index))
.map(|(index, line)| format!("{:>5} | {line}", index + 1))
.collect::<Vec<_>>()
.join("\n")
};
Some(format!(
"file: {path}\nfirst difference at line {}\n--- forward ---\n{}\n--- reversed ---\n{}",
first_diff + 1,
render(&left_lines),
render(&right_lines),
))
}
fn reversed(api: &ApiSurface) -> ApiSurface {
let mut reversed = api.clone();
reversed.types.reverse();
reversed.enums.reverse();
reversed.functions.reverse();
reversed.errors.reverse();
reversed
}
fn describe_order_difference(
backend: &dyn Backend,
forward: &ApiSurface,
backwards: &ApiSurface,
config: &ResolvedCrateConfig,
) -> Option<String> {
let forward_files = generated_files_sorted(backend, forward, config);
let reversed_files = generated_files_sorted(backend, backwards, config);
describe_first_difference(&forward_files, &reversed_files)
}
fn covered_languages() -> Vec<Language> {
Language::ALL
.iter()
.copied()
.filter(|lang| crate::cli::registry::try_get_backend(*lang).is_some())
.filter(|lang| !NOT_YET_ORDER_INVARIANT.iter().any(|(excluded, _)| excluded == lang))
.collect()
}
#[test]
fn every_covered_backend_is_invariant_to_ir_collection_order() {
let _cwd_lock = crate::test_support::CWD_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let config = every_language_config();
let forward = determinism_api();
let backwards = reversed(&forward);
let mut failures: Vec<String> = Vec::new();
for language in covered_languages() {
let backend = crate::cli::registry::try_get_backend(language).expect("filtered to registered backends");
if let Some(difference) = describe_order_difference(backend.as_ref(), &forward, &backwards, &config) {
failures.push(format!("=== {language:?} ===\n{difference}"));
}
}
assert!(
failures.is_empty(),
"reversing api.types/enums/functions/errors must not change any generated file. A diff \
here means the backend leaks IR Vec ordering into emitted text, so two `alef` runs over \
an unchanged tree can disagree.\n\n{}",
failures.join("\n\n")
);
}
#[test]
fn excluded_backends_are_still_actually_failing() {
let _cwd_lock = crate::test_support::CWD_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let config = every_language_config();
let forward = determinism_api();
let backwards = reversed(&forward);
for (language, reason) in NOT_YET_ORDER_INVARIANT {
let backend = crate::cli::registry::try_get_backend(*language).expect("excluded languages have backends");
let forward_files = generated_files_sorted(backend.as_ref(), &forward, &config);
let reversed_files = generated_files_sorted(backend.as_ref(), &backwards, &config);
assert!(
describe_first_difference(&forward_files, &reversed_files).is_some(),
"{language:?} is now order-invariant, so it must be removed from \
NOT_YET_ORDER_INVARIANT (recorded reason: {reason})"
);
}
}