Skip to main content

alef/backends/kotlin_android/
mod.rs

1//! Kotlin/Android (AAR library) backend for alef.
2//!
3//! Emits a self-contained Android library Gradle project with a pure-Kotlin
4//! JNI layout — no bundled Java facade. All binding code lives under
5//! `src/main/kotlin/`.
6//!
7//! - `build.gradle.kts` with the Android Gradle Plugin and `maven-publish`
8//! - `settings.gradle.kts` with `pluginManagement` so plugins resolve from a
9//!   clean checkout
10//! - `src/main/AndroidManifest.xml`
11//! - `src/main/kotlin/<pkg>/<Module>Bridge.kt` — a Kotlin `object` with
12//!   `external fun` JNI declarations and `init { System.loadLibrary(...) }`
13//! - `src/main/kotlin/<pkg>/DefaultClient.kt` — coroutine-friendly client
14//!   class holding a `Long` handle when the API has methodful types
15//! - `src/main/jniLibs/<abi>/.gitkeep` for each configured ABI (default
16//!   `arm64-v8a`, `x86_64`)
17//! - `consumer-rules.pro`, `proguard-rules.pro`, `.gitignore`
18//!
19//! Forces `KotlinFfiStyle::Jni` regardless of the workspace configuration.
20//! Consumers must ship a `<crate>-jni` Rust crate exporting
21//! `Java_<package>_<Module>Bridge_native<Method>` symbols per JNI spec §5.11.3
22//! and link `lib<crate>_jni.so` into `jniLibs/<abi>/`.
23//!
24//! Distinct from the JVM-only `alef-backend-kotlin` backend.
25
26pub 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
49/// Collect all type names excluded for the `kotlin_android` language from both
50/// the per-language `[crates.kotlin_android].exclude_types` list and the shared
51/// `[crates.ffi].exclude_types` list (mirroring the Java backend pattern).
52fn 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
64/// Return true when `ty` references any type name in `exclude_types`.
65fn references_excluded_type(ty: &TypeRef, exclude_types: &HashSet<String>) -> bool {
66    exclude_types.iter().any(|name| ty.references_named(name))
67}
68
69/// Return true when any parameter type or the return type references an
70/// excluded type name.
71fn 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(&param.ty, exclude_types))
80}
81
82/// Build a filtered copy of `api` with all excluded types (and any
83/// fields / methods / functions that reference them) removed.
84fn 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
110/// Default output root when the workspace does not configure
111/// `[crates.output].kotlin_android` explicitly.
112const DEFAULT_AAR_ROOT: &str = "packages/kotlin-android";
113
114/// Segment used by Gradle's Android source-set layout to separate the
115/// project root from the Kotlin source destination
116/// (`<project_root>/src/main/kotlin/<dotted_package>/`).
117const KOTLIN_SOURCE_INFIX: &str = "src/main/kotlin";
118
119/// Backend implementation for the Kotlin/Android target.
120#[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        // Always force JNI mode: the Android AAR does not ship a Java/Panama facade.
147        let config = config.clone().with_kotlin_ffi_style(KotlinFfiStyle::Jni);
148        let config = &config;
149
150        // Apply per-language exclude_types filter before any emission.
151        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/// Resolved Android-AAR project paths.
223///
224/// `[crates.output].kotlin_android` semantically names the **Kotlin source
225/// destination** — the directory that holds `<Module>.kt` and any Kotlin
226/// facade files — because the Gradle Android source-set layout pins it to
227/// `<project_root>/src/main/kotlin/<dotted_package_as_path>/`. The project
228/// root (where `build.gradle.kts`, `AndroidManifest.xml`, `jniLibs/`, etc.
229/// live) is derived by stripping that suffix.
230///
231/// When no output path is configured, the layout falls back to the legacy
232/// default rooted at [`DEFAULT_AAR_ROOT`] and the Kotlin source dir is
233/// computed from the package layout.
234#[derive(Debug, Clone)]
235struct ProjectLayout {
236    /// Project root — where build metadata files (build.gradle.kts,
237    /// settings.gradle.kts, AndroidManifest.xml, consumer/proguard rules,
238    /// .gitignore, jniLibs/, src/main/java/) are emitted.
239    package_root: PathBuf,
240    /// Kotlin source destination — where `<Module>.kt` and Kotlin facade
241    /// files are emitted.
242    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    /// Interpret a configured `[crates.output].kotlin_android` path.
255    ///
256    /// When the path ends with the Gradle Android source-set suffix
257    /// `src/main/kotlin/<dotted_package_as_path>/`, the configured path
258    /// is the Kotlin source destination and the project root is the
259    /// prefix before that suffix.
260    ///
261    /// Otherwise, fall back to treating the configured path as the
262    /// project root (legacy semantics — preserves behaviour for
263    /// workspaces and the workspace template default that point
264    /// `kotlin_android` at the project root directly).
265    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    /// Compose a layout rooted at `package_root` with the Kotlin source
277    /// destination derived from the Gradle Android source-set layout.
278    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
286/// Walk `configured` backwards to strip the `src/main/kotlin/<pkg_path>`
287/// suffix. Returns the project-root prefix on a match, or `None` when the
288/// suffix is absent.
289fn 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
323/// Apply post-processing fixes to generated Kotlin files using the shared normalizer.
324/// Fixes integer-like float literals that lack decimal points (e.g., "32" -> "32.0").
325///
326/// Uses `Path::extension` rather than `Path::ends_with`: the latter performs
327/// component-wise matching, so `ends_with(".kt")` is always `false` for a file
328/// named `Foo.kt` (the final component is `Foo.kt`, not `.kt`).
329fn 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}