Skip to main content

zerodds_idl_java/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! IDL4 → Java 17 source codegen (OMG IDL4-Java mapping v1.0).
4//!
5//! Crate `zerodds-idl-java` — Java language bindings, cluster C5.4-a (foundation)
6//! plus C5.4-b (bitset/bitmask, multi-inheritance, annotation bridge,
7//! TopicType marker).
8//!
9//! Safety classification: **SAFE (std-only)**. A pure build-time tool —
10//! `forbid(unsafe_code)`, no no_std use case.
11//!
12//! # Scope (C5.4-a)
13//! - Block A: header layout (`package`, class modifiers, FQN imports).
14//! - Block B: primitive mapping (boolean → boolean, octet → byte, ...,
15//!   incl. unsigned workaround per spec §6).
16//! - Block C: struct → public class (bean pattern), enum, union →
17//!   sealed interface + case records, typedef → wrapper class,
18//!   sequence → `java.util.List<T>`, array → Java array, single
19//!   inheritance → `extends`.
20//! - Block D: Exception → `class X extends RuntimeException`.
21//!
22//! # Java version targets
23//! The standard emit targets **Java 17**: unions use a `sealed
24//! interface` with `record` case types. Structs/enums/typedefs are bean
25//! classes and thus version-neutral.
26//!
27//! The opt-in **Java-8 compat mode** ([`JavaGenOptions::java8_compat`])
28//! avoids all Java-9+ constructs: unions are instead emitted as an
29//! `abstract class` with a private constructor (pseudo-sealing) +
30//! `static final` subclasses (final field + constructor + same-named
31//! accessor). Everything else is identical in both modes.
32//!
33//! # Scope (C5.4-b — Cluster E)
34//! - Bitmask → wrapper class with an inner enum `Flag` and
35//!   `EnumSet<Flag> bits` (spec idl4-java-1.0 §6.3).
36//! - Bitset (≤ 64 bits cumulative) → wrapper class with `long bits` and
37//!   mask/shift accessors per bitfield. > 64 bits → hard error
38//!   [`error::JavaGenError::UnsupportedConstruct`].
39//! - Multi-inheritance via an interface pattern: every struct that is itself
40//!   the base of another struct gets a
41//!   `<Name>Interface.java` companion. Sub-sub-classes use
42//!   `extends DirectBase implements GrandparentInterface, ...`.
43//! - `@value(N)` on enum members → an explicit `int` constructor value
44//!   instead of the auto ordinal.
45//! - Annotation bridge: `@key`, `@id(N)`, `@optional`,
46//!   `@must_understand`, `@external`, `@nested`, `@extensibility(...)`
47//!   → Java annotations under `org.zerodds.types.*` (see
48//!   `runtime/`).
49//! - DDS Java PSM stub: every top-level `struct` without `@nested`
50//!   implements `org.omg.dds.topic.TopicType<SelfType>`.
51//!
52//! # Deliberately not in the crate
53//! - Clusters F-H: ServiceEnvironment SPI, Time/Duration/Status/QoS/
54//!   listener codegen (C5.5).
55//! - Reflection-based TypeRep (java-psm §8) — a stretch goal.
56//! - `interface`, `valuetype`, `fixed`, `any`, `map<K,V>` → come with
57//!   `zerodds-rpc-java`.
58//!
59//! # Multi-file output
60//! Java requires one `.java` file per top-level public class.
61//! Therefore [`generate_java_files`] returns a [`Vec<JavaFile>`]; each
62//! `JavaFile` has a package path + class name + source.
63//!
64//! # Example
65//!
66//! ```
67//! use zerodds_idl::config::ParserConfig;
68//! use zerodds_idl_java::{generate_java_files, JavaGenOptions};
69//!
70//! let ast = zerodds_idl::parse(
71//!     "module M { struct S { long x; }; };",
72//!     &ParserConfig::default(),
73//! )
74//! .expect("parse");
75//! let files = generate_java_files(&ast, &JavaGenOptions::default()).expect("gen");
76//! // POJO + TypeSupport (zerodds-xcdr2-java-1.0 §4).
77//! assert_eq!(files.len(), 2);
78//! let pojo = files.iter().find(|f| f.class_name == "S").expect("POJO");
79//! assert!(pojo.source.contains("package m;"));
80//! assert!(pojo.source.contains("public class S"));
81//! assert!(files.iter().any(|f| f.class_name == "STypeSupport"));
82//! ```
83
84#![forbid(unsafe_code)]
85#![warn(missing_docs)]
86
87pub(crate) mod amqp;
88pub(crate) mod annotations;
89pub(crate) mod bitset;
90pub(crate) mod corba_traits;
91pub mod emitter;
92pub mod error;
93pub mod keywords;
94pub mod rpc;
95pub mod type_map;
96pub(crate) mod typesupport;
97pub(crate) mod verbatim;
98
99pub use emitter::JavaFile;
100pub use error::JavaGenError;
101
102use zerodds_idl::ast::Specification;
103
104/// Configuration of the Java code generator.
105#[derive(Debug, Clone)]
106pub struct JavaGenOptions {
107    /// Java root package that all generated classes belong to
108    /// (e.g. `"org.example.types"`). Empty string = default package.
109    pub root_package: String,
110    /// Indent width in spaces. Default 4.
111    pub indent_width: usize,
112    /// If `true`, flat aggregate types are emitted as Java `record`
113    /// (Java 14+). Default `false` — the spec requires the bean pattern.
114    pub use_records: bool,
115    /// Spec §7.2.3 / §8.1.2 / §8.1.3 — opt-in: emits per top-level
116    /// struct/union an additional `<TypeName>AmqpCodec.java` file with
117    /// static `toAmqpValue` / `toJsonString` helpers. Default `false`,
118    /// because the emitted calls require an `org.zerodds.amqp` runtime
119    /// library.
120    pub emit_amqp_helpers: bool,
121    /// Annex A.1 (idl4-java-1.0) — opt-in: emits per top-level type an
122    /// additional `<TypeName>CorbaTraits.java` file with per-type
123    /// constants (`FULL_NAME`, `IS_VARIABLE_SIZE`, `IS_LOCAL`).
124    /// Default `false`.
125    pub emit_corba_traits: bool,
126    /// Spec zerodds-xcdr2-java-1.0 §4 — opt-in: emits per top-level
127    /// struct an additional `<TypeName>TypeSupport.java` file with an
128    /// `org.zerodds.cdr.TopicTypeSupport<T>` implementation
129    /// (encode/decode/keyHash + INSTANCE). Default `true` from v1.0
130    /// (spec-mandatory).
131    pub emit_typesupport: bool,
132    /// Java-8 compat mode — opt-in. If `true`, the emitter avoids all
133    /// Java-9+ constructs: unions are emitted as an `abstract class` with
134    /// a private constructor (pseudo-sealing) + `static final` subclasses
135    /// instead of as a `sealed interface` + `record` (Java 17).
136    /// Structs are bean classes anyway (`use_records=false`) and thus
137    /// already Java-8-capable. Default `false` (standard = Java 17).
138    pub java8_compat: bool,
139}
140
141impl Default for JavaGenOptions {
142    fn default() -> Self {
143        Self {
144            root_package: String::new(),
145            indent_width: 4,
146            use_records: false,
147            emit_amqp_helpers: false,
148            emit_corba_traits: false,
149            emit_typesupport: true,
150            java8_compat: false,
151        }
152    }
153}
154
155/// Produces a list of Java source files from an IDL specification.
156///
157/// # Errors
158/// - [`JavaGenError::UnsupportedConstruct`]: IDL construct outside the current scope
159///   (e.g. `interface`, `valuetype`, `fixed`, `any`) or a C5.4-b
160///   constraint violated (e.g. bitset sum > 64 bit, bitmask
161///   `bit_bound > 64`).
162/// - [`JavaGenError::InvalidName`]: an identifier is empty or still
163///   collides with a Java keyword after sanitization.
164/// - [`JavaGenError::InheritanceCycle`]: direct or indirect
165///   self-inheritance in the struct graph.
166pub fn generate_java_files(
167    ast: &Specification,
168    opts: &JavaGenOptions,
169) -> Result<Vec<JavaFile>, JavaGenError> {
170    let mut files = emitter::emit_files(ast, opts)?;
171    if opts.emit_amqp_helpers {
172        files.extend(amqp::emit_amqp_codec_files(ast, opts)?);
173    }
174    if opts.emit_corba_traits {
175        files.extend(corba_traits::emit_corba_traits_files(ast, opts)?);
176    }
177    if opts.emit_typesupport {
178        files.extend(typesupport::emit_typesupport_files(ast, opts)?);
179    }
180    Ok(files)
181}
182
183/// Convenience variant with the `emit_corba_traits` flag enabled.
184///
185/// Cross-Ref: `idl4-java-1.0` Annex A.1.
186///
187/// # Errors
188/// Wie [`generate_java_files`].
189pub fn generate_java_files_with_corba_traits(
190    ast: &Specification,
191    opts: &JavaGenOptions,
192) -> Result<Vec<JavaFile>, JavaGenError> {
193    let opts = JavaGenOptions {
194        emit_corba_traits: true,
195        ..opts.clone()
196    };
197    generate_java_files(ast, &opts)
198}
199
200/// Convenience variant with the `emit_amqp_helpers` flag enabled.
201///
202/// Identical to [`generate_java_files`], but forces
203/// `opts.emit_amqp_helpers = true`.
204///
205/// # Errors
206/// Wie [`generate_java_files`].
207pub fn generate_java_files_with_amqp(
208    ast: &Specification,
209    opts: &JavaGenOptions,
210) -> Result<Vec<JavaFile>, JavaGenError> {
211    let opts = JavaGenOptions {
212        emit_amqp_helpers: true,
213        ..opts.clone()
214    };
215    generate_java_files(ast, &opts)
216}
217
218#[cfg(test)]
219mod tests {
220    #![allow(clippy::expect_used, clippy::panic)]
221    use super::*;
222    use zerodds_idl::config::ParserConfig;
223
224    fn gen_java(src: &str) -> Vec<JavaFile> {
225        // Inline tests check the POJO emitter behavior — TypeSupport
226        // has its own tests in the `typesupport` module + snapshots.
227        let opts = JavaGenOptions {
228            emit_typesupport: false,
229            ..Default::default()
230        };
231        let ast = zerodds_idl::parse(src, &ParserConfig::default()).expect("parse must succeed");
232        generate_java_files(&ast, &opts).expect("gen must succeed")
233    }
234
235    fn gen_with(src: &str, opts: &JavaGenOptions) -> Vec<JavaFile> {
236        let ast = zerodds_idl::parse(src, &ParserConfig::default()).expect("parse must succeed");
237        generate_java_files(&ast, opts).expect("gen must succeed")
238    }
239
240    #[test]
241    fn empty_source_emits_no_files() {
242        assert!(gen_java("").is_empty());
243    }
244
245    #[test]
246    fn empty_module_emits_no_files() {
247        // A module without type defs produces no Java file (Java has no
248        // package-marker file).
249        assert!(gen_java("module M {};").is_empty());
250    }
251
252    #[test]
253    fn struct_emits_one_file_per_type() {
254        let files = gen_java("struct A { long x; }; struct B { long y; }; struct C { long z; };");
255        assert_eq!(files.len(), 3);
256        let names: Vec<&str> = files.iter().map(|f| f.class_name.as_str()).collect();
257        assert!(names.contains(&"A"));
258        assert!(names.contains(&"B"));
259        assert!(names.contains(&"C"));
260    }
261
262    #[test]
263    fn module_becomes_lowercase_package() {
264        let files = gen_java("module Foo { struct S { long x; }; };");
265        assert_eq!(files.len(), 1);
266        assert_eq!(files[0].package_path, "foo");
267        assert!(files[0].source.contains("package foo;"));
268    }
269
270    #[test]
271    fn three_level_modules_become_three_packages() {
272        let files = gen_java("module A { module B { module C { struct S { long x; }; }; }; };");
273        assert_eq!(files.len(), 1);
274        assert_eq!(files[0].package_path, "a.b.c");
275        assert!(files[0].source.contains("package a.b.c;"));
276    }
277
278    #[test]
279    fn primitive_struct_uses_correct_java_types() {
280        let files = gen_java(
281            "struct S { boolean b; octet o; short s; long l; long long ll; \
282             unsigned short us; unsigned long ul; unsigned long long ull; \
283             float f; double d; char c; wchar wc; string str; };",
284        );
285        let src = &files[0].source;
286        assert!(src.contains("private boolean b;"));
287        assert!(src.contains("private byte o;"));
288        assert!(src.contains("private short s;"));
289        assert!(src.contains("private int l;"));
290        assert!(src.contains("private long ll;"));
291        // Unsigned workaround:
292        assert!(src.contains("private int us;"));
293        assert!(src.contains("private long ul;"));
294        assert!(src.contains("private long ull;"));
295        assert!(src.contains("private float f;"));
296        assert!(src.contains("private double d;"));
297        assert!(src.contains("private char c;"));
298        assert!(src.contains("private char wc;"));
299        assert!(src.contains("private String str;"));
300    }
301
302    #[test]
303    fn unsigned_member_gets_doc_comment() {
304        let files = gen_java("struct S { unsigned long u; };");
305        assert!(files[0].source.contains("unsigned IDL value"));
306    }
307
308    #[test]
309    fn enum_emits_explicit_values() {
310        let files = gen_java("enum Color { RED, GREEN, BLUE };");
311        let src = &files[0].source;
312        assert!(src.contains("public enum Color {"));
313        assert!(src.contains("RED(0),"));
314        assert!(src.contains("GREEN(1),"));
315        assert!(src.contains("BLUE(2);"));
316        assert!(src.contains("public int value()"));
317    }
318
319    #[test]
320    fn union_emits_sealed_interface() {
321        let files = gen_java(
322            "union U switch (long) { case 1: long a; case 2: double b; default: octet c; };",
323        );
324        let src = &files[0].source;
325        assert!(src.contains("public sealed interface U"));
326        assert!(src.contains("permits"));
327        assert!(src.contains("record A(int a) implements U"));
328        assert!(src.contains("record B(double b) implements U"));
329        assert!(src.contains("// case default"));
330    }
331
332    #[test]
333    fn typedef_emits_wrapper_class() {
334        let files = gen_java("typedef long Counter;");
335        assert_eq!(files.len(), 1);
336        let src = &files[0].source;
337        assert!(src.contains("public final class Counter"));
338        assert!(src.contains("private int value;"));
339    }
340
341    #[test]
342    fn sequence_uses_list() {
343        let files = gen_java("struct Bag { sequence<long> items; };");
344        let src = &files[0].source;
345        assert!(src.contains("private java.util.List<Integer> items;"));
346    }
347
348    #[test]
349    fn array_uses_java_array_syntax() {
350        let files = gen_java("struct M { long cells[3][4]; };");
351        let src = &files[0].source;
352        assert!(src.contains("private int[][] cells;"));
353    }
354
355    #[test]
356    fn inherited_struct_uses_extends() {
357        let files = gen_java("struct Parent { long x; }; struct Child : Parent { long y; };");
358        let child = files
359            .iter()
360            .find(|f| f.class_name == "Child")
361            .expect("Child file");
362        assert!(child.source.contains("public class Child extends Parent"));
363    }
364
365    #[test]
366    fn keyed_member_emits_key_annotation() {
367        let files = gen_java("struct S { @key long id; long val; };");
368        assert!(files[0].source.contains("@org.zerodds.types.Key"));
369    }
370
371    #[test]
372    fn optional_member_uses_optional() {
373        let files = gen_java("struct S { @optional long maybe; };");
374        let src = &files[0].source;
375        assert!(src.contains("java.util.Optional<Integer> maybe"));
376    }
377
378    #[test]
379    fn exception_extends_runtime_exception() {
380        let files = gen_java("exception NotFound { string what_; };");
381        let src = &files[0].source;
382        assert!(src.contains("public class NotFound extends RuntimeException"));
383        assert!(src.contains("public NotFound(String message)"));
384    }
385
386    #[test]
387    fn reserved_member_name_gets_underscore_suffix() {
388        let files = gen_java("struct S { long class; };");
389        let src = &files[0].source;
390        assert!(src.contains("class_"));
391        assert!(src.contains("getClass_"));
392    }
393
394    #[test]
395    fn non_service_interface_emits_java_interface() {
396        let opts = JavaGenOptions {
397            emit_typesupport: false,
398            ..Default::default()
399        };
400        let ast = zerodds_idl::parse("interface I { void op(); };", &ParserConfig::default())
401            .expect("parse");
402        let files = generate_java_files(&ast, &opts).expect("ok");
403        let combined: String = files.iter().map(|f| f.source.clone()).collect();
404        assert!(combined.contains("public interface I"));
405    }
406
407    #[test]
408    fn any_member_emits_object() {
409        // `any` members are outside the TypeSupport scope (v1.0);
410        // the POJO still emits `Object`, TypeSupport is omitted.
411        let ast = zerodds_idl::parse("struct S { any value; };", &ParserConfig::default())
412            .expect("parse");
413        let files = generate_java_files(&ast, &JavaGenOptions::default()).expect("ok");
414        let combined: String = files.iter().map(|f| f.source.clone()).collect();
415        assert!(combined.contains("Object"));
416        // No TypeSupport generation for `any`.
417        assert!(!combined.contains("STypeSupport"));
418    }
419
420    #[test]
421    fn root_package_prepends_to_modules() {
422        let opts = JavaGenOptions {
423            root_package: "org.example".into(),
424            ..Default::default()
425        };
426        let files = gen_with("module Inner { struct S { long x; }; };", &opts);
427        assert_eq!(files[0].package_path, "org.example.inner");
428    }
429
430    #[test]
431    fn relative_path_uses_package_directory() {
432        let files = gen_java("module M { struct S { long x; }; };");
433        assert_eq!(files[0].relative_path(), "m/S.java");
434    }
435
436    #[test]
437    fn relative_path_default_package() {
438        let files = gen_java("struct S { long x; };");
439        assert_eq!(files[0].relative_path(), "S.java");
440    }
441
442    #[test]
443    fn inheritance_cycle_is_rejected() {
444        let ast = zerodds_idl::parse(
445            "struct A : B { long a; };\n\
446             struct B : A { long b; };",
447            &ParserConfig::default(),
448        )
449        .expect("parse");
450        let res = generate_java_files(&ast, &JavaGenOptions::default());
451        assert!(matches!(res, Err(JavaGenError::InheritanceCycle { .. })));
452    }
453
454    #[test]
455    fn options_have_sensible_defaults() {
456        let o = JavaGenOptions::default();
457        assert_eq!(o.indent_width, 4);
458        assert!(o.root_package.is_empty());
459        assert!(!o.use_records);
460    }
461
462    #[test]
463    fn options_clone_works() {
464        let o = JavaGenOptions {
465            root_package: "foo.bar".into(),
466            indent_width: 2,
467            use_records: true,
468            emit_amqp_helpers: false,
469            emit_corba_traits: false,
470            emit_typesupport: true,
471            java8_compat: false,
472        };
473        let cloned = o.clone();
474        assert_eq!(cloned.indent_width, 2);
475        assert_eq!(cloned.root_package, "foo.bar");
476        assert!(cloned.use_records);
477    }
478
479    #[test]
480    fn java_file_struct_field_access() {
481        let files = gen_java("struct S { long x; };");
482        assert_eq!(files[0].class_name, "S");
483        assert_eq!(files[0].package_path, "");
484        assert!(files[0].source.contains("public class S"));
485    }
486
487    #[test]
488    fn const_decl_emits_holder_class() {
489        let files = gen_java("const long MAX = 100;");
490        let src = &files[0].source;
491        assert!(src.contains("public final class MAXConstant"));
492        assert!(src.contains("public static final int MAX = 100;"));
493    }
494
495    #[test]
496    fn each_file_starts_with_generated_marker() {
497        for f in gen_java("struct S { long x; };") {
498            assert!(f.source.starts_with("// Generated by zerodds idl-java."));
499        }
500    }
501}