From 63c7205dd9e2201d8fe9b6fc521ce27b072df1ad Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Tue, 7 Apr 2026 11:50:44 -0600 Subject: [PATCH 1/4] Stab at JSpecify nullness annotation option --- CHANGELOG.md | 7 + README.md | 58 +++ src/gen_java/compounds.rs | 10 +- src/gen_java/mod.rs | 570 +++++++++++++++++++++++++++++- src/lib.rs | 5 + src/templates/ObjectTemplate.java | 2 +- 6 files changed, 644 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5afe5c..75285a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +- 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/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..aa5b2bf 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() { + format!("@org.jspecify.annotations.Nullable {}", inner) + } else { + inner + } } fn canonical_name(&self) -> String { diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index 1db1616..a799e4d 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -161,6 +161,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 +181,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 +815,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(format!("@org.jspecify.annotations.Nullable {}", inner)) + } else { + Ok(inner) + } } Type::Sequence { inner_type } => match inner_type.as_ref() { Type::Int16 | Type::UInt16 => Ok("short[]".to_string()), @@ -1450,9 +1462,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 +1913,555 @@ mod tests { ); } + fn nullness_config() -> Config { + toml::from_str("nullness_annotations = true").unwrap() + } + + 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("@org.jspecify.annotations.Nullable java.lang.Integer"), + "return type should be @Nullable Integer" + ); + assert!( + bindings.contains("@org.jspecify.annotations.Nullable java.lang.String optional"), + "optional param should be @Nullable String" + ); + // The required param should NOT have @Nullable + assert!( + bindings.contains("java.lang.String required, @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 @org.jspecify.annotations.Nullable java.lang.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("@org.jspecify.annotations.Nullable java.lang.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("@org.jspecify.annotations.Nullable java.lang.String nickname"), + "immutable record should have @Nullable on optional component:\n{}", + bindings + .lines() + .filter(|l| l.contains("nickname")) + .collect::>() + .join("\n") + ); + assert!( + !bindings.contains("@org.jspecify.annotations.Nullable java.lang.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("@org.jspecify.annotations.Nullable java.lang.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("@org.jspecify.annotations.Nullable UniffiCleaner.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("@org.jspecify.annotations.Nullable UniffiCleaner.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("@org.jspecify.annotations.Nullable"), + "enum variant with optional field should have @Nullable:\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("@org.jspecify.annotations.Nullable"), + "error variant with optional field should have @Nullable:\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<@org.jspecify.annotations.Nullable java.lang.String>"), + "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<@org.jspecify.annotations.Nullable java.lang.String>"), + "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..983a0ce 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 {% if config.nullness_annotations() %}@org.jspecify.annotations.Nullable {% endif %}UniffiCleaner.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); From bfe29897fe06ff5c788bf0e252b85f2947513cb5 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Tue, 7 Apr 2026 16:38:05 -0600 Subject: [PATCH 2/4] Make the nullable checks a bit more robust --- src/gen_java/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index a799e4d..9121276 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -2259,8 +2259,8 @@ mod tests { let ci = ComponentInterface::from_metadata(group).unwrap(); let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( - bindings.contains("@org.jspecify.annotations.Nullable"), - "enum variant with optional field should have @Nullable:\n{}", + bindings.contains("@org.jspecify.annotations.Nullable java.lang.String value"), + "enum variant optional field should have @Nullable on the field declaration:\n{}", bindings .lines() .filter(|l| l.contains("value")) @@ -2310,8 +2310,8 @@ mod tests { let ci = ComponentInterface::from_metadata(group).unwrap(); let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( - bindings.contains("@org.jspecify.annotations.Nullable"), - "error variant with optional field should have @Nullable:\n{}", + bindings.contains("@org.jspecify.annotations.Nullable java.lang.String detail"), + "error variant optional field should have @Nullable on the field declaration:\n{}", bindings .lines() .filter(|l| l.contains("detail")) From e544deac36509c83297eebac56c231facdd9a327 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Wed, 8 Apr 2026 11:38:32 -0600 Subject: [PATCH 3/4] Use JLS correct fully qualified nullables and add tests to cover that strangeness. Not all compilers seem to care, but it's the most technically correct generation. --- src/gen_java/compounds.rs | 2 +- src/gen_java/mod.rs | 112 +++++++++++++++++++++++++----- src/templates/ObjectTemplate.java | 2 +- 3 files changed, 98 insertions(+), 18 deletions(-) diff --git a/src/gen_java/compounds.rs b/src/gen_java/compounds.rs index aa5b2bf..a9c1ed9 100644 --- a/src/gen_java/compounds.rs +++ b/src/gen_java/compounds.rs @@ -26,7 +26,7 @@ impl CodeType for OptionalCodeType { .find(self.inner()) .type_label(ci, config); if config.nullness_annotations() { - format!("@org.jspecify.annotations.Nullable {}", inner) + super::nullable_type_label(&inner) } else { inner } diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index 9121276..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, @@ -817,7 +843,7 @@ mod filters { Type::Optional { inner_type } => { let inner = fully_qualified_type_label(inner_type, ci, config)?; if config.nullness_annotations() { - Ok(format!("@org.jspecify.annotations.Nullable {}", inner)) + Ok(nullable_type_label(&inner)) } else { Ok(inner) } @@ -1917,6 +1943,58 @@ mod tests { 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 { @@ -1996,16 +2074,18 @@ mod tests { let ci = ComponentInterface::from_metadata(group).unwrap(); let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( - bindings.contains("@org.jspecify.annotations.Nullable java.lang.Integer"), + bindings.contains("java.lang.@org.jspecify.annotations.Nullable Integer"), "return type should be @Nullable Integer" ); assert!( - bindings.contains("@org.jspecify.annotations.Nullable java.lang.String optional"), + 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, @org.jspecify.annotations.Nullable"), + bindings.contains( + "java.lang.String required, java.lang.@org.jspecify.annotations.Nullable" + ), "required param should not have @Nullable" ); } @@ -2053,7 +2133,7 @@ mod tests { let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( bindings - .contains("private @org.jspecify.annotations.Nullable java.lang.String nickname"), + .contains("private java.lang.@org.jspecify.annotations.Nullable String nickname"), "optional field should have @Nullable:\n{}", bindings .lines() @@ -2063,7 +2143,7 @@ mod tests { ); // Non-optional field should NOT have @Nullable assert!( - !bindings.contains("@org.jspecify.annotations.Nullable java.lang.String name"), + !bindings.contains("java.lang.@org.jspecify.annotations.Nullable String name"), "non-optional field should not have @Nullable" ); } @@ -2112,7 +2192,7 @@ mod tests { .unwrap(); let bindings = generate_bindings(&config, &ci).unwrap(); assert!( - bindings.contains("@org.jspecify.annotations.Nullable java.lang.String nickname"), + bindings.contains("java.lang.@org.jspecify.annotations.Nullable String nickname"), "immutable record should have @Nullable on optional component:\n{}", bindings .lines() @@ -2121,7 +2201,7 @@ mod tests { .join("\n") ); assert!( - !bindings.contains("@org.jspecify.annotations.Nullable java.lang.String name"), + !bindings.contains("java.lang.@org.jspecify.annotations.Nullable String name"), "non-optional component should not have @Nullable" ); } @@ -2160,7 +2240,7 @@ mod tests { ci.derive_ffi_funcs().unwrap(); let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( - bindings.contains("@org.jspecify.annotations.Nullable java.lang.String input"), + bindings.contains("java.lang.@org.jspecify.annotations.Nullable String input"), "object method param should have @Nullable:\n{}", bindings .lines() @@ -2185,7 +2265,7 @@ mod tests { let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( bindings - .contains("@org.jspecify.annotations.Nullable UniffiCleaner.Cleanable cleanable"), + .contains("UniffiCleaner.@org.jspecify.annotations.Nullable Cleanable cleanable"), "cleanable field should have @Nullable when annotations enabled:\n{}", bindings .lines() @@ -2213,7 +2293,7 @@ mod tests { "cleanable field should not have @Nullable by default" ); assert!( - !bindings.contains("@org.jspecify.annotations.Nullable UniffiCleaner.Cleanable"), + !bindings.contains("UniffiCleaner.@org.jspecify.annotations.Nullable Cleanable"), "cleanable field should not have @Nullable when annotations disabled" ); } @@ -2259,7 +2339,7 @@ mod tests { let ci = ComponentInterface::from_metadata(group).unwrap(); let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( - bindings.contains("@org.jspecify.annotations.Nullable java.lang.String value"), + bindings.contains("java.lang.@org.jspecify.annotations.Nullable String value"), "enum variant optional field should have @Nullable on the field declaration:\n{}", bindings .lines() @@ -2310,7 +2390,7 @@ mod tests { let ci = ComponentInterface::from_metadata(group).unwrap(); let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( - bindings.contains("@org.jspecify.annotations.Nullable java.lang.String detail"), + bindings.contains("java.lang.@org.jspecify.annotations.Nullable String detail"), "error variant optional field should have @Nullable on the field declaration:\n{}", bindings .lines() @@ -2339,7 +2419,7 @@ mod tests { let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( bindings - .contains("CompletableFuture<@org.jspecify.annotations.Nullable java.lang.String>"), + .contains("CompletableFuture"), "async optional return should be CompletableFuture<@Nullable String>:\n{}", bindings .lines() @@ -2419,7 +2499,7 @@ mod tests { let ci = ComponentInterface::from_metadata(group).unwrap(); let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( - bindings.contains("java.util.Map"), + 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") ); @@ -2452,7 +2532,7 @@ mod tests { let bindings = generate_bindings(&nullness_config(), &ci).unwrap(); assert!( bindings - .contains("java.util.List<@org.jspecify.annotations.Nullable java.lang.String>"), + .contains("java.util.List"), "list with optional element should have @Nullable on element type:\n{}", bindings .lines() diff --git a/src/templates/ObjectTemplate.java b/src/templates/ObjectTemplate.java index 983a0ce..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 {% if config.nullness_annotations() %}@org.jspecify.annotations.Nullable {% endif %}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); From 4f28b4ed4fa83c0b9dbd78be057497a15512903d Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Mon, 13 Apr 2026 10:56:50 -0600 Subject: [PATCH 4/4] Prep for release --- CHANGELOG.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75285a9..9350c97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 0.4.2 - Added `nullness_annotations` config option to emit JSpecify `@NullMarked` and `@Nullable` annotations in generated code. When enabled, Rust `Option` maps to 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"