From 3f907ff4708445662239f87d9c18cdaa9b58bd2f Mon Sep 17 00:00:00 2001 From: Einar Omang Date: Tue, 11 Nov 2025 14:54:58 +0100 Subject: [PATCH] Support dependent nodesets for types codegen This is a relatively simple implementation of this. Let users list dependent nodesets with import paths, like we allow for events. When actually generating the types, we store this as a map from namespace to import path, and during loading we just get the namespace of the type being loaded. This only really works for nodeset2 files, but since there exists a workaround, this isn't critical. We can improve on this in the future. With the codegen tests we can actually test stuff like this, which is quite nice. --- async-opcua-codegen/sample_codegen_config.yml | 5 ++ async-opcua-codegen/src/input/nodeset.rs | 2 + async-opcua-codegen/src/types/gen.rs | 49 +++++++---- .../src/types/loaders/binary_schema.rs | 10 ++- .../src/types/loaders/nodeset.rs | 8 +- .../src/types/loaders/types.rs | 14 ++- async-opcua-codegen/src/types/mod.rs | 19 +++- codegen-tests/build.rs | 34 ++++++-- .../schemas/Async.Opcua.Test.Ext.NodeSet2.xml | 87 +++++++++++++++++++ codegen-tests/src/lib.rs | 9 +- codegen-tests/src/tests/types.rs | 34 +++++++- 11 files changed, 232 insertions(+), 39 deletions(-) create mode 100644 codegen-tests/schemas/Async.Opcua.Test.Ext.NodeSet2.xml diff --git a/async-opcua-codegen/sample_codegen_config.yml b/async-opcua-codegen/sample_codegen_config.yml index 53230299..354c5839 100644 --- a/async-opcua-codegen/sample_codegen_config.yml +++ b/async-opcua-codegen/sample_codegen_config.yml @@ -92,6 +92,11 @@ targets: # This is useful if the nodeset lacks node ID CSV files, or those files are incomplete. node_ids_from_nodeset: false + dependent_nodesets: + # This can be a path, filename, or primary namespace URI. + - file: Another.Namespace.Uri + import_path: crate::generated::another_namespace + # This target generates code to generate nodes that are added to the server address space. # Each node in the NodeSet2 file creates a function, which is then called from # a large iterator that can be used as a a node set source. diff --git a/async-opcua-codegen/src/input/nodeset.rs b/async-opcua-codegen/src/input/nodeset.rs index 37f3f9cf..692ba3d9 100644 --- a/async-opcua-codegen/src/input/nodeset.rs +++ b/async-opcua-codegen/src/input/nodeset.rs @@ -31,6 +31,7 @@ pub struct RawEncodingIds { #[derive(Debug, Clone)] pub struct TypeInfo { pub name: String, + pub namespace: String, pub is_abstract: bool, pub definition: Option, pub encoding_ids: RawEncodingIds, @@ -346,6 +347,7 @@ impl NodeSetInput { is_abstract: data_type.base.is_abstract, definition: data_type.definition.clone(), encoding_ids, + namespace: self.uri.clone(), }, ); } diff --git a/async-opcua-codegen/src/types/gen.rs b/async-opcua-codegen/src/types/gen.rs index 75d6a785..efa97dae 100644 --- a/async-opcua-codegen/src/types/gen.rs +++ b/async-opcua-codegen/src/types/gen.rs @@ -95,9 +95,11 @@ pub struct CodeGenerator { target_namespace: String, native_types: HashSet, id_path: String, + namespace_to_import_path: HashMap, } impl CodeGenerator { + #[allow(clippy::too_many_arguments)] pub fn new( external_import_map: HashMap, native_types: HashSet, @@ -106,6 +108,7 @@ impl CodeGenerator { config: CodeGenItemConfig, target_namespace: String, id_path: String, + namespace_to_import_path: HashMap, ) -> Self { Self { import_map: external_import_map @@ -119,7 +122,10 @@ impl CodeGenerator { Some("ExtensionObject" | "OptionSet") => { Some(FieldType::ExtensionObject(None)) } - Some(t) => Some(FieldType::Normal(t.to_owned())), + Some(t) => Some(FieldType::Normal { + name: t.to_owned(), + namespace: None, + }), None => None, }, path: v.path, @@ -137,6 +143,7 @@ impl CodeGenerator { target_namespace, native_types, id_path, + namespace_to_import_path, } } @@ -159,7 +166,7 @@ impl CodeGenerator { } let Some(it) = self.import_map.get(name) else { - // Not in the import map means it's a builtin, we assume these have defaults for now. + // Not in the import map means it's a builtin or external reference, we assume these have defaults for now. return true; }; @@ -175,8 +182,8 @@ impl CodeGenerator { LoadedType::Struct(s) => { for k in &s.fields { let has_default = match &k.typ { - StructureFieldType::Field(FieldType::Normal(f)) => { - self.is_default_recursive(f) + StructureFieldType::Field(FieldType::Normal { name, .. }) => { + self.is_default_recursive(name) } StructureFieldType::Array(_) | StructureFieldType::Field(_) => true, }; @@ -279,7 +286,7 @@ impl CodeGenerator { } /// Get the fully qualified path of a type, by looking it up in the import map. - fn get_type_path(&self, name: &str) -> String { + fn get_type_path(&self, name: &str, namespace: Option<&str>) -> String { // Type is known, use the external path. if let Some(ext) = self.import_map.get(name) { return format!("{}::{}", ext.path, name); @@ -288,6 +295,12 @@ impl CodeGenerator { if self.native_types.contains(name) { return name.to_owned(); } + + if let Some(namespace) = namespace { + if let Some(import_path) = self.namespace_to_import_path.get(namespace) { + return format!("{}::{}", import_path, name); + } + } // Assume the type is a builtin. format!("opcua::types::{name}") } @@ -548,7 +561,7 @@ impl CodeGenerator { fn is_extension_object(&self, typ: Option<&FieldType>) -> bool { let name = match &typ { Some(FieldType::Abstract(_)) | Some(FieldType::ExtensionObject(_)) => return true, - Some(FieldType::Normal(s)) => s, + Some(FieldType::Normal { name, .. }) => name, None => return false, }; let name = match name.split_once(":") { @@ -596,18 +609,22 @@ impl CodeGenerator { for field in item.visible_fields() { let typ: Type = match &field.typ { - StructureFieldType::Field(f) => { - syn::parse_str(&self.get_type_path(f.as_type_str())).map_err(|e| { - CodeGenError::from(e) - .with_context(format!("Generating path for {}", f.as_type_str())) - })? - } + StructureFieldType::Field(f) => syn::parse_str( + &self.get_type_path(f.as_type_str(), f.namespace()), + ) + .map_err(|e| { + CodeGenError::from(e) + .with_context(format!("Generating path for {}", f.as_type_str())) + })?, StructureFieldType::Array(f) => { let path: Path = - syn::parse_str(&self.get_type_path(f.as_type_str())).map_err(|e| { - CodeGenError::from(e) - .with_context(format!("Generating path for {}", f.as_type_str())) - })?; + syn::parse_str(&self.get_type_path(f.as_type_str(), f.namespace())) + .map_err(|e| { + CodeGenError::from(e).with_context(format!( + "Generating path for {}", + f.as_type_str() + )) + })?; parse_quote! { Option> } } }; diff --git a/async-opcua-codegen/src/types/loaders/binary_schema.rs b/async-opcua-codegen/src/types/loaders/binary_schema.rs index 9db51fb8..5ee03d08 100644 --- a/async-opcua-codegen/src/types/loaders/binary_schema.rs +++ b/async-opcua-codegen/src/types/loaders/binary_schema.rs @@ -51,7 +51,10 @@ impl<'a> BsdTypeLoader<'a> { fn get_field_type(field: &str) -> FieldType { match field { "ExtensionObject" | "OptionSet" => FieldType::ExtensionObject(None), - _ => FieldType::Normal(field.to_owned()), + _ => FieldType::Normal { + name: field.to_owned(), + namespace: None, + }, } } @@ -114,7 +117,10 @@ impl<'a> BsdTypeLoader<'a> { Some("ua:ExtensionObject" | "ua:OptionSet") => { Some(FieldType::ExtensionObject(None)) } - Some(base) => Some(FieldType::Normal(self.massage_type_name(base))), + Some(base) => Some(FieldType::Normal { + name: self.massage_type_name(base), + namespace: Some(self.target_namespace()), + }), None => None, }, is_union: false, diff --git a/async-opcua-codegen/src/types/loaders/nodeset.rs b/async-opcua-codegen/src/types/loaders/nodeset.rs index 155504ba..baeedda9 100644 --- a/async-opcua-codegen/src/types/loaders/nodeset.rs +++ b/async-opcua-codegen/src/types/loaders/nodeset.rs @@ -5,7 +5,7 @@ use opcua_xml::schema::ua_node_set::{DataTypeField, LocalizedText, UADataType, U use crate::{ input::{NodeSetInput, SchemaCache, TypeInfo}, utils::{split_qualified_name, to_snake_case, NodeIdVariant, ParsedNodeId}, - CodeGenError, + CodeGenError, BASE_NAMESPACE, }; use super::{ @@ -67,7 +67,10 @@ impl<'a> NodeSetTypeLoader<'a> { } else if info.name == "Structure" || info.name == "OptionSet" { FieldType::ExtensionObject(Some(info.encoding_ids)) } else { - FieldType::Normal(info.name) + FieldType::Normal { + name: info.name, + namespace: Some(info.namespace), + } } } @@ -283,6 +286,7 @@ impl<'a> NodeSetTypeLoader<'a> { is_abstract: false, definition: None, encoding_ids: Default::default(), + namespace: BASE_NAMESPACE.to_owned(), }) } else { Ok(r) diff --git a/async-opcua-codegen/src/types/loaders/types.rs b/async-opcua-codegen/src/types/loaders/types.rs index c3ac80a3..561b6396 100644 --- a/async-opcua-codegen/src/types/loaders/types.rs +++ b/async-opcua-codegen/src/types/loaders/types.rs @@ -18,14 +18,24 @@ pub struct StructureField { pub enum FieldType { Abstract(#[allow(unused)] String), ExtensionObject(Option), - Normal(String), + Normal { + name: String, + namespace: Option, + }, } impl FieldType { pub fn as_type_str(&self) -> &str { match self { FieldType::Abstract(_) | FieldType::ExtensionObject(_) => "ExtensionObject", - FieldType::Normal(s) => s, + FieldType::Normal { name, .. } => name, + } + } + + pub fn namespace(&self) -> Option<&str> { + match self { + FieldType::Normal { namespace, .. } => namespace.as_deref(), + _ => None, } } } diff --git a/async-opcua-codegen/src/types/mod.rs b/async-opcua-codegen/src/types/mod.rs index 5853fd14..33796136 100644 --- a/async-opcua-codegen/src/types/mod.rs +++ b/async-opcua-codegen/src/types/mod.rs @@ -23,7 +23,7 @@ use tracing::info; use crate::{ input::{BinarySchemaInput, NodeSetInput, SchemaCache}, - CodeGenError, BASE_NAMESPACE, + CodeGenError, DependentNodeset, BASE_NAMESPACE, }; #[derive(Serialize, Deserialize, Debug)] @@ -60,6 +60,9 @@ pub struct TypeCodeGenTarget { #[serde(default)] /// If true, instead of using `id_path` and ID enums, generate the node IDs from the nodeset file. pub node_ids_from_nodeset: bool, + /// List of dependent nodesets to load types from. Only valid when using a NodeSet input. + #[serde(default)] + pub dependent_nodesets: Vec, } impl Default for TypeCodeGenTarget { @@ -75,6 +78,7 @@ impl Default for TypeCodeGenTarget { extra_header: String::new(), id_path: defaults::id_path(), node_ids_from_nodeset: false, + dependent_nodesets: Vec::new(), } } } @@ -120,7 +124,7 @@ pub fn generate_types( .map_err(|e| e.in_file(&input.path))?; info!("Loaded {} types", types.len()); - generate_types_inner(target, target_namespace, types) + generate_types_inner(target, target_namespace, types, HashMap::new()) } /// Generate types from the given NodeSet file input. @@ -149,13 +153,21 @@ pub fn generate_types_nodeset( let types = type_loader.load_types(cache)?; info!("Loaded {} types", types.len()); - generate_types_inner(target, target_namespace, types) + let mut namespace_to_import_path = HashMap::new(); + for dependent_nodeset in &target.dependent_nodesets { + let dep_input = cache.get_nodeset(&dependent_nodeset.file)?; + namespace_to_import_path + .insert(dep_input.uri.clone(), dependent_nodeset.import_path.clone()); + } + + generate_types_inner(target, target_namespace, types, namespace_to_import_path) } fn generate_types_inner( target: &TypeCodeGenTarget, target_namespace: String, types: Vec, + namespace_to_import_path: HashMap, ) -> Result<(Vec, String), CodeGenError> { let mut types_import_map = basic_types_import_map(); for (k, v) in &target.types_import_map { @@ -179,6 +191,7 @@ fn generate_types_inner( }, target_namespace.clone(), target.id_path.clone(), + namespace_to_import_path, ); Ok((generator.generate_types()?, target_namespace)) diff --git a/codegen-tests/build.rs b/codegen-tests/build.rs index c7485059..d49bdaf7 100644 --- a/codegen-tests/build.rs +++ b/codegen-tests/build.rs @@ -7,18 +7,34 @@ use opcua_codegen::{CodeGenConfig, CodeGenSource, CodeGenTarget, TypeCodeGenTarg fn main() { let out_dir = std::env::var("OUT_DIR").unwrap(); let target_dir = format!("{}/opcua_generated", out_dir); + println!("cargo:rerun-if-changed=schemas/Async.Opcua.Test.NodeSet2.xml"); + println!("cargo:rerun-if-changed=schemas/Async.Opcua.Test.Ext.NodeSet2.xml"); println!("cargo:rustc-env=OPCUA_GENERATED_DIR={}", target_dir); run_codegen( &CodeGenConfig { - targets: vec![CodeGenTarget::Types(TypeCodeGenTarget { - file: "Async.Opcua.Test.NodeSet2.xml".to_owned(), - output_dir: target_dir, - enums_single_file: true, - structs_single_file: true, - node_ids_from_nodeset: true, - default_excluded: ["SimpleEnum".to_string()].into_iter().collect(), - ..Default::default() - })], + targets: vec![ + CodeGenTarget::Types(TypeCodeGenTarget { + file: "Async.Opcua.Test.NodeSet2.xml".to_owned(), + output_dir: format!("{}/base", target_dir), + enums_single_file: true, + structs_single_file: true, + node_ids_from_nodeset: true, + default_excluded: ["SimpleEnum".to_string()].into_iter().collect(), + ..Default::default() + }), + CodeGenTarget::Types(TypeCodeGenTarget { + file: "Async.Opcua.Test.Ext.NodeSet2.xml".to_owned(), + output_dir: format!("{}/ext", target_dir), + enums_single_file: true, + structs_single_file: true, + node_ids_from_nodeset: true, + dependent_nodesets: vec![opcua_codegen::DependentNodeset { + file: "Async.Opcua.Test.NodeSet2.xml".to_owned(), + import_path: "crate::generated::base".to_owned(), + }], + ..Default::default() + }), + ], sources: vec![ CodeGenSource::Implicit("./schemas".to_owned()), CodeGenSource::Implicit("../schemas/1.05".to_owned()), diff --git a/codegen-tests/schemas/Async.Opcua.Test.Ext.NodeSet2.xml b/codegen-tests/schemas/Async.Opcua.Test.Ext.NodeSet2.xml new file mode 100644 index 00000000..765c370b --- /dev/null +++ b/codegen-tests/schemas/Async.Opcua.Test.Ext.NodeSet2.xml @@ -0,0 +1,87 @@ + + + + http://github.com/freeopcua/async-opcua/codegen-tests + http://github.com/freeopcua/async-opcua/codegen-tests/ext + + + + + + + + + i=1 + i=2 + i=3 + i=4 + i=5 + i=6 + i=7 + i=8 + i=9 + i=10 + i=11 + i=13 + i=12 + i=15 + i=14 + i=16 + i=17 + i=18 + i=20 + i=21 + i=19 + i=22 + i=26 + i=27 + i=28 + i=47 + i=46 + i=35 + i=36 + i=48 + i=45 + i=40 + i=37 + i=38 + i=39 + i=7594 + + + + + ExtStruct + A struct containing types from the base nodeset. + + i=22 + ns=2;i=1001 + ns=2;i=1011 + ns=2;i=1021 + + + + + + + + + + Default Binary + + i=76 + + + + Default XML + + i=76 + + + + Default JSON + + i=76 + + + diff --git a/codegen-tests/src/lib.rs b/codegen-tests/src/lib.rs index efceb234..4c7a2a43 100644 --- a/codegen-tests/src/lib.rs +++ b/codegen-tests/src/lib.rs @@ -3,10 +3,15 @@ #![allow(clippy::disallowed_names)] #![allow(clippy::derivable_impls)] -use crate::generated::SimpleEnum; +use crate::generated::base::SimpleEnum; pub mod generated { - include!(concat!(env!("OPCUA_GENERATED_DIR"), "/mod.rs")); + pub mod base { + include!(concat!(env!("OPCUA_GENERATED_DIR"), "/base/mod.rs")); + } + pub mod ext { + include!(concat!(env!("OPCUA_GENERATED_DIR"), "/ext/mod.rs")); + } } impl Default for SimpleEnum { diff --git a/codegen-tests/src/tests/types.rs b/codegen-tests/src/tests/types.rs index fb520290..d44929c5 100644 --- a/codegen-tests/src/tests/types.rs +++ b/codegen-tests/src/tests/types.rs @@ -21,14 +21,17 @@ use opcua::types::xml::XmlEncodable; use opcua::xml::XmlStreamReader; use opcua::xml::XmlStreamWriter; -use crate::generated::enums::*; -use crate::generated::structs::*; +use crate::generated::base::enums::*; +use crate::generated::base::structs::*; +use crate::generated::ext::structs::*; fn ctx() -> ContextOwned { let mut namespaces = NamespaceMap::new(); namespaces.add_namespace("http://github.com/freeopcua/async-opcua/codegen-tests"); + namespaces.add_namespace("http://github.com/freeopcua/async-opcua/codegen-tests/ext"); let mut loaders = TypeLoaderCollection::new(); - loaders.add_type_loader(crate::generated::GeneratedTypeLoader); + loaders.add_type_loader(crate::generated::base::GeneratedTypeLoader); + loaders.add_type_loader(crate::generated::ext::GeneratedTypeLoader); let ctx_owned = ContextOwned::new(namespaces, loaders, DecodingOptions::default()); ctx_owned } @@ -194,3 +197,28 @@ fn test_container_struct() { }; encoding_roundtrip_extension_object(s); } + +#[test] +fn test_external_struct() { + let s = ExtStruct { + simple: SimpleStruct { + foo: "hello".into(), + bar: 42, + baz: true, + simple_enum: SimpleEnum::Bar, + numbers: Some(vec![1.0, 2.0, 3.0]), + }, + extended: ExtendedStruct { + foo: "hello".into(), + bar: 42, + baz: true, + simple_enum: SimpleEnum::Bar, + numbers: Some(vec![1.0, 2.0, 3.0]), + bar_2: -12345, + foo_2: "world".into(), + }, + baz: true, + simple_enum: SimpleEnum::Foo, + }; + encoding_roundtrip_extension_object(s); +}