diff --git a/CHANGELOG.md b/CHANGELOG.md index b5afe5c..9350c97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.4.2 + +- Added `nullness_annotations` config option to emit JSpecify `@NullMarked` and + `@Nullable` annotations in generated code. When enabled, Rust `Option` maps to + `@Nullable T` and all other types are non-null by default. Requires + `org.jspecify:jspecify` on the compile classpath. + ## 0.4.1 - fix resolving callback trait implementations declared in submodules of the current crate. Previously, traits like `my_crate::metrics::MetricsRecorder` failed with "no interface with module_path" during code generation. diff --git a/Cargo.lock b/Cargo.lock index ec50a6d..55e201d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1597,7 +1597,7 @@ dependencies = [ [[package]] name = "uniffi-bindgen-java" -version = "0.4.1" +version = "0.4.2" dependencies = [ "anyhow", "askama", diff --git a/Cargo.toml b/Cargo.toml index a7c32ad..42c5e9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uniffi-bindgen-java" -version = "0.4.1" +version = "0.4.2" authors = ["IronCore Labs "] readme = "README.md" license = "MPL-2.0" diff --git a/README.md b/README.md index 5f53aaa..423f26a 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ The generated Java can be configured using a `uniffi.toml` configuration file. | `custom_types` | | A map which controls how custom types are exposed to Java. See the [custom types section of the UniFFI manual](https://mozilla.github.io/uniffi-rs/latest/udl/custom_types.html#custom-types-in-the-bindings-code) | | `external_packages` | | A map of packages to be used for the specified external crates. The key is the Rust crate name, the value is the Java package which will be used referring to types in that crate. See the [external types section of the manual](https://mozilla.github.io/uniffi-rs/latest/udl/ext_types_external.html#kotlin) | | `rename` | | A map to rename types, functions, methods, and their members in the generated Java bindings. See the [renaming section](https://mozilla.github.io/uniffi-rs/latest/renaming.html). | +| `nullness_annotations` | `false` | Generate [JSpecify](https://jspecify.dev/) nullness annotations. Rust `Option` maps to `@Nullable T`; all other types are non-null by default via `@NullMarked`. Requires `org.jspecify:jspecify` on the compile classpath. See [Nullness Annotations](#nullness-annotations). | | `android` | `false` | Generate [PanamaPort](https://github.com/vova7878/PanamaPort)-compatible code for Android. Replaces `java.lang.foreign.*` with `com.v7878.foreign.*` and `java.lang.invoke.VarHandle` with `com.v7878.invoke.VarHandle`. Requires PanamaPort `io.github.vova7878.panama:Core` as a runtime dependency and Android API 26+. | | `omit_checksums` | `false` | Whether to omit checking the library checksums as the library is initialized. Changing this will shoot yourself in the foot if you mixup your build pipeline in any way, but might speed up initialization. | @@ -174,6 +175,63 @@ Where `` is the UniFFI namespace of your component (e.g., `arithmetic You can also pass a plain library name as the override, in which case it behaves like `System.loadLibrary()` and still requires the library to be on `java.library.path`. +## Nullness Annotations + +Generated bindings can include [JSpecify](https://jspecify.dev/) nullness annotations so that +Kotlin consumers get proper nullable/non-null types and Java consumers get IDE and static +analysis support. + +Enable in `uniffi.toml`: + +```toml +[bindings.java] +nullness_annotations = true +``` + +When enabled: +- A `package-info.java` is generated with `@NullMarked`, making all types non-null by default +- Rust `Option` types are annotated with `@Nullable`, including inside generic type + arguments (e.g., `Map` for `HashMap>`) +- All non-optional types (primitives, strings, records, objects, enums) are non-null + +### Build Setup + +JSpecify must be on the compile classpath when compiling the generated Java source. + +**Gradle:** +```kotlin +// Use `api` if publishing a library so Kotlin/Java consumers benefit automatically. +// Use `compileOnly` if the bindings are only used within this project. +dependencies { + api("org.jspecify:jspecify:1.0.0") +} +``` + +**Maven:** +```xml + + + org.jspecify + jspecify + 1.0.0 + +``` + +There is no runtime dependency — the JVM ignores annotation classes that are not present at +runtime. + +### Kotlin Interop + +Without nullness annotations, Kotlin sees all Java types from the generated bindings as +**platform types** (`String!`), which bypass null-safety checks. With annotations enabled, +Kotlin correctly maps: + +- Non-optional types → non-null (`String`, `MyRecord`) +- `Option` types → nullable (`String?`, `MyRecord?`) + +This requires JSpecify to be on Kotlin's compile classpath (automatic if declared with `api` +scope). + ## Notes - failures in CompletableFutures will cause them to `completeExceptionally`. The error that caused the failure can be checked with `e.getCause()`. When implementing an async Rust trait in Java, you'll need to `completeExceptionally` instead of throwing. See `TestFixtureFutures.java` for an example trait implementation with errors. diff --git a/src/gen_java/compounds.rs b/src/gen_java/compounds.rs index 68b96c8..a9c1ed9 100644 --- a/src/gen_java/compounds.rs +++ b/src/gen_java/compounds.rs @@ -22,10 +22,14 @@ impl OptionalCodeType { impl CodeType for OptionalCodeType { fn type_label(&self, ci: &ComponentInterface, config: &Config) -> String { - super::JavaCodeOracle + let inner = super::JavaCodeOracle .find(self.inner()) - .type_label(ci, config) - .to_string() + .type_label(ci, config); + if config.nullness_annotations() { + super::nullable_type_label(&inner) + } else { + inner + } } fn canonical_name(&self) -> String { diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index 1db1616..23dec63 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -21,6 +21,32 @@ mod primitives; mod record; mod variant; +/// Insert a JSpecify `@Nullable` TYPE_USE annotation at the correct position +/// in a (possibly fully-qualified) Java type name. +/// +/// Per JLS §9.7.4, TYPE_USE annotations must appear directly before the simple +/// name of the type, not before the package/enclosing-class qualifier: +/// - `java.lang.String` → `java.lang.@Nullable String` +/// - `UniffiCleaner.Cleanable` → `UniffiCleaner.@Nullable Cleanable` +/// - `String` → `@Nullable String` +/// - `java.util.Map` → `java.util.@Nullable Map` +pub(crate) fn nullable_type_label(type_label: &str) -> String { + const ANNOTATION: &str = "@org.jspecify.annotations.Nullable "; + // Only look at the portion before any generic '<' to find the right '.' + let before_generics = type_label.find('<').unwrap_or(type_label.len()); + let prefix = &type_label[..before_generics]; + if let Some(dot_pos) = prefix.rfind('.') { + format!( + "{}{}{}", + &type_label[..dot_pos + 1], + ANNOTATION, + &type_label[dot_pos + 1..] + ) + } else { + format!("{}{}", ANNOTATION, type_label) + } +} + pub fn potentially_add_external_package( config: &Config, ci: &ComponentInterface, @@ -161,6 +187,8 @@ pub struct Config { pub(super) external_packages: HashMap, #[serde(default)] android: bool, + #[serde(default)] + nullness_annotations: bool, /// Renames for types, fields, methods, variants, and arguments. /// Uses dot notation: "OldRecord" = "NewRecord", "OldRecord.field" = "new_field" #[serde(default)] @@ -179,6 +207,11 @@ impl Config { pub fn android(&self) -> bool { self.android } + + /// Whether to generate JSpecify `@NullMarked` and `@Nullable` annotations. + pub fn nullness_annotations(&self) -> bool { + self.nullness_annotations + } } impl Config { @@ -808,7 +841,12 @@ mod filters { ) -> anyhow::Result { match ty { Type::Optional { inner_type } => { - Ok(fully_qualified_type_label(inner_type, ci, config)?.to_string()) + let inner = fully_qualified_type_label(inner_type, ci, config)?; + if config.nullness_annotations() { + Ok(nullable_type_label(&inner)) + } else { + Ok(inner) + } } Type::Sequence { inner_type } => match inner_type.as_ref() { Type::Int16 | Type::UInt16 => Ok("short[]".to_string()), @@ -1450,9 +1488,10 @@ mod tests { use super::*; use uniffi_bindgen::interface::ComponentInterface; use uniffi_meta::{ - CallbackInterfaceMetadata, EnumMetadata, EnumShape, FnMetadata, FnParamMetadata, Metadata, - MetadataGroup, NamespaceMetadata, ObjectImpl, ObjectMetadata, ObjectTraitImplMetadata, - TraitMethodMetadata, Type, VariantMetadata, + CallbackInterfaceMetadata, EnumMetadata, EnumShape, FieldMetadata, FnMetadata, + FnParamMetadata, Metadata, MetadataGroup, MethodMetadata, NamespaceMetadata, ObjectImpl, + ObjectMetadata, ObjectTraitImplMetadata, RecordMetadata, TraitMethodMetadata, Type, + VariantMetadata, }; #[test] @@ -1900,6 +1939,609 @@ mod tests { ); } + fn nullness_config() -> Config { + toml::from_str("nullness_annotations = true").unwrap() + } + + #[test] + fn nullable_type_label_fully_qualified() { + assert_eq!( + super::nullable_type_label("java.lang.String"), + "java.lang.@org.jspecify.annotations.Nullable String" + ); + } + + #[test] + fn nullable_type_label_unqualified() { + assert_eq!( + super::nullable_type_label("String"), + "@org.jspecify.annotations.Nullable String" + ); + } + + #[test] + fn nullable_type_label_nested_class() { + assert_eq!( + super::nullable_type_label("UniffiCleaner.Cleanable"), + "UniffiCleaner.@org.jspecify.annotations.Nullable Cleanable" + ); + } + + #[test] + fn nullable_type_label_generic_type() { + assert_eq!( + super::nullable_type_label("java.util.Map"), + "java.util.@org.jspecify.annotations.Nullable Map" + ); + } + + #[test] + fn nullable_type_label_generic_with_nullable_inner() { + // Simulates Optional>>: the inner Optional + // is already annotated, then the outer Optional annotates the Map itself. + assert_eq!( + super::nullable_type_label( + "java.util.Map" + ), + "java.util.@org.jspecify.annotations.Nullable Map" + ); + } + + #[test] + fn nullable_type_label_generic_unqualified() { + assert_eq!( + super::nullable_type_label("CompletableFuture"), + "@org.jspecify.annotations.Nullable CompletableFuture" + ); + } + + fn test_group() -> MetadataGroup { + MetadataGroup { + namespace: NamespaceMetadata { + crate_name: "test".to_string(), + name: "test".to_string(), + }, + namespace_docstring: None, + items: Default::default(), + } + } + + #[test] + fn nullness_annotations_disabled_by_default() { + let mut group = test_group(); + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "maybe_string".to_string(), + is_async: false, + inputs: vec![FnParamMetadata { + name: "input".to_string(), + ty: Type::String, + by_ref: false, + optional: false, + default: None, + }], + return_type: Some(Type::Optional { + inner_type: Box::new(Type::String), + }), + throws: None, + checksum: None, + docstring: None, + })); + let ci = ComponentInterface::from_metadata(group).unwrap(); + let bindings = generate_bindings(&Config::default(), &ci).unwrap(); + assert!( + !bindings.contains("@org.jspecify.annotations.NullMarked"), + "should not contain @NullMarked when disabled" + ); + assert!( + !bindings.contains("@org.jspecify.annotations.Nullable"), + "should not contain @Nullable when disabled" + ); + } + + #[test] + fn nullness_function_with_optional_param_and_return() { + let mut group = test_group(); + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "foo".to_string(), + is_async: false, + inputs: vec![ + FnParamMetadata { + name: "required".to_string(), + ty: Type::String, + by_ref: false, + optional: false, + default: None, + }, + FnParamMetadata { + name: "optional".to_string(), + ty: Type::Optional { + inner_type: Box::new(Type::String), + }, + by_ref: false, + optional: false, + default: None, + }, + ], + return_type: Some(Type::Optional { + inner_type: Box::new(Type::Int32), + }), + throws: None, + checksum: None, + docstring: None, + })); + let ci = ComponentInterface::from_metadata(group).unwrap(); + let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); + assert!( + bindings.contains("java.lang.@org.jspecify.annotations.Nullable Integer"), + "return type should be @Nullable Integer" + ); + assert!( + bindings.contains("java.lang.@org.jspecify.annotations.Nullable String optional"), + "optional param should be @Nullable String" + ); + // The required param should NOT have @Nullable + assert!( + bindings.contains( + "java.lang.String required, java.lang.@org.jspecify.annotations.Nullable" + ), + "required param should not have @Nullable" + ); + } + + #[test] + fn nullness_record_with_optional_field() { + let mut group = test_group(); + group.add_item(Metadata::Record(RecordMetadata { + module_path: "test".to_string(), + name: "Person".to_string(), + remote: false, + fields: vec![ + FieldMetadata { + name: "name".to_string(), + ty: Type::String, + default: None, + docstring: None, + }, + FieldMetadata { + name: "nickname".to_string(), + ty: Type::Optional { + inner_type: Box::new(Type::String), + }, + default: None, + docstring: None, + }, + ], + docstring: None, + })); + // Need a function to make the record reachable + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "get_person".to_string(), + is_async: false, + inputs: vec![], + return_type: Some(Type::Record { + module_path: "test".to_string(), + name: "Person".to_string(), + }), + throws: None, + checksum: None, + docstring: None, + })); + let ci = ComponentInterface::from_metadata(group).unwrap(); + let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); + assert!( + bindings + .contains("private java.lang.@org.jspecify.annotations.Nullable String nickname"), + "optional field should have @Nullable:\n{}", + bindings + .lines() + .filter(|l| l.contains("nickname")) + .collect::>() + .join("\n") + ); + // Non-optional field should NOT have @Nullable + assert!( + !bindings.contains("java.lang.@org.jspecify.annotations.Nullable String name"), + "non-optional field should not have @Nullable" + ); + } + + #[test] + fn nullness_immutable_record_with_optional_field() { + let mut group = test_group(); + group.add_item(Metadata::Record(RecordMetadata { + module_path: "test".to_string(), + name: "Person".to_string(), + remote: false, + fields: vec![ + FieldMetadata { + name: "name".to_string(), + ty: Type::String, + default: None, + docstring: None, + }, + FieldMetadata { + name: "nickname".to_string(), + ty: Type::Optional { + inner_type: Box::new(Type::String), + }, + default: None, + docstring: None, + }, + ], + docstring: None, + })); + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "get_person".to_string(), + is_async: false, + inputs: vec![], + return_type: Some(Type::Record { + module_path: "test".to_string(), + name: "Person".to_string(), + }), + throws: None, + checksum: None, + docstring: None, + })); + let ci = ComponentInterface::from_metadata(group).unwrap(); + let config: Config = + toml::from_str("nullness_annotations = true\ngenerate_immutable_records = true") + .unwrap(); + let bindings = generate_bindings(&config, &ci).unwrap(); + assert!( + bindings.contains("java.lang.@org.jspecify.annotations.Nullable String nickname"), + "immutable record should have @Nullable on optional component:\n{}", + bindings + .lines() + .filter(|l| l.contains("nickname")) + .collect::>() + .join("\n") + ); + assert!( + !bindings.contains("java.lang.@org.jspecify.annotations.Nullable String name"), + "non-optional component should not have @Nullable" + ); + } + + #[test] + fn nullness_object_method_with_optional_param() { + let mut group = test_group(); + group.add_item(Metadata::Object(ObjectMetadata { + module_path: "test".to_string(), + name: "MyObj".to_string(), + remote: false, + imp: ObjectImpl::Struct, + docstring: None, + })); + group.add_item(Metadata::Method(MethodMetadata { + module_path: "test".to_string(), + self_name: "MyObj".to_string(), + name: "do_thing".to_string(), + is_async: false, + inputs: vec![FnParamMetadata { + name: "input".to_string(), + ty: Type::Optional { + inner_type: Box::new(Type::String), + }, + by_ref: false, + optional: false, + default: None, + }], + return_type: None, + throws: None, + takes_self_by_arc: false, + checksum: None, + docstring: None, + })); + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); + assert!( + bindings.contains("java.lang.@org.jspecify.annotations.Nullable String input"), + "object method param should have @Nullable:\n{}", + bindings + .lines() + .filter(|l| l.contains("doThing")) + .collect::>() + .join("\n") + ); + } + + #[test] + fn nullness_object_cleanable_field_annotated() { + let mut group = test_group(); + group.add_item(Metadata::Object(ObjectMetadata { + module_path: "test".to_string(), + name: "MyObj".to_string(), + remote: false, + imp: ObjectImpl::Struct, + docstring: None, + })); + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); + assert!( + bindings + .contains("UniffiCleaner.@org.jspecify.annotations.Nullable Cleanable cleanable"), + "cleanable field should have @Nullable when annotations enabled:\n{}", + bindings + .lines() + .filter(|l| l.contains("cleanable")) + .collect::>() + .join("\n") + ); + } + + #[test] + fn nullness_object_cleanable_field_not_annotated_by_default() { + let mut group = test_group(); + group.add_item(Metadata::Object(ObjectMetadata { + module_path: "test".to_string(), + name: "MyObj".to_string(), + remote: false, + imp: ObjectImpl::Struct, + docstring: None, + })); + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&Config::default(), &ci).unwrap(); + assert!( + bindings.contains("UniffiCleaner.Cleanable cleanable"), + "cleanable field should not have @Nullable by default" + ); + assert!( + !bindings.contains("UniffiCleaner.@org.jspecify.annotations.Nullable Cleanable"), + "cleanable field should not have @Nullable when annotations disabled" + ); + } + + #[test] + fn nullness_enum_variant_with_optional_field() { + let mut group = test_group(); + group.add_item(Metadata::Enum(EnumMetadata { + module_path: "test".to_string(), + name: "MyEnum".to_string(), + shape: EnumShape::Enum, + remote: false, + variants: vec![VariantMetadata { + name: "WithOptional".to_string(), + discr: None, + fields: vec![FieldMetadata { + name: "value".to_string(), + ty: Type::Optional { + inner_type: Box::new(Type::String), + }, + default: None, + docstring: None, + }], + docstring: None, + }], + discr_type: None, + non_exhaustive: false, + docstring: None, + })); + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "get_enum".to_string(), + is_async: false, + inputs: vec![], + return_type: Some(Type::Enum { + module_path: "test".to_string(), + name: "MyEnum".to_string(), + }), + throws: None, + checksum: None, + docstring: None, + })); + let ci = ComponentInterface::from_metadata(group).unwrap(); + let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); + assert!( + bindings.contains("java.lang.@org.jspecify.annotations.Nullable String value"), + "enum variant optional field should have @Nullable on the field declaration:\n{}", + bindings + .lines() + .filter(|l| l.contains("value")) + .collect::>() + .join("\n") + ); + } + + #[test] + fn nullness_error_variant_with_optional_field() { + let mut group = test_group(); + group.add_item(Metadata::Enum(EnumMetadata { + module_path: "test".to_string(), + name: "MyError".to_string(), + shape: EnumShape::Error { flat: false }, + remote: false, + variants: vec![VariantMetadata { + name: "BadInput".to_string(), + discr: None, + fields: vec![FieldMetadata { + name: "detail".to_string(), + ty: Type::Optional { + inner_type: Box::new(Type::String), + }, + default: None, + docstring: None, + }], + docstring: None, + }], + discr_type: None, + non_exhaustive: false, + docstring: None, + })); + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "do_stuff".to_string(), + is_async: false, + inputs: vec![], + return_type: None, + throws: Some(Type::Enum { + module_path: "test".to_string(), + name: "MyError".to_string(), + }), + checksum: None, + docstring: None, + })); + let ci = ComponentInterface::from_metadata(group).unwrap(); + let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); + assert!( + bindings.contains("java.lang.@org.jspecify.annotations.Nullable String detail"), + "error variant optional field should have @Nullable on the field declaration:\n{}", + bindings + .lines() + .filter(|l| l.contains("detail")) + .collect::>() + .join("\n") + ); + } + + #[test] + fn nullness_async_function_with_optional_return() { + let mut group = test_group(); + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "fetch".to_string(), + is_async: true, + inputs: vec![], + return_type: Some(Type::Optional { + inner_type: Box::new(Type::String), + }), + throws: None, + checksum: None, + docstring: None, + })); + let ci = ComponentInterface::from_metadata(group).unwrap(); + let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); + assert!( + bindings + .contains("CompletableFuture"), + "async optional return should be CompletableFuture<@Nullable String>:\n{}", + bindings + .lines() + .filter(|l| l.contains("fetch")) + .collect::>() + .join("\n") + ); + } + + #[test] + fn nullness_non_optional_types_never_nullable() { + let mut group = test_group(); + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "identity".to_string(), + is_async: false, + inputs: vec![ + FnParamMetadata { + name: "s".to_string(), + ty: Type::String, + by_ref: false, + optional: false, + default: None, + }, + FnParamMetadata { + name: "i".to_string(), + ty: Type::Int32, + by_ref: false, + optional: false, + default: None, + }, + FnParamMetadata { + name: "b".to_string(), + ty: Type::Boolean, + by_ref: false, + optional: false, + default: None, + }, + ], + return_type: Some(Type::String), + throws: None, + checksum: None, + docstring: None, + })); + let ci = ComponentInterface::from_metadata(group).unwrap(); + let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); + assert!( + !bindings.contains("@org.jspecify.annotations.Nullable"), + "non-optional types should never have @Nullable" + ); + } + + #[test] + fn nullness_nested_optional_in_map_value() { + let mut group = test_group(); + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "process_map".to_string(), + is_async: false, + inputs: vec![FnParamMetadata { + name: "data".to_string(), + ty: Type::Map { + key_type: Box::new(Type::String), + value_type: Box::new(Type::Optional { + inner_type: Box::new(Type::Int32), + }), + }, + by_ref: false, + optional: false, + default: None, + }], + return_type: None, + throws: None, + checksum: None, + docstring: None, + })); + let ci = ComponentInterface::from_metadata(group).unwrap(); + let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); + assert!( + bindings.contains("java.util.Map"), + "map with optional value should have @Nullable on value type:\n{}", + bindings.lines().filter(|l| l.contains("processMap")).collect::>().join("\n") + ); + } + + #[test] + fn nullness_nested_optional_in_list() { + let mut group = test_group(); + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "process_list".to_string(), + is_async: false, + inputs: vec![FnParamMetadata { + name: "data".to_string(), + ty: Type::Sequence { + inner_type: Box::new(Type::Optional { + inner_type: Box::new(Type::String), + }), + }, + by_ref: false, + optional: false, + default: None, + }], + return_type: None, + throws: None, + checksum: None, + docstring: None, + })); + let ci = ComponentInterface::from_metadata(group).unwrap(); + let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); + assert!( + bindings + .contains("java.util.List"), + "list with optional element should have @Nullable on element type:\n{}", + bindings + .lines() + .filter(|l| l.contains("processList")) + .collect::>() + .join("\n") + ); + } + #[test] fn callback_interface_helpers_use_class_style_names() { let mut group = MetadataGroup { diff --git a/src/lib.rs b/src/lib.rs index 1b2a247..d444b97 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,6 +73,11 @@ pub fn generate(loader: &BindgenLoader, options: &GenerateOptions) -> Result<()> fs::write(&java_file_location, format!("{}\n{}", package_line, file))?; } + if config.nullness_annotations() { + let package_info = format!("@org.jspecify.annotations.NullMarked\n{}", package_line); + fs::write(java_package_out_dir.join("package-info.java"), package_info)?; + } + if options.format { // TODO: if there's a CLI formatter that makes sense to use here, use it, PRs welcome // seems like palantir-java-format is popular, but it's only exposed through plugins diff --git a/src/templates/ObjectTemplate.java b/src/templates/ObjectTemplate.java index 380e1dd..4f26cd3 100644 --- a/src/templates/ObjectTemplate.java +++ b/src/templates/ObjectTemplate.java @@ -118,7 +118,7 @@ public class {{ impl_class_name }} extends Exception implements AutoCloseable, { public class {{ impl_class_name }} implements AutoCloseable, {{ interface_name }}{% for t in obj.trait_impls() %}, {{ t.trait_ty|trait_interface_name(ci) }}{% endfor %}{% if uniffi_trait_methods.ord_cmp.is_some() %}, Comparable<{{ impl_class_name }}>{% endif %} { {%- endif %} protected long handle; - protected UniffiCleaner.Cleanable cleanable; + protected UniffiCleaner.{% if config.nullness_annotations() %}@org.jspecify.annotations.Nullable {% endif %}Cleanable cleanable; private java.util.concurrent.atomic.AtomicBoolean wasDestroyed = new java.util.concurrent.atomic.AtomicBoolean(false); private java.util.concurrent.atomic.AtomicLong callCounter = new java.util.concurrent.atomic.AtomicLong(1);