/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading; using Opc.Ua.Schema.Model; namespace Opc.Ua.SourceGeneration { /// /// Source Generation API /// public static class Generators { /// /// Generate code from design files /// /// Design files to process /// File system abstraction to use /// Output folder or null /// Telemetry context for logging /// Generator options /// allow subtypes /// Any additional csv files /// Models supplied by referenced assemblies. /// Optional node manager bindings. /// Optional binding diagnostic callback. /// Optional set that accumulates matched bindings. /// Total number of generatable models across all passes. public static void GenerateCode( this DesignFileCollection designFiles, IFileSystem fileSystem, string outputDir, ITelemetryContext telemetry, GeneratorOptions? options = null, bool useAllowSubtypes = false, List? identifierFiles = null, IReadOnlyDictionary? referencedModels = null, IReadOnlyList? nodeManagerBindings = null, Action? reportBindingDiagnostic = null, HashSet? sharedUsedBindings = null, int bindingModelCount = 0) { GenerateCode( designFiles, fileSystem, outputDir, telemetry, options, useAllowSubtypes, identifierFiles!, referencedModels, nodeManagerBindings, reportBindingDiagnostic!, sharedUsedBindings, bindingModelCount, null, null, null); } /// /// Generate code from design files with fluent-accessor provider metadata. /// /// Design files to process /// File system abstraction to use /// Output folder or null /// Telemetry context for logging /// Generator options /// allow subtypes /// Any additional csv files /// Models supplied by referenced /// assemblies (keyed by model URI). Used to seed the assembly /// dependency closure and may be empty. Targets supplied under the /// same C# prefix are normally skipped; when /// is enabled they are /// instead loaded and used to emit typed fluent accessors only. /// /// Optional [NodeManager] attribute bindings discovered in /// the consuming compilation. When supplied, each binding is /// matched to a design by /// (preferred) or, if no URI is given, by single-design fallback. /// Matched bindings force GenerateNodeManager = true for the /// design and override the manager class name and namespace. /// /// /// Optional callback invoked for each binding-related warning or /// error (e.g. unmatched URI, ambiguous fallback). Implementations /// typically convert these into Roslyn diagnostics. /// /// /// Optional caller-owned set that accumulates the bindings matched /// across multiple generation passes (NodeSet2 and ModelDesign). /// When supplied, this pass records its matches in the set and does /// not report unmatched bindings itself; the caller reports /// them once, after all passes, via /// . When null /// the pass keeps a private set and reports directly (single pass). /// /// /// Total number of generatable models across all passes, used for /// single-model binding fallback and ambiguity detection. When /// 0 the per-pass model count is used (single-pass callers). /// /// /// Optional callback invoked when a model cannot participate in /// fluent-accessors-only generation. Arguments are model URI, /// requested prefix, input path, and failure reason. /// /// /// All model dependency declarations from referenced assemblies, /// including payload-bearing producers and payloadless re-exports. /// /// /// All referenced assemblies that declare generated fluent accessors. /// public static void GenerateCode( this DesignFileCollection designFiles, IFileSystem fileSystem, string outputDir, ITelemetryContext telemetry, GeneratorOptions? options, bool useAllowSubtypes, List identifierFiles, IReadOnlyDictionary? referencedModels, IReadOnlyList? nodeManagerBindings, Action reportBindingDiagnostic, HashSet? sharedUsedBindings, int bindingModelCount, Action? reportFluentAccessorsOnlyDiagnostic, IReadOnlyList? referencedModelProviders, IReadOnlyList? referencedAccessorProviders) { if (designFiles.Targets == null || designFiles.Targets.Count == 0) { return; } options ??= new GeneratorOptions(); referencedModels ??= ImmutableDictionary.Empty; referencedModelProviders ??= [.. referencedModels.Values]; referencedAccessorProviders ??= []; // The dependency payloads are carried inside the referenced // model attributes; decode them here so a design file can // resolve upstream types (e.g. subtype a structure) without an // AdditionalFiles entry for the upstream model. IReadOnlyDictionary referencedDependencies = BuildReferencedDependencyMap(referencedModels); // Combine with embedded resources in this assembly. fileSystem = typeof(Generators).Assembly .AsFileSystem("Opc.Ua.SourceGeneration.Design") .WithFallback(fileSystem); HashSet? usedBindings = sharedUsedBindings ?? (nodeManagerBindings is { Count: > 0 } ? [] : null); bool deferBindingDiagnostics = sharedUsedBindings != null; int totalDesigns = bindingModelCount > 0 ? bindingModelCount : designFiles.Targets.Count; foreach (DesignFileCollection model in designFiles.Group(identifierFiles)) { options.Cancellation.ThrowIfCancellationRequested(); IModelDesign modelDesign = fileSystem.OpenModelDesign( model, options.Exclusions, telemetry, useAllowSubtypes, referencedDependencies); // Override resolution: if a referenced assembly already // provides this model under the same C# prefix, silently // skip local generation to avoid duplicate type emission. Namespace target = modelDesign.TargetNamespace; bool targetProvidedByReference = target != null && !string.IsNullOrEmpty(target.Value) && referencedModels.TryGetValue(target.Value, out ModelDependencyReference referenced) && string.Equals(referenced.Prefix, target.Prefix, StringComparison.Ordinal); DesignFileOptions? effectiveOptions = ApplyNodeManagerBinding( model, modelDesign, nodeManagerBindings!, usedBindings, totalDesigns, out bool boundToNodeManager); // A [NodeManager] bound to a model that a reference already // supplies is the one reason to keep processing such a // design: the manager and its fluent surface are emitted // here while the model types keep coming from the // reference, exactly as in fluent-accessors-only mode. bool accessorsOnly = options.FluentAccessorsOnly || (targetProvidedByReference && boundToNodeManager); ValidateClientAccessorsOnly(options, targetProvidedByReference, boundToNodeManager, target?.Value); if (targetProvidedByReference && !accessorsOnly && !options.ClientAccessorsOnly) { continue; } // Cross-namespace prefix override: when a referenced // assembly publishes a model under a specific C# prefix, // rewrite any matching dependency namespace in the loaded // ModelDesign to use that prefix. Without this, NodeSet2 // inputs (which auto-generate prefixes via // NodeSetToModelDesign) emit references like // `global::Opc.Ua.DI.X` instead of `global::Opc.Ua.Di.X`. OverrideDependencyPrefixes(modelDesign, referencedModels); EnsureUniqueTargetNamespaceName(modelDesign); string modelPath = model.Targets.Count > 0 ? model.Targets[0] : string.Empty; bool emitFluentAccessors = true; if (accessorsOnly && !CanEmitFluentAccessorsOnly( (target?.Value)!, (target?.Prefix)!, modelPath, options, effectiveOptions!, referencedModelProviders, referencedAccessorProviders, reportFluentAccessorsOnlyDiagnostic!)) { // The [NodeManager] binding asked for the manager, which // does not depend on (re-)emitting the accessors: emit it // without them instead of dropping the whole binding. if (!(targetProvidedByReference && boundToNodeManager)) { continue; } emitFluentAccessors = false; } var context = new GeneratorContext { FileSystem = fileSystem, OutputFolder = outputDir, ModelDesign = modelDesign, Telemetry = telemetry, Options = options, ReferencedModels = referencedModels }; Generate( context, validateSchemas: false, designOptions: effectiveOptions, accessorsOnly: accessorsOnly, emitFluentAccessors: emitFluentAccessors); // When the model itself is supplied by a referenced // assembly, that assembly carries its event records too. // Emitting them again here would duplicate every record // type the reference already exports (CS0436). if (!options.OmitEventRecords && !accessorsOnly && !options.ClientAccessorsOnly) { new EventRecordGenerator(context).Emit(); } } if (!deferBindingDiagnostics) { ReportUnmatchedNodeManagerBindings( nodeManagerBindings, usedBindings, totalDesigns, reportBindingDiagnostic); } } /// /// Override the C# of dependency /// namespaces in with the prefix /// published by the referenced assembly's /// [assembly: ModelDependencyAttribute]. This guarantees /// that cross-namespace type references emitted by the generator /// resolve to the actual C# namespace the referenced assembly /// uses (e.g. Opc.Ua.Di), not the auto-generated default /// (e.g. Opc.Ua.DI) produced by /// when no ModelDesign XML is /// available for the dependency. /// /// /// The target namespace (the one currently being generated) is /// intentionally left untouched; otherwise the generator would /// emit references into someone else's assembly. /// internal static void OverrideDependencyPrefixes( IModelDesign modelDesign, IReadOnlyDictionary referencedModels) { if (modelDesign?.Namespaces == null || referencedModels == null || referencedModels.Count == 0) { return; } string? targetUri = modelDesign.TargetNamespace?.Value; foreach (Namespace ns in modelDesign.Namespaces) { if (ns == null || string.IsNullOrEmpty(ns.Value)) { continue; } if (string.Equals(ns.Value, targetUri, StringComparison.Ordinal)) { continue; } if (!referencedModels.TryGetValue(ns.Value, out ModelDependencyReference dep) || !dep.IsValid) { continue; } if (!string.Equals(ns.Prefix, dep.Prefix, StringComparison.Ordinal)) { ns.Prefix = dep.Prefix; } // Also align the namespace Name with the referenced assembly's // Namespaces class identifier so that cross-namespace constant // references like `global::{Prefix}.Namespaces.{Name}` resolve. if (!string.IsNullOrEmpty(dep.Name) && !string.Equals(ns.Name, dep.Name, StringComparison.Ordinal)) { ns.Name = dep.Name; } } } internal static void EnsureUniqueTargetNamespaceName(IModelDesign modelDesign) { Namespace? target = modelDesign?.TargetNamespace; if (target == null || string.IsNullOrEmpty(target.Value) || string.IsNullOrEmpty(target.Name) || modelDesign!.Namespaces == null) { return; } bool duplicate = modelDesign.Namespaces.Any(ns => ns != null && !string.Equals(ns.Value, target.Value, StringComparison.Ordinal) && string.Equals(ns.Name, target.Name, StringComparison.Ordinal)); if (!duplicate) { return; } string? candidate = CreateNamespaceConstantName(target.Prefix); if (string.IsNullOrEmpty(candidate) || string.Equals(candidate, target.Name, StringComparison.Ordinal)) { candidate = target.Name + "Namespace"; } HashSet usedNames = [.. modelDesign.Namespaces .Where(ns => ns != null && !string.Equals(ns.Value, target.Value, StringComparison.Ordinal) && !string.IsNullOrEmpty(ns.Name)) .Select(ns => ns.Name)]; string uniqueName = candidate; int suffix = 2; while (usedNames.Contains(uniqueName)) { uniqueName = candidate + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture); suffix++; } target.Name = uniqueName; foreach (Namespace ns in modelDesign.Namespaces.Where(ns => ns != null && string.Equals(ns.Value, target.Value, StringComparison.Ordinal))) { ns.Name = uniqueName; } } private static string? CreateNamespaceConstantName(string prefix) { if (string.IsNullOrEmpty(prefix)) { return null; } // The parts are concatenated into one identifier, so a part must // not keep the '@' ToSafeSymbolName escapes a keyword with - it // would land mid-identifier ("Acme@base"). Upper-casing the first // letter already takes every part out of the (lower case) keywords. string[] parts = prefix.Split(['.', '-', '_', '/', ':'], StringSplitOptions.RemoveEmptyEntries); string name = string.Concat(parts.Select(part => part.ToSafeSymbolName().TrimStart('@').ToUpperCamelCase())); return string.IsNullOrEmpty(name) ? null : name.ToCSharpIdentifierPreserveCase(); } /// /// Returns whether a fluent-accessors-only build has a target it can /// legitimately extend, reporting why through /// when it does not. /// /// /// Internal rather than private so the outcomes can be asserted /// directly; running the whole generator to observe a single /// validation branch would say much less about which branch fired. /// /// The target model URI. /// The target C# prefix. /// The design file path, for diagnostics. /// /// Referenced assemblies that produce models. /// /// /// Referenced assemblies that already produce fluent accessors. /// /// /// Receives model URI, prefix, path and the reason on rejection. /// /// True when the target may be extended. internal static bool ValidateFluentAccessorsOnlyTarget( string modelUri, string prefix, string path, IReadOnlyList referencedModelProviders, IReadOnlyList referencedAccessorProviders, Action? reportDiagnostic) { if (string.IsNullOrEmpty(modelUri)) { reportDiagnostic?.Invoke( modelUri ?? string.Empty, prefix ?? string.Empty, path ?? string.Empty, "no referenced assembly supplies the target model URI."); return false; } ModelFluentAccessorProviderReference accessorProvider = referencedAccessorProviders.FirstOrDefault(provider => provider.IsValid && string.Equals(provider.ModelUri, modelUri, StringComparison.Ordinal) && string.Equals(provider.Prefix, prefix, StringComparison.Ordinal)); if (accessorProvider.IsValid) { reportDiagnostic?.Invoke( modelUri, prefix ?? string.Empty, path ?? string.Empty, "referenced assembly '" + accessorProvider.AssemblyName + "' already provides generated fluent accessors."); return false; } var producers = new List<(ModelDependencyReference Reference, Dependency.ModelDependencyV1 Dependency)>(); foreach (ModelDependencyReference reference in referencedModelProviders.Where( reference => reference.IsValid && string.Equals(reference.ModelUri, modelUri, StringComparison.Ordinal))) { Dependency.ModelDependencyV1? dependency = reference.GetDependency(); if (string.Equals(reference.Prefix, prefix, StringComparison.Ordinal) && !string.IsNullOrEmpty(reference.Payload) && (dependency == null || !string.Equals( dependency.ModelUri, modelUri, StringComparison.Ordinal))) { reportDiagnostic?.Invoke( modelUri, prefix ?? string.Empty, path ?? string.Empty, "model producer '" + reference.AssemblyName + "' has a malformed model dependency payload."); return false; } if (dependency != null && string.Equals(dependency.ModelUri, modelUri, StringComparison.Ordinal)) { producers.Add((reference, dependency)); } } List<(ModelDependencyReference Reference, Dependency.ModelDependencyV1 Dependency)> matchingProducers = [.. producers.Where(entry => string.Equals(entry.Reference.Prefix, prefix, StringComparison.Ordinal))]; if (matchingProducers.Count == 0) { string reason = producers.Count > 0 ? "model producer '" + producers[0].Reference.AssemblyName + "' supplies prefix '" + producers[0].Reference.Prefix + "'." : "no payload-bearing referenced model producer supplies the target model."; reportDiagnostic?.Invoke( modelUri, prefix ?? string.Empty, path ?? string.Empty, reason); return false; } foreach ((ModelDependencyReference reference, Dependency.ModelDependencyV1 dependency) in matchingProducers) { if (!dependency.FluentAccessorsEmitted.HasValue) { reportDiagnostic?.Invoke( modelUri, prefix ?? string.Empty, path ?? string.Empty, "model producer '" + reference.AssemblyName + "' has unknown legacy fluent-accessor capability."); return false; } if (dependency.FluentAccessorsEmitted.Value) { reportDiagnostic?.Invoke( modelUri, prefix ?? string.Empty, path ?? string.Empty, "model producer '" + reference.AssemblyName + "' already contains fluent accessors."); return false; } } return true; } /// /// Whether the fluent accessors of a model supplied by a reference may be /// emitted here. Diagnostics are only reported when fluent-accessors-only /// mode was requested explicitly; a run that exists only because of a /// [NodeManager] binding silently emits the manager without them. /// private static bool CanEmitFluentAccessorsOnly( string modelUri, string prefix, string path, GeneratorOptions options, DesignFileOptions designOptions, IReadOnlyList referencedModelProviders, IReadOnlyList referencedAccessorProviders, Action reportDiagnostic) { if (!options.FluentAccessorsOnly) { return ValidateFluentAccessorsOnlyTarget( modelUri, prefix, path, referencedModelProviders, referencedAccessorProviders, null); } return ValidateFluentAccessorsOnlyTarget( modelUri, prefix, path, referencedModelProviders, referencedAccessorProviders, reportDiagnostic) && ValidateFluentAccessorsOnlyOptions( modelUri, prefix, path, options, designOptions, reportDiagnostic); } private static bool ValidateFluentAccessorsOnlyOptions( string modelUri, string prefix, string path, GeneratorOptions options, DesignFileOptions designOptions, Action reportDiagnostic) { // NodeManager generation is deliberately *not* rejected here: // a manager and its fluent surface emit no model types, so they // compose with a model that a referenced assembly supplies. // OmitFluentApi still is — it asks for the opposite of what // this mode exists to produce. string? reason = options.OmitFluentApi ? "OmitFluentApi is also enabled." : null; if (reason == null) { return true; } reportDiagnostic?.Invoke( modelUri ?? string.Empty, prefix ?? string.Empty, path ?? string.Empty, reason); return false; } /// /// The design-file selector a [NodeManager] binding may /// name: the single input's file name without extension, or /// null when the model is composed from several files and /// no one name identifies it. /// private static string? DesignNameOf(IReadOnlyList targets) { return targets != null && targets.Count == 1 ? System.IO.Path.GetFileNameWithoutExtension(targets[0]) : null; } /// /// Finds the [NodeManager] binding that claims a model, by /// namespace URI, then by design file name, then — for a project /// with exactly one model and one selector-less binding — by /// fallback. Shared so a caller can learn a design is claimed /// before it has opened it. /// /// The matching binding, or null. private static NodeManagerAttributeBinding? MatchNodeManagerBinding( string? uri, string? designName, IReadOnlyList? bindings, int totalDesigns) { if (bindings == null || bindings.Count == 0) { return null; } // 1) exact URI match if (!string.IsNullOrEmpty(uri)) { NodeManagerAttributeBinding? byUri = bindings.FirstOrDefault(b => string.Equals(b.NamespaceUri, uri, StringComparison.Ordinal)); if (byUri != null) { return byUri; } } // 2) design file name match if (!string.IsNullOrEmpty(designName)) { NodeManagerAttributeBinding? byName = bindings.FirstOrDefault(b => !string.IsNullOrEmpty(b.Design) && string.Equals(b.Design, designName, StringComparison.OrdinalIgnoreCase)); if (byName != null) { return byName; } } // 3) single-design / single-binding fallback if (totalDesigns == 1 && bindings.Count == 1 && string.IsNullOrEmpty(bindings[0].NamespaceUri) && string.IsNullOrEmpty(bindings[0].Design)) { return bindings[0]; } return null; } /// /// Resolve the effective per-design options by overlaying any /// matching [NodeManager] attribute binding on top of the /// existing . /// private static DesignFileOptions? ApplyNodeManagerBinding( DesignFileCollection model, IModelDesign modelDesign, IReadOnlyList bindings, HashSet? usedBindings, int totalDesigns, out bool bound) { bound = false; DesignFileOptions? effective = model.Options; NodeManagerAttributeBinding? match = MatchNodeManagerBinding( modelDesign?.TargetNamespace?.Value, DesignNameOf(model.Targets), bindings, totalDesigns); if (match == null) { return effective; } usedBindings?.Add(match); bound = true; return (effective ?? new DesignFileOptions()) with { GenerateNodeManager = true, NodeManagerNamespace = match.TargetNamespace, NodeManagerClassName = match.TargetClassName, EmitNodeManagerFactory = match.GenerateFactory, EmitNodeManagerDefaultConstructor = match.GenerateDefaultConstructor, NodeManagerAdditionalNamespaceUris = match.AdditionalNamespaceUris }; } /// /// Report a diagnostic for every [NodeManager] binding that /// no generation pass matched. Binding resolution runs in two /// independent passes (NodeSet2 and ModelDesign); a binding matched /// by either pass is recorded in the shared /// set, so the report must be made /// once — after both passes — against that aggregated set. Reporting /// per pass would false-positive a binding matched by the other pass /// (issue #3937). A selector-less binding in a multi-model project is /// reported as an ambiguity (the user must add a NamespaceUri); /// everything else is reported as an unmatched selector. /// /// /// The full set of discovered [NodeManager] bindings. /// /// /// The bindings that were matched to a model across all passes. /// /// /// Total number of generatable models across all passes. /// /// /// Callback invoked for each unmatched or ambiguous binding. /// public static void ReportUnmatchedNodeManagerBindings( IReadOnlyList? bindings, ICollection? usedBindings, int totalModelCount, Action reportBindingDiagnostic) { if (bindings == null || bindings.Count == 0 || reportBindingDiagnostic == null) { return; } foreach (NodeManagerAttributeBinding binding in bindings) { if (usedBindings != null && usedBindings.Contains(binding)) { continue; } bool hasSelector = !string.IsNullOrEmpty(binding.NamespaceUri) || !string.IsNullOrEmpty(binding.Design); if (!hasSelector && totalModelCount > 1) { reportBindingDiagnostic( binding, "[NodeManager] on '" + binding.TargetNamespace + "." + binding.TargetClassName + "' has no NamespaceUri/Design selector but the " + "project contains multiple models. Specify " + "NamespaceUri to disambiguate."); continue; } string selector = !string.IsNullOrEmpty(binding.NamespaceUri) ? "NamespaceUri='" + binding.NamespaceUri + "'" : !string.IsNullOrEmpty(binding.Design) ? "Design='" + binding.Design + "'" : "(no selector)"; reportBindingDiagnostic( binding, "[NodeManager] on '" + binding.TargetNamespace + "." + binding.TargetClassName + "' did not match any model (" + selector + ")."); } } /// /// Generate from nodesets /// /// Nodesets to process /// File system abstraction to use /// Output folder or null /// Telemetry context for logging /// Generator options /// allow subtypes /// Models supplied by referenced assemblies. /// Optional node manager bindings. /// Optional binding diagnostic callback. /// Per-URI model dependency /// payloads. Leave null to derive them from the payloads /// carried by . /// Optional set that accumulates matched bindings. /// Total number of generatable models across all passes. public static void GenerateCode( this NodesetFileCollection nodesets, IFileSystem fileSystem, string outputDir, ITelemetryContext telemetry, GeneratorOptions? options = null, bool useAllowSubtypes = false, IReadOnlyDictionary? referencedModels = null, IReadOnlyList? nodeManagerBindings = null, Action? reportBindingDiagnostic = null, IReadOnlyDictionary? referencedDependencies = null, HashSet? sharedUsedBindings = null, int bindingModelCount = 0) { GenerateCode( nodesets, fileSystem, outputDir, telemetry, options, useAllowSubtypes, referencedModels, nodeManagerBindings, reportBindingDiagnostic!, referencedDependencies, sharedUsedBindings, bindingModelCount, null, null, null); } /// /// Generate from nodesets with fluent-accessor provider metadata. /// /// Nodesets to process /// File system abstraction to use /// Output folder or null /// Telemetry context for logging /// Generator options /// allow subtypes /// Models supplied by referenced /// assemblies (keyed by model URI). When a target's model URI /// is in this map under the same C# prefix the nodeset is normally /// skipped because the referenced assembly already supplies the /// types. When is /// enabled the target is instead loaded and used to emit typed /// fluent accessors only. Transitive nodeset dependencies found in /// the map are also satisfied without erroring. /// /// Optional [NodeManager] attribute bindings discovered in /// the consuming compilation. When supplied, each binding is /// matched to a nodeset model by /// (preferred) /// or, if no URI is given, by single-design fallback. Matched /// bindings force GenerateNodeManager = true for the model /// and override the manager class name and namespace. /// /// /// Optional callback invoked for each binding-related warning or /// error (e.g. unmatched URI, ambiguous fallback). /// /// /// Per-URI model dependency payloads recovered from referenced /// assemblies. The validator pre-imports them so downstream models /// can resolve upstream types without an explicit /// AdditionalFiles entry for them. Pass null to /// derive the map from the payloads already carried by /// . /// /// /// Optional caller-owned set that accumulates the bindings matched /// across multiple generation passes (NodeSet2 and ModelDesign). /// When supplied, this pass records its matches in the set and does /// not report unmatched bindings itself; the caller reports /// them once, after all passes, via /// . When null /// the pass keeps a private set and reports directly (single pass). /// /// /// Total number of generatable models across all passes, used for /// single-model binding fallback and ambiguity detection. When /// 0 the per-pass model count is used (single-pass callers). /// /// /// Optional callback invoked when a model cannot participate in /// fluent-accessors-only generation. Arguments are model URI, /// requested prefix, input path, and failure reason. /// /// /// All model dependency declarations from referenced assemblies, /// including payload-bearing producers and payloadless re-exports. /// /// /// All referenced assemblies that declare generated fluent accessors. /// public static void GenerateCode( this NodesetFileCollection nodesets, IFileSystem fileSystem, string outputDir, ITelemetryContext telemetry, GeneratorOptions? options, bool useAllowSubtypes, IReadOnlyDictionary? referencedModels, IReadOnlyList? nodeManagerBindings, Action reportBindingDiagnostic, IReadOnlyDictionary? referencedDependencies, HashSet? sharedUsedBindings, int bindingModelCount, Action? reportFluentAccessorsOnlyDiagnostic, IReadOnlyList? referencedModelProviders, IReadOnlyList? referencedAccessorProviders) { if (nodesets.Files.Count == 0) { return; } options ??= new GeneratorOptions(); referencedModels ??= ImmutableDictionary.Empty; referencedModelProviders ??= [.. referencedModels.Values]; referencedAccessorProviders ??= []; // The payloads are carried inside the referenced model // attributes, so a caller that already supplies the models does // not have to decode and thread the map as well. referencedDependencies ??= BuildReferencedDependencyMap(referencedModels); // Combine with embedded resources in this assembly. fileSystem = typeof(Generators).Assembly .AsFileSystem("Opc.Ua.SourceGeneration.Design") .WithFallback(fileSystem); HashSet? usedBindings = sharedUsedBindings ?? (nodeManagerBindings is { Count: > 0 } ? [] : null); bool deferBindingDiagnostics = sharedUsedBindings != null; int totalDesigns = bindingModelCount > 0 ? bindingModelCount : nodesets.ModelUris.Count(); foreach (string modelUri in nodesets.ModelUris) { options.Cancellation.ThrowIfCancellationRequested(); List? designFilesForModel = nodesets.GetDesignFileListForModel( modelUri, out NodesetFile nodeset, referencedModels); if (designFilesForModel == null || nodeset.Info!.Ignore) { continue; } // Override resolution: if a referenced assembly already // provides this model under the same C# prefix, silently // skip local generation to avoid duplicate type emission. bool targetProvidedByReference = referencedModels.TryGetValue(modelUri, out ModelDependencyReference referenced) && string.Equals(referenced.Prefix, nodeset.Info.Prefix, StringComparison.Ordinal); // A [NodeManager] bound to a model that a reference already // supplies is the one reason to keep processing such an // input: the manager and its fluent surface are emitted // here while the model types keep coming from the // reference. Matching before the model is opened, because // the answer decides how to open it. bool boundToNodeManager = MatchNodeManagerBinding( modelUri, DesignNameOf(designFilesForModel), nodeManagerBindings, totalDesigns) != null; bool accessorsOnly = options.FluentAccessorsOnly || (targetProvidedByReference && boundToNodeManager); ValidateClientAccessorsOnly(options, targetProvidedByReference, boundToNodeManager, modelUri); if (targetProvidedByReference && !accessorsOnly && !options.ClientAccessorsOnly) { continue; } // The rest of the input is processed as design files var model = new DesignFileCollection { Targets = designFilesForModel }; IReadOnlyDictionary validationDependencies = referencedDependencies; if ((accessorsOnly || options.ClientAccessorsOnly) && referencedDependencies.ContainsKey(modelUri)) { Dictionary dependenciesWithoutTarget = referencedDependencies.ToDictionary( entry => entry.Key, entry => entry.Value, StringComparer.Ordinal); dependenciesWithoutTarget.Remove(modelUri); validationDependencies = dependenciesWithoutTarget; } IModelDesign modelDesign = fileSystem.OpenModelDesign( model, options.Exclusions, telemetry, useAllowSubtypes, validationDependencies); // Cross-namespace prefix override: when a referenced // assembly publishes a model under a specific C# prefix, // rewrite any matching dependency namespace so that // generated type references resolve against the referenced // assembly's actual prefix (not the auto-generated one). OverrideDependencyPrefixes(modelDesign, referencedModels); EnsureUniqueTargetNamespaceName(modelDesign); DesignFileOptions? effectiveOptions = ApplyNodeManagerBinding( model, modelDesign, nodeManagerBindings!, usedBindings, totalDesigns, out _); bool emitFluentAccessors = true; if (accessorsOnly && !CanEmitFluentAccessorsOnly( modelUri, nodeset.Info.Prefix!, nodeset.FileName!, options, effectiveOptions!, referencedModelProviders, referencedAccessorProviders, reportFluentAccessorsOnlyDiagnostic!)) { // See the design-file path: a bound manager is still emitted. if (!(targetProvidedByReference && boundToNodeManager)) { continue; } emitFluentAccessors = false; } var context = new GeneratorContext { FileSystem = fileSystem, OutputFolder = outputDir, ModelDesign = modelDesign, Telemetry = telemetry, Options = options, ReferencedModels = referencedModels }; Generate( context, validateSchemas: false, designOptions: effectiveOptions, accessorsOnly: accessorsOnly, emitFluentAccessors: emitFluentAccessors); // When the model itself is supplied by a referenced // assembly, that assembly carries its event records too. // Emitting them again here would duplicate every record // type the reference already exports (CS0436). if (!options.OmitEventRecords && !accessorsOnly && !options.ClientAccessorsOnly) { new EventRecordGenerator(context).Emit(); } } if (!deferBindingDiagnostics) { ReportUnmatchedNodeManagerBindings( nodeManagerBindings, usedBindings, totalDesigns, reportBindingDiagnostic); } } /// /// Build a per-URI dictionary of the deserialised model dependency /// payloads carried by the referenced model attributes. Payloads /// with unknown versions or malformed encodings are silently /// dropped (the validator then falls back to explicit /// AdditionalFiles resolution). /// private static IReadOnlyDictionary BuildReferencedDependencyMap( IReadOnlyDictionary referencedModels) { if (referencedModels.Count == 0) { return ImmutableDictionary.Empty; } var map = new Dictionary( StringComparer.Ordinal); foreach (KeyValuePair entry in referencedModels) { Dependency.ModelDependencyV1? decoded = entry.Value.GetDependency(); if (decoded == null || !string.Equals(decoded.ModelUri, entry.Key, StringComparison.Ordinal)) { continue; } map[entry.Key] = decoded; } return map; } /// /// Generate the .net stack code /// /// Generator type /// The root file system to use /// Output folder or null /// A telemetry context for logging /// Generator options public static void GenerateStack( StackGenerationType generatorType, IFileSystem fileSystem, string outputDir, ITelemetryContext telemetry, GeneratorOptions? options = null) { options ??= new GeneratorOptions(); options.Cancellation.ThrowIfCancellationRequested(); // Combine with embedded resources in this assembly. fileSystem = typeof(Generators).Assembly .AsFileSystem("Opc.Ua.SourceGeneration.Design") .WithFallback(fileSystem); IModelDesign modelDesign = fileSystem.OpenModelDesign( new DesignFileCollection { Targets = [ BuiltInDesignFiles.StandardTypesXml, BuiltInDesignFiles.UACoreServicesXml ], IdentifierFilePath = BuiltInDesignFiles.StandardTypesCsv, Options = new DesignFileOptions { StartId = 0, ModelVersion = "1.05.06", ModelPublicationDate = "2025-11-08", ReleaseCandidate = true } }, options.Exclusions, telemetry, false); var generatorContext = new GeneratorContext { FileSystem = fileSystem, OutputFolder = outputDir, ModelDesign = modelDesign, Telemetry = telemetry, Options = options }; if ((generatorType & StackGenerationType.Stack) != 0) { var clientApiGenerator = new ClientApiGenerator(generatorContext); clientApiGenerator.Emit(); options.Cancellation.ThrowIfCancellationRequested(); var serverApiGenerator = new ServerApiGenerator(generatorContext); serverApiGenerator.Emit(); options.Cancellation.ThrowIfCancellationRequested(); var endpointsGenerator = new EndpointsGenerator(generatorContext); endpointsGenerator.Emit(); options.Cancellation.ThrowIfCancellationRequested(); // Emit ObjectType client proxies for every standard UA // ObjectType so downstream model proxies (e.g. GDS) can // derive from them. Proxies are emitted into the model's // own namespace (Opc.Ua for the standard NodeSet) — no // namespace override. Suppressed when the consumer opts // out via OmitObjectTypeProxies. if (!options.OmitObjectTypeProxies) { var stackProxyContext = new GeneratorContext { FileSystem = generatorContext.FileSystem, OutputFolder = generatorContext.OutputFolder, ModelDesign = generatorContext.ModelDesign, Telemetry = generatorContext.Telemetry, Options = new GeneratorOptions { OptimizeForCompileSpeed = options.OptimizeForCompileSpeed, Exclusions = options.Exclusions, Cancellation = options.Cancellation, UseUtf8StringLiterals = options.UseUtf8StringLiterals } }; var stackProxyGenerator = new ObjectTypeProxyGenerator(stackProxyContext); stackProxyGenerator.Emit(); options.Cancellation.ThrowIfCancellationRequested(); } // Event records depend on EventRecord and decoder runtime // types in Opc.Ua.Core. Emit them only for the Stack target; // the Models target builds Opc.Ua.Core.Types and cannot // reference Opc.Ua.Core without creating a cycle. var stackRecordContext = new GeneratorContext { FileSystem = generatorContext.FileSystem, OutputFolder = generatorContext.OutputFolder, ModelDesign = generatorContext.ModelDesign, Telemetry = generatorContext.Telemetry, Options = new GeneratorOptions { OptimizeForCompileSpeed = options.OptimizeForCompileSpeed, Exclusions = options.Exclusions, Cancellation = options.Cancellation, UseUtf8StringLiterals = options.UseUtf8StringLiterals } }; if (!options.OmitEventRecords) { new EventRecordGenerator(stackRecordContext).Emit(); } } if ((generatorType & StackGenerationType.Models) != 0) { var attributesGenerator = new AttributesGenerator(generatorContext); attributesGenerator.Emit(); options.Cancellation.ThrowIfCancellationRequested(); var statusCodesGenerator = new StatusCodesGenerator(generatorContext); statusCodesGenerator.Emit(); options.Cancellation.ThrowIfCancellationRequested(); var serverCapabilitiesGenerator = new ServerCapabilitiesGenerator(generatorContext); serverCapabilitiesGenerator.Emit(); options.Cancellation.ThrowIfCancellationRequested(); Generate(generatorContext, !options.OptimizeForCompileSpeed); } } /// /// Generates all files /// /// /// Emits the bound NodeManager (and its factory) for a /// design. Shared by the full-model and accessors-only paths so /// both produce an identical manager. /// private static void EmitNodeManager( GeneratorContext context, DesignFileOptions designOptions) { new NodeManagerGenerator(context) { OverrideNamespace = designOptions.NodeManagerNamespace, OverrideClassName = designOptions.NodeManagerClassName, EmitFactory = designOptions.EmitNodeManagerFactory, EmitDefaultConstructor = designOptions.EmitNodeManagerDefaultConstructor, AdditionalNamespaceUris = designOptions.NodeManagerAdditionalNamespaceUris }.Emit(); } /// /// Rejects client accessor generation without its model producer or with conflicting output modes. /// private static void ValidateClientAccessorsOnly( GeneratorOptions options, bool targetProvidedByReference, bool boundToNodeManager, string? modelUri) { if (!options.ClientAccessorsOnly) { return; } if (!targetProvidedByReference) { throw new InvalidOperationException( $"Client accessor-only generation requires a referenced producer for model '{modelUri}' " + "with the same C# prefix."); } if (options.FluentAccessorsOnly || boundToNodeManager) { throw new InvalidOperationException( "Client accessor-only generation cannot be combined with fluent accessors or a node manager."); } } private static void Generate( GeneratorContext context, bool validateSchemas = false, DesignFileOptions? designOptions = null, bool accessorsOnly = false, bool emitFluentAccessors = true) { // Cancellation is observed between generators: each emitter is a // bounded unit of work, and checking inside their node loops would // put the check on the hot path for little gain. CancellationToken ct = context.Options?.Cancellation ?? default; ct.ThrowIfCancellationRequested(); if (context.Options?.ClientAccessorsOnly == true) { new ClientAccessorGenerator(context).Emit(); return; } // The model types live in a referenced assembly: emit the // fluent surface over them, and the node manager when one was // bound, but none of the model itself. if (accessorsOnly || context.Options?.FluentAccessorsOnly == true) { bool generateManager = designOptions?.GenerateNodeManager == true; if (generateManager) { // The manager wires NodeSet imports through the model's // import factory provider. That provider implements an // Opc.Ua.Server contract, so the model-only assembly that // owns the types cannot emit it; it is emitted here, into // the assembly that does reference Opc.Ua.Server, under // the model's own namespace so the manager resolves it. new NodeStateGenerator(context) { GenerateNodeSetImportSupport = true, ImportSupportOnly = true }.Emit(); ct.ThrowIfCancellationRequested(); EmitNodeManager(context, designOptions!); ct.ThrowIfCancellationRequested(); } new FluentBuilderGenerator(context) { OverrideManagerNamespace = designOptions?.NodeManagerNamespace, OverrideManagerClassName = designOptions?.NodeManagerClassName, GenerateManagerWrappers = generateManager, EmitFluentAccessors = emitFluentAccessors }.Emit(); return; } // Generate schemas var xmlSchemaGenerator = new XmlSchemaGenerator(context) { ValidateOutput = validateSchemas }; IEnumerable xmlSchemaResource = xmlSchemaGenerator.Emit(); ct.ThrowIfCancellationRequested(); var binarySchemaGenerator = new BinarySchemaGenerator(context) { ValidateOutput = validateSchemas }; IEnumerable binarySchemaResource = binarySchemaGenerator.Emit(); var schemaResources = new ResourceGenerator(context); schemaResources.Embed( context.ModelDesign.TargetNamespace.Prefix, "XmlSchemas", false, [.. binarySchemaResource, .. xmlSchemaResource]); ct.ThrowIfCancellationRequested(); // Must run after schema generation to initilize the dictionaries. var constantsGenerator = new ConstantsGenerator(context); constantsGenerator.Emit(); ct.ThrowIfCancellationRequested(); var nodeIdGenerator = new NodeIdGenerator(context); nodeIdGenerator.Emit(); ct.ThrowIfCancellationRequested(); var nodeStateCodeGenerator = new NodeStateGenerator(context) { // The provider references Opc.Ua.Server contracts, so it is // only emitted for models that also generate a node manager. GenerateNodeSetImportSupport = designOptions?.GenerateNodeManager == true }; nodeStateCodeGenerator.Emit(); ct.ThrowIfCancellationRequested(); var dataTypesGenerator = new DataTypeGenerator(context); dataTypesGenerator.Emit(); ct.ThrowIfCancellationRequested(); if (designOptions?.GenerateNodeManager == true) { EmitNodeManager(context, designOptions); ct.ThrowIfCancellationRequested(); } // FluentBuilderGenerator emits per-ObjectType typed-accessor // extension classes by default. Model-only assemblies that // don't reference Opc.Ua.Server can opt out via // GeneratorOptions.OmitFluentApi (or the MSBuild property // ModelSourceGeneratorOmitFluentApi=true). When // GenerateNodeManager=true we ALWAYS emit (any consumer // that wires a node manager already references // Opc.Ua.Server, so suppression is unnecessary). bool emitTypedAccessors = designOptions?.GenerateNodeManager == true || context.Options?.OmitFluentApi != true; if (emitTypedAccessors) { new FluentBuilderGenerator(context) { OverrideManagerNamespace = designOptions?.NodeManagerNamespace, OverrideManagerClassName = designOptions?.NodeManagerClassName, GenerateManagerWrappers = designOptions?.GenerateNodeManager == true, EmitFluentAccessors = emitTypedAccessors }.Emit(); ct.ThrowIfCancellationRequested(); } if (context.Options?.OmitObjectTypeProxies != true) { var objectTypeProxyGenerator = new ObjectTypeProxyGenerator(context); objectTypeProxyGenerator.Emit(); ct.ThrowIfCancellationRequested(); } if (context.Options?.OmitStateMachineIds != true) { var stateMachineIdsGenerator = new StateMachineIdsGenerator(context); stateMachineIdsGenerator.Emit(); ct.ThrowIfCancellationRequested(); } if (context.Options?.EmitDependencyMetadata != false) { var modelDependencyGenerator = new ModelDependencyGenerator( context, emitTypedAccessors); modelDependencyGenerator.Emit(); } } } }