alef/backends/kotlin_android/
mod.rs1pub mod gen_bindings;
27pub mod gen_build_gradle;
28pub mod gen_editorconfig;
29pub mod gen_gitignore;
30pub mod gen_gradle_properties;
31pub mod gen_jni_skeleton;
32pub mod gen_manifest;
33pub mod gen_proguard;
34pub mod gen_settings_gradle;
35pub mod naming;
36pub mod template_env;
37pub mod trait_bridge;
38
39use std::collections::HashSet;
40use std::path::{Path, PathBuf};
41
42use crate::backends::kotlin::literal_normalizer;
43use crate::core::backend::{Backend, BuildConfig, BuildDependency, Capabilities, GeneratedFile};
44use crate::core::config::{KotlinFfiStyle, Language, ResolvedCrateConfig};
45use crate::core::ir::{ApiSurface, TypeRef};
46
47use crate::backends::kotlin_android::naming::package_path;
48
49fn effective_exclude_types(config: &ResolvedCrateConfig) -> HashSet<String> {
53 let mut exclude_types: HashSet<String> = config
54 .ffi
55 .as_ref()
56 .map(|ffi| ffi.exclude_types.iter().cloned().collect())
57 .unwrap_or_default();
58 if let Some(ka) = &config.kotlin_android {
59 exclude_types.extend(ka.exclude_types.iter().cloned());
60 }
61 exclude_types
62}
63
64fn references_excluded_type(ty: &TypeRef, exclude_types: &HashSet<String>) -> bool {
66 exclude_types.iter().any(|name| ty.references_named(name))
67}
68
69fn signature_references_excluded_type(
72 params: &[crate::core::ir::ParamDef],
73 return_type: &TypeRef,
74 exclude_types: &HashSet<String>,
75) -> bool {
76 references_excluded_type(return_type, exclude_types)
77 || params
78 .iter()
79 .any(|param| references_excluded_type(¶m.ty, exclude_types))
80}
81
82fn api_without_excluded_types(api: &ApiSurface, exclude_types: &HashSet<String>) -> ApiSurface {
85 let mut filtered = api.clone();
86 filtered.types.retain(|typ| !exclude_types.contains(&typ.name));
87 for typ in &mut filtered.types {
88 typ.fields
89 .retain(|field| !references_excluded_type(&field.ty, exclude_types));
90 typ.methods
91 .retain(|method| !signature_references_excluded_type(&method.params, &method.return_type, exclude_types));
92 }
93 filtered
94 .enums
95 .retain(|enum_def| !exclude_types.contains(&enum_def.name));
96 for enum_def in &mut filtered.enums {
97 for variant in &mut enum_def.variants {
98 variant
99 .fields
100 .retain(|field| !references_excluded_type(&field.ty, exclude_types));
101 }
102 }
103 filtered
104 .functions
105 .retain(|func| !signature_references_excluded_type(&func.params, &func.return_type, exclude_types));
106 filtered.errors.retain(|error| !exclude_types.contains(&error.name));
107 filtered
108}
109
110const DEFAULT_AAR_ROOT: &str = "packages/kotlin-android";
113
114const KOTLIN_SOURCE_INFIX: &str = "src/main/kotlin";
118
119#[derive(Debug, Default, Clone, Copy)]
121pub struct KotlinAndroidBackend;
122
123impl Backend for KotlinAndroidBackend {
124 fn name(&self) -> &str {
125 "kotlin_android"
126 }
127
128 fn language(&self) -> Language {
129 Language::KotlinAndroid
130 }
131
132 fn capabilities(&self) -> Capabilities {
133 Capabilities {
134 supports_async: true,
135 supports_classes: true,
136 supports_enums: true,
137 supports_option: true,
138 supports_result: true,
139 supports_callbacks: false,
140 supports_streaming: true,
141 supports_service_api: false,
142 }
143 }
144
145 fn generate_bindings(&self, api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
146 let config = config.clone().with_kotlin_ffi_style(KotlinFfiStyle::Jni);
148 let config = &config;
149
150 let exclude_types = effective_exclude_types(config);
152 let filtered_api;
153 let api = if exclude_types.is_empty() {
154 api
155 } else {
156 filtered_api = api_without_excluded_types(api, &exclude_types);
157 &filtered_api
158 };
159
160 let layout = ProjectLayout::resolve(config);
161
162 let mut files = vec![
163 GeneratedFile {
164 path: layout.package_root.join("build.gradle.kts"),
165 content: gen_build_gradle::emit(config),
166 generated_header: false,
167 },
168 GeneratedFile {
169 path: layout.package_root.join("gradle.properties"),
170 content: gen_gradle_properties::emit(),
171 generated_header: false,
172 },
173 GeneratedFile {
174 path: layout.package_root.join("settings.gradle.kts"),
175 content: gen_settings_gradle::emit(config),
176 generated_header: false,
177 },
178 GeneratedFile {
179 path: layout.package_root.join("src/main/AndroidManifest.xml"),
180 content: gen_manifest::emit(config),
181 generated_header: false,
182 },
183 GeneratedFile {
184 path: layout.package_root.join("consumer-rules.pro"),
185 content: gen_proguard::emit_consumer(config),
186 generated_header: false,
187 },
188 GeneratedFile {
189 path: layout.package_root.join("proguard-rules.pro"),
190 content: gen_proguard::emit_module(),
191 generated_header: false,
192 },
193 GeneratedFile {
194 path: layout.package_root.join(".gitignore"),
195 content: gen_gitignore::emit(),
196 generated_header: false,
197 },
198 GeneratedFile {
199 path: layout.package_root.join(".editorconfig"),
200 content: gen_editorconfig::emit(),
201 generated_header: false,
202 },
203 ];
204
205 files.extend(gen_jni_skeleton::emit(config, &layout.package_root));
206 files.extend(gen_bindings::emit(api, config, &layout.kotlin_source_dir));
207
208 apply_kotlin_post_processing(&mut files);
209 Ok(files)
210 }
211
212 fn build_config(&self) -> Option<BuildConfig> {
213 Some(BuildConfig {
214 tool: "gradle",
215 crate_suffix: "",
216 build_dep: BuildDependency::Ffi,
217 post_build: vec![],
218 })
219 }
220}
221
222#[derive(Debug, Clone)]
235struct ProjectLayout {
236 package_root: PathBuf,
240 kotlin_source_dir: PathBuf,
243}
244
245impl ProjectLayout {
246 fn resolve(config: &ResolvedCrateConfig) -> Self {
247 let pkg_path = package_path(config);
248 match config.output_for("kotlin_android") {
249 Some(configured) => Self::from_configured(configured, &pkg_path),
250 None => Self::rooted_at(&PathBuf::from(DEFAULT_AAR_ROOT), &pkg_path),
251 }
252 }
253
254 fn from_configured(configured: &Path, pkg_path: &str) -> Self {
266 if let Some(package_root) = strip_kotlin_source_suffix(configured, pkg_path) {
267 Self {
268 package_root,
269 kotlin_source_dir: configured.to_path_buf(),
270 }
271 } else {
272 Self::rooted_at(configured, pkg_path)
273 }
274 }
275
276 fn rooted_at(package_root: &Path, pkg_path: &str) -> Self {
279 Self {
280 package_root: package_root.to_path_buf(),
281 kotlin_source_dir: package_root.join(KOTLIN_SOURCE_INFIX).join(pkg_path),
282 }
283 }
284}
285
286fn strip_kotlin_source_suffix(configured: &Path, pkg_path: &str) -> Option<PathBuf> {
290 let pkg_segment = PathBuf::from(pkg_path);
291 let pkg_components: Vec<_> = pkg_segment.components().collect();
292 let kotlin_components: Vec<_> = Path::new(KOTLIN_SOURCE_INFIX).components().collect();
293 let configured_components: Vec<_> = configured.components().collect();
294
295 let suffix_len = kotlin_components.len() + pkg_components.len();
296 if configured_components.len() < suffix_len {
297 return None;
298 }
299 let tail_start = configured_components.len() - suffix_len;
300 let tail = &configured_components[tail_start..];
301 let kotlin_matches = tail[..kotlin_components.len()]
302 .iter()
303 .zip(kotlin_components.iter())
304 .all(|(a, b)| a == b);
305 let pkg_matches = tail[kotlin_components.len()..]
306 .iter()
307 .zip(pkg_components.iter())
308 .all(|(a, b)| a == b);
309 if !(kotlin_matches && pkg_matches) {
310 return None;
311 }
312 let head = &configured_components[..tail_start];
313 if head.is_empty() {
314 return Some(PathBuf::from("."));
315 }
316 let mut root = PathBuf::new();
317 for comp in head {
318 root.push(comp);
319 }
320 Some(root)
321}
322
323fn apply_kotlin_post_processing(files: &mut [GeneratedFile]) {
330 for file in files {
331 if file.path.extension().is_some_and(|ext| ext == "kt") {
332 file.content = literal_normalizer::fix_float_literals(&file.content);
333 }
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn strip_kotlin_source_suffix_extracts_project_root() {
343 let configured = Path::new("packages/kotlin-android/src/main/kotlin/dev/sample_crate/sample_crawler/android");
344 let root = strip_kotlin_source_suffix(configured, "dev/sample_crate/sample_crawler/android");
345 assert_eq!(root, Some(PathBuf::from("packages/kotlin-android")));
346 }
347
348 #[test]
349 fn strip_kotlin_source_suffix_returns_none_when_suffix_missing() {
350 let configured = Path::new("packages/kotlin-android");
351 assert_eq!(strip_kotlin_source_suffix(configured, "dev/sample_crate"), None);
352 }
353
354 #[test]
355 fn from_configured_derives_package_root_when_path_targets_kotlin_source() {
356 let configured = Path::new("packages/kotlin-android/src/main/kotlin/dev/sample_crate/sample_crawler/android");
357 let layout = ProjectLayout::from_configured(configured, "dev/sample_crate/sample_crawler/android");
358 assert_eq!(layout.package_root, PathBuf::from("packages/kotlin-android"));
359 assert_eq!(layout.kotlin_source_dir, PathBuf::from(configured));
360 }
361
362 #[test]
363 fn from_configured_falls_back_to_legacy_when_path_is_project_root() {
364 let configured = Path::new("packages/kotlin-android");
365 let layout = ProjectLayout::from_configured(configured, "dev/sample_crate");
366 assert_eq!(layout.package_root, PathBuf::from("packages/kotlin-android"));
367 assert_eq!(
368 layout.kotlin_source_dir,
369 PathBuf::from("packages/kotlin-android/src/main/kotlin/dev/sample_crate")
370 );
371 }
372
373 #[test]
374 fn apply_kotlin_post_processing_fixes_double_literals_in_named_kt_files() {
375 let mut files = vec![GeneratedFile {
376 path: PathBuf::from("src/main/kotlin/dev/sample_crate/OcrQualityThresholds.kt"),
377 content: " val minNonWhitespacePerPage: Double = 32,\n".to_string(),
378 generated_header: true,
379 }];
380 apply_kotlin_post_processing(&mut files);
381 assert_eq!(files[0].content, " val minNonWhitespacePerPage: Double = 32.0,\n");
382 }
383
384 #[test]
385 fn apply_kotlin_post_processing_skips_non_kotlin_files() {
386 let mut files = vec![GeneratedFile {
387 path: PathBuf::from("build.gradle.kts"),
388 content: "ext = 32".to_string(),
389 generated_header: false,
390 }];
391 apply_kotlin_post_processing(&mut files);
392 assert_eq!(files[0].content, "ext = 32");
393 }
394}