/* ======================================================================== * 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.Globalization; using System.Linq; using System.Text; using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Opc.Ua.SourceGeneration.Dependency; namespace Opc.Ua.SourceGeneration { /// /// Compiles the model. This honors the options of the compile command /// of the tool. However, it does not generate identifier files and /// assumes that if identifiers are required they are loaded from a /// csv file that is side by side with the design files. Also, this /// does not produce xsd or bsd nor nodeset files, which cannot be /// added to a compilation using roslyn source generator (yet). /// internal sealed class ModelCompilation { /// /// Create compilation /// public ModelCompilation( SourceProductionContext context, ImmutableArray<(AdditionalText, NodesetFileOptions)> inputFiles, ImmutableArray csvFiles, ImmutableArray identifierFiles, ModelCompilationOptions options, CompilationOptions compilationOptions, ImmutableArray referencedModels, ImmutableArray referencedAccessorProviders, ImmutableArray nodeManagerBindings, ImmutableHashSet availableStateTypeNames) { m_context = context; m_input = inputFiles; m_csvFiles = csvFiles; m_identifierFiles = identifierFiles; m_options = options; m_compilationOptions = compilationOptions; m_nodeManagerBindings = nodeManagerBindings; m_referencedModels = referencedModels; m_referencedAccessorProviders = referencedAccessorProviders; m_availableStateTypeNames = availableStateTypeNames; m_telemetry = SourceGeneratorTelemetry.Create(m_context); } /// /// Perform the compilation /// public void Emit(CancellationToken cancellationToken) { if (!CheckCompilationOptions()) { return; } var sourceFiles = new SourceGeneratorFileSystem( m_input.Select(i => i.Item1).Concat(m_csvFiles)); using var vfs = new VirtualFileSystem(); // Use a virtual file sytem try { if (m_input.Length == 0) { // Nothing to do return; } if (m_options.ClientAccessorsOnly && m_options.Options.GenerateNodeManager) { throw new InvalidOperationException( "Client accessor-only generation cannot be combined with node manager generation."); } string[] exclusions = [.. m_options.Exclude .Append("Draft") .Distinct()]; var generatorOptions = new GeneratorOptions { Cancellation = cancellationToken, Exclusions = exclusions, // csharp10 or below does not support utf8 string literals UseUtf8StringLiterals = m_compilationOptions.LanguageVersion >= LanguageVersion.CSharp11, OptimizeForCompileSpeed = m_compilationOptions.OptimizationLevel == OptimizationLevel.Debug, OmitObjectTypeProxies = m_options.OmitObjectTypeProxies, ObjectTypeProxyNamespace = string.IsNullOrWhiteSpace(m_options.ObjectTypeProxyNamespace) ? null : m_options.ObjectTypeProxyNamespace, UseTypeDefinitionModellingRules = m_options.UseTypeDefinitionModellingRules, EmitNodeSetNodesOnly = m_options.EmitNodeSetNodesOnly, EmitDependencyMetadata = ResolveEmitDependencyMetadata(), OmitFluentApi = m_options.OmitFluentApi, FluentAccessorsOnly = m_options.FluentAccessorsOnly, ClientAccessorsOnly = m_options.ClientAccessorsOnly, ClientAccessorNamespace = m_options.ClientAccessorNamespace, ClientAccessorClassName = m_options.ClientAccessorClassName, OmitEventRecords = m_options.OmitEventRecords }; // Load all available nodeset files from the input NodesetFileCollection nodesets = m_input.ToNodeSetFileCollection( m_csvFiles, sourceFiles, // .WithFallback(vfs), m_telemetry); ReportNodesetIdentifierDiagnostics(nodesets.IdentifierValidationErrors); // Resolve [NodeManager] bindings: validate partial-ness and // build the binding list to pass into both GenerateCode calls // (nodeset-derived and design-file-derived). var bindings = new List(); var bindingByPayload = new Dictionary(); foreach (NodeManagerAttributeDiscovery discovery in m_nodeManagerBindings) { // An invalid discovery is reported by // NodeManagerAttributeDiscovery.ReportDiagnostics, in a // separate output that has the compilation to report it // at a source location. if (discovery == null || discovery.IsInvalid) { continue; } bindings.Add(discovery.Binding); bindingByPayload[discovery.Binding] = discovery; } void reportBinding(NodeManagerAttributeBinding binding, string message) { // Reported while the models are generated, which does not // depend on the compilation (to stay cached), so there is // no syntax tree to report at: an external location. Location loc = bindingByPayload.TryGetValue(binding, out NodeManagerAttributeDiscovery d) && d != null ? d.Location.ToLocation() : Location.None; m_context.ReportDiagnostic( Diagnostic.Create( SourceGenerator.NodeManagerBindingError, loc, message)); } void reportFluentAccessorsOnly( string modelUri, string prefix, string path, string reason) { m_context.ReportDiagnostic( Diagnostic.Create( SourceGenerator.FluentAccessorsOnlyModelError, Location.None, modelUri, prefix, path, reason)); } // Reduce referenced model attributes to a single dictionary by // model URI (with tie-break on highest version+publication date) // so the downstream generators can apply override resolution // and decode the carried dependency payloads. IReadOnlyDictionary referencedModels = BuildReferencedModelMap(); // The design files that are not NodeSet2 inputs form the // ModelDesign pass. Compute them up front so the total model // count (NodeSet2 models + ModelDesign targets) can be shared // with both passes: [NodeManager] bindings are resolved across // both passes, so single-model fallback / ambiguity detection // must see the global model count, not the per-pass count. // Use a set for O(1) membership tests instead of an O(n) // ContainsValue scan per input (Ordinal preserves the previous // dictionary-value equality semantics exactly). // AllFilePaths, not Files.Values: Files exposes one entry per // model URI, so a superseded NodeSet2 version would not be // recognised as a NodeSet input and would be picked up as a // ModelDesign target and generated a second time. var nodesetPaths = new HashSet( nodesets.AllFilePaths, StringComparer.Ordinal); // Distinct: a project can list the same file as an AdditionalFile // more than once (a glob overlapping an explicit item). The file // system dedupes its own view, but a repeated design target is // generated twice and the second AddSource throws on the // duplicate hint name - and it also inflates totalModelCount, // which decides the single-model [NodeManager] fallback. List designInputs = [.. m_input .Where(f => !nodesetPaths.Contains(f.Item1.Path)) .Select(f => f.Item1.Path) .Distinct(StringComparer.Ordinal)]; // A design marked ModelSourceGeneratorIgnore is, like an // ignored NodeSet2 input, only there to resolve references // of the other inputs: it stays a dependency but is not // generated, or its types would be emitted a second time // next to the assembly that already provides them. var ignoredDesigns = new HashSet( m_input.Where(f => f.Item2?.Ignore == true).Select(f => f.Item1.Path), StringComparer.Ordinal); List designTargets = [.. designInputs .Where(path => !ignoredDesigns.Contains(path))]; var designDependencies = new List(nodesets.DesignFileEntries); designDependencies.AddRange(designInputs); // A CSV that a NodeSet claims through its IdentifierFile metadata // is that NodeSet's sidecar. Left in the list, the ModelDesign // pass adopts it as the identifier file of a design that has no // same-named CSV and is the only design in the folder, and the // design silently takes the NodeSet's numeric ids. var claimedIdentifierFiles = new HashSet( nodesets.IdentifierFilePaths, StringComparer.Ordinal); List designIdentifierFiles = [.. m_identifierFiles .Select(i => i.Path) .Where(path => !claimedIdentifierFiles.Contains(path))]; // A [NodeManager] may bind to a model produced by either pass // (a NodeSet2 type model or a ModelDesign instance model). The // "used" set is therefore shared across both passes and the // unmatched-binding diagnostics are reported once, after both // passes — reporting per pass would false-positive a binding // that the other pass matched (issue #3937). HashSet usedBindings = bindings.Count > 0 ? [] : null; int totalModelCount = nodesets.ModelUris.Count() + designTargets.Count; // Enter the standard method-state fallback scope for the whole // model-generation pass: a reference to a standard // global::Opc.Ua.*MethodState class degrades to the base // MethodState only when the typed class is neither present in // the compilation nor declared by this pass. The Stack // generator (which builds Opc.Ua.Core) never enters this scope. using IDisposable fallbackScope = StandardMethodStateFallback.Enter(m_availableStateTypeNames); nodesets.GenerateCode( sourceFiles.WithFallback(vfs), string.Empty, m_telemetry, generatorOptions, m_options.UseAllowSubtypes, referencedModels, bindings.Count > 0 ? bindings : null, bindings.Count > 0 ? reportBinding : null, // Derived from referencedModels, which carries the payloads. null, usedBindings, totalModelCount, reportFluentAccessorsOnly, m_referencedModels, m_referencedAccessorProviders); // Process the remaining design files. Every NodeSet2 input // (encoded with the prefix/name computed by the nodeset // pass) and every other ModelDesign input is supplied as a // dependency so cross-model references resolve — both // ModelDesign -> NodeSet2 (e.g. an instance whose // TypeDefinition is a NodeSet2-defined ObjectType) and // ModelDesign -> ModelDesign across directories. The // referenced models carry the upstream dependency payloads, // so a design file can also subtype a structure of a // referenced model that is not present in AdditionalFiles. new DesignFileCollection { Targets = designTargets, Dependencies = designDependencies, Options = m_options.Options }.GenerateCode( sourceFiles.WithFallback(vfs), string.Empty, m_telemetry, generatorOptions, m_options.UseAllowSubtypes, designIdentifierFiles, referencedModels, bindings.Count > 0 ? bindings : null, bindings.Count > 0 ? reportBinding : null, usedBindings, totalModelCount, reportFluentAccessorsOnly, m_referencedModels, m_referencedAccessorProviders); // Report any [NodeManager] bindings that neither pass matched, // once, against the shared used-set aggregated across passes. Generators.ReportUnmatchedNodeManagerBindings( bindings, usedBindings, totalModelCount, reportBinding); // Collect all generated cs files and produce them into the compilation foreach (string file in vfs.CreatedFiles .Where(f => f.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))) { string content = Encoding.UTF8.GetString(vfs.Get(file)); m_context.AddSource(file, content); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // A cancelled run is not a generator failure: let the driver // see the cancellation instead of caching an error diagnostic. throw; } catch (Exception ex) { m_context.ReportDiagnostic( Diagnostic.Create( SourceGenerator.Exception, Location.None, ex.Message, ex.ToString())); } } /// /// Tests the compilation options are valid /// /// private bool CheckCompilationOptions() { if (m_compilationOptions.LanguageVersion < LanguageVersion.CSharp8) { m_context.ReportDiagnostic( Diagnostic.Create( SourceGenerator.GenericError, Location.None, "Minimum required language version is CSharp 8.")); return false; } if (m_options.FluentAccessorsOnly && m_options.OmitFluentApi) { m_context.ReportDiagnostic( Diagnostic.Create( SourceGenerator.FluentAccessorsOnlyOptionsError, Location.None, "ModelSourceGeneratorFluentAccessorsOnly and " + "ModelSourceGeneratorOmitFluentApi cannot both be enabled.")); return false; } // The project-wide switch would emit a conventionally named // manager into the *referenced* model's own C# namespace for // every design in the project. A [NodeManager] says which model // gets a manager and what it is called, so that is the way to // put one on a model a reference supplies. if (m_options.FluentAccessorsOnly && m_options.Options.GenerateNodeManager) { m_context.ReportDiagnostic( Diagnostic.Create( SourceGenerator.FluentAccessorsOnlyOptionsError, Location.None, "ModelSourceGeneratorFluentAccessorsOnly cannot be combined with " + "ModelSourceGeneratorGenerateNodeManager. Apply a [NodeManager] " + "attribute to the manager class instead; it binds to a model a " + "referenced assembly supplies without re-emitting the model.")); return false; } return true; } private void ReportNodesetIdentifierDiagnostics( IEnumerable errors) { foreach (NodesetIdentifierValidationError error in errors) { m_context.ReportDiagnostic( Diagnostic.Create( error.Kind switch { NodesetIdentifierValidationErrorKind.MissingFile => SourceGenerator.NodesetIdentifierFileMissing, NodesetIdentifierValidationErrorKind.DuplicateSymbolicName => SourceGenerator.NodesetIdentifierDuplicateSymbolicName, NodesetIdentifierValidationErrorKind.DuplicateNumericId => SourceGenerator.NodesetIdentifierDuplicateNumericId, NodesetIdentifierValidationErrorKind.UnknownSymbol => SourceGenerator.NodesetIdentifierUnknownSymbol, NodesetIdentifierValidationErrorKind.NumericIdMismatch => SourceGenerator.NodesetIdentifierNumericIdMismatch, NodesetIdentifierValidationErrorKind.NodeClassMismatch => SourceGenerator.NodesetIdentifierNodeClassMismatch, NodesetIdentifierValidationErrorKind.AssignedToMultipleModels => SourceGenerator.NodesetIdentifierAssignedToMultipleModels, _ => SourceGenerator.NodesetIdentifierInvalidRow }, Location.None, error.NodeSetFilePath, error.IdentifierFilePath, error.SymbolicName, error.Value)); } } /// /// Group the referenced-assembly attributes by model URI; when more /// than one assembly contributes the same URI, prefer a payload-bearing /// self producer over payloadless transitive re-exports, then use the /// highest (Version, PublicationDate) tuple per the contract on /// (see /// ). /// private IReadOnlyDictionary BuildReferencedModelMap() { if (m_referencedModels.IsDefaultOrEmpty) { return ImmutableDictionary.Empty; } var map = new Dictionary( StringComparer.Ordinal); foreach (ModelDependencyReference candidate in m_referencedModels) { if (!candidate.IsValid) { continue; } if (!map.TryGetValue(candidate.ModelUri, out ModelDependencyReference existing)) { map[candidate.ModelUri] = candidate; continue; } if (IsMatchingTransitiveReference(candidate, existing)) { if (IsModelProducer(candidate)) { map[candidate.ModelUri] = candidate; } continue; } int cmp = CompareReferencedModels(candidate, existing); if (cmp == 0) { continue; } ModelDependencyReference selected = cmp > 0 ? candidate : existing; ModelDependencyReference ignored = cmp > 0 ? existing : candidate; if (cmp > 0) { map[candidate.ModelUri] = candidate; } m_context.ReportDiagnostic( Diagnostic.Create( SourceGenerator.ModelDependencyTieBreak, Location.None, selected.ModelUri, selected.AssemblyName, ignored.AssemblyName)); } return map; } /// /// Identifies matching dependency metadata, not another assembly that emits the model's types. /// private static bool IsMatchingTransitiveReference( ModelDependencyReference candidate, ModelDependencyReference existing) { if (!string.Equals(candidate.ModelUri, existing.ModelUri, StringComparison.Ordinal) || !string.Equals(candidate.Prefix, existing.Prefix, StringComparison.Ordinal) || !string.Equals(candidate.Version, existing.Version, StringComparison.Ordinal) || !string.Equals(candidate.PublicationDate, existing.PublicationDate, StringComparison.Ordinal) || !string.Equals(candidate.Name, existing.Name, StringComparison.Ordinal)) { return false; } return (candidate.Payload.Length == 0 && (existing.Payload.Length == 0 || IsModelProducer(existing))) || (existing.Payload.Length == 0 && IsModelProducer(candidate)); } /// /// Orders two referenced-assembly entries for the same model URI: a /// payload-bearing self producer first, then the version, then the /// publication date. The newest entry is picked by pairwise /// replacement, so this must be a total order or the winner depends on /// the order the assemblies are referenced in. The versions go through /// , the OPC 10000-6 F.2 comparator /// NodesetFileCollection uses, so the two halves of the pipeline cannot /// pick different winners. An ordinal assembly-name tie-break keeps /// equally versioned distinct producers deterministic. /// /// /// and its ModelDependencyV1 /// payload carry a single version string and no separate ModelVersion. /// That string is the version the producing generator publishes as the /// model's version (the ModelDesign pipeline derives the /// NamespaceMetadata ModelVersion from it), so it takes the ModelVersion /// slot of the comparison; there is no separate Version label to fall /// back to. The raw publication date text breaks what is left. /// internal static int CompareReferencedModels( ModelDependencyReference candidate, ModelDependencyReference existing) { int cmp = IsModelProducer(candidate).CompareTo(IsModelProducer(existing)); if (cmp == 0) { cmp = SemVer.CompareModels( candidate.Version, ParsePublicationDate(candidate.PublicationDate), null, existing.Version, ParsePublicationDate(existing.PublicationDate), null); } if (cmp == 0) { cmp = string.CompareOrdinal( candidate.PublicationDate, existing.PublicationDate); } if (cmp == 0) { cmp = string.CompareOrdinal(existing.AssemblyName, candidate.AssemblyName); } return cmp; } private static DateTime ParsePublicationDate(string publicationDate) { return !string.IsNullOrEmpty(publicationDate) && DateTime.TryParse( publicationDate, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out DateTime date) ? date : DateTime.MinValue; } private static bool IsModelProducer(ModelDependencyReference reference) { ModelDependencyV1 dependency = reference.GetDependency(); return dependency != null && string.Equals(dependency.ModelUri, reference.ModelUri, StringComparison.Ordinal); } /// /// Resolve whether the model-dependency assembly attribute /// should be emitted, honouring the compilation's OutputKind /// in Auto mode. /// private bool ResolveEmitDependencyMetadata() { return m_options.EmitDependencyMetadata switch { EmitDependencyMetadataMode.Always => true, EmitDependencyMetadataMode.Never => false, _ => m_compilationOptions.OutputKind is OutputKind.DynamicallyLinkedLibrary or OutputKind.NetModule }; } private readonly SourceProductionContext m_context; private readonly ImmutableArray<(AdditionalText, NodesetFileOptions)> m_input; private readonly ImmutableArray m_csvFiles; private readonly ImmutableArray m_identifierFiles; private readonly ModelCompilationOptions m_options; private readonly CompilationOptions m_compilationOptions; private readonly ImmutableArray m_referencedModels; private readonly ImmutableArray m_referencedAccessorProviders; private readonly ImmutableArray m_nodeManagerBindings; private readonly ImmutableHashSet m_availableStateTypeNames; private readonly SourceGeneratorTelemetry m_telemetry; } }