/* ======================================================================== * 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.IO; using System.Linq; using Microsoft.Extensions.Logging; using Opc.Ua.Export; using Opc.Ua.Schema.Model; using Opc.Ua.Types; namespace Opc.Ua.SourceGeneration { /// /// An entry in the collection /// public sealed class NodesetFile { /// /// Nodeset information /// public NodesetFileOptions? Info { get; init; } /// /// File name /// public string? FileName { get; init; } /// /// The nodeset parsed /// public UANodeSet? NodeSet { get; init; } /// /// Previous versions /// internal List? PreviousVersions { get; set; } /// /// The ModelVersion used to order editions of the model: the item /// metadata Version when set, otherwise the ModelTableEntry /// ModelVersion (OPC 10000-6 F.2). Null when neither declares one. /// internal string? ModelVersion { get; init; } } /// /// Nodeset information /// public sealed record class NodesetFileOptions { /// /// Model uri /// public string? ModelUri { get; init; } /// /// Name /// public string? Name { get; init; } /// /// Prefix to use /// public string? Prefix { get; init; } /// /// Should be ignored /// public bool Ignore { get; init; } /// /// Version /// public string? Version { get; init; } /// /// Optional identifier CSV explicitly associated with this NodeSet. /// public string? IdentifierFile { get; init; } } /// /// Nodeset compiler /// public sealed class NodesetFileCollection { /// /// The files in the collection /// public Dictionary Files => m_nodesets .ToDictionary(x => x.Key, x => x.Value.FileName!); /// /// The path of every file identified as a NodeSet2 input, including the /// superseded versions hides behind the winning one /// and the ones that failed to load. Callers use this to tell NodeSet2 /// inputs apart from ModelDesign inputs: a superseded NodeSet is still a /// NodeSet and must not be generated a second time by the ModelDesign /// pass, and a NodeSet that failed to load is still not a ModelDesign - /// handing it to the ModelDesign pass only turns a skipped file into a /// second, more confusing failure. /// public IEnumerable AllFilePaths => m_nodesetPaths; /// /// The models in the collection /// public IEnumerable ModelUris => m_nodesets.Values .Where(x => !x.Info!.Ignore) .Select(x => x.Info!.ModelUri!) .Where(x => !string.IsNullOrEmpty(x)); /// /// The design file entries for every nodeset in the collection, /// encoded as "{file},{prefix},{name}" (the same encoding /// used by ). These are /// suitable for use as Dependencies of a ModelDesign /// generation pass so that a ModelDesign referencing a type /// defined in a NodeSet2 input can resolve it. The built-in OpcUa /// namespace and ignored nodesets are excluded. /// public IEnumerable DesignFileEntries => m_nodesets.Values .Where(x => !x.Info!.Ignore && x.Info.ModelUri != Namespaces.OpcUa) .Select(x => $"{x.FileName},{x.Info!.Prefix},{x.Info.Name}"); /// /// The identifier CSV files claimed by a NodeSet through its /// IdentifierFile metadata. They belong to that NodeSet and must /// not be picked up as the identifier file of a ModelDesign input. /// public IEnumerable IdentifierFilePaths => m_identifierFiles .Select(entry => entry.IdentifierFile); /// /// Identifier sidecar validation errors discovered while loading NodeSets. /// public IReadOnlyList IdentifierValidationErrors => m_identifierValidationErrors; /// /// Create collection /// public NodesetFileCollection( ImmutableArray<(string, NodesetFileOptions)> nodeset2Files, IReadOnlyList csvFiles, IFileSystem fileSystem, ITelemetryContext telemetry) { m_logger = telemetry.CreateLogger(); foreach ((string file, NodesetFileOptions options) in nodeset2Files) { try { if (!NodeSetToModelDesign.IsNodeSet(fileSystem, file)) { continue; } // Positively identified as a NodeSet2 input. Record it // before the load is attempted: a file that fails to load // is still not a ModelDesign, and letting it fall through // to the ModelDesign pass turns a skipped file into a // second failure that aborts the whole pass. m_nodesetPaths.Add(file); using Stream istrm = fileSystem.OpenRead(file); SystemContext systemContext = new(telemetry) { NamespaceUris = new NamespaceTable(), ServerUris = new StringTable() }; var nodeset = UANodeSet.Read(istrm); #if !SKIP_NODE_STATE_LOADING var collection = new NodeStateCollection(); try { nodeset!.Import(systemContext, collection); } catch (Exception e) { m_logger.LogError(e, "NodeSet could not be loaded ({File})", file); // Skip this NodeSet, not every remaining one - the other // inputs are independent and must still be loaded. continue; } #endif if (nodeset.Models == null || nodeset.Models.Length == 0 || string.IsNullOrEmpty(nodeset.Models[0].ModelUri)) { m_logger.LogError("NodeSet is missing model definition ({File}).", file); continue; } ModelTableEntry model = nodeset.Models[0]; if (!Uri.IsWellFormedUriString(model.ModelUri, UriKind.Absolute)) { m_logger.LogError( "NodeSet ModelURI is not valid ({ModelUri}).", model.ModelUri); continue; } // The model URI is the one the NodeSet declares. An item // ModelUri that differs cannot rename the model - the nodes // stay in the NodeSet's namespace, and the collection is // keyed and its dependencies resolved by the declared URI - // so honouring it made the model vanish without a word. if (!string.IsNullOrEmpty(options.ModelUri) && !string.Equals(options.ModelUri, model.ModelUri, StringComparison.Ordinal)) { m_logger.LogWarning( "NodeSet ({File}) declares model URI {DeclaredModelUri}, " + "which differs from the ModelUri item metadata {ModelUri}. " + "The declared model URI is used.", file, model.ModelUri, options.ModelUri); } // Get a sane name and prefix string name = GetDefaultNameFromUri(model.ModelUri); string prefix = GetDefaultPrefixFromUri(model.ModelUri); var info = new NodesetFile { FileName = file, NodeSet = nodeset, // OPC 10000-6 F.2 Table F.1: editions are ordered by the // SemVer ModelVersion; the Version attribute is "not // intended for programmatic comparisons". ModelVersion = FirstNonEmpty(options.Version, model.ModelVersion), Info = new NodesetFileOptions // Set reasonable defaults if not provided { ModelUri = model.ModelUri, // A label only; ordering uses ModelVersion above. Version = FirstNonEmpty( options.Version, model.ModelVersion, model.Version, model.PublicationDate.ToString( "yyyy-MM-dd", CultureInfo.InvariantCulture)), Name = !string.IsNullOrEmpty(options.Name) ? options.Name : name, Prefix = !string.IsNullOrEmpty(options.Prefix) ? options.Prefix : prefix, Ignore = options.Ignore, IdentifierFile = options.IdentifierFile } }; if (!string.IsNullOrWhiteSpace(options.IdentifierFile)) { string? identifierFile = NodesetIdentifierFileValidator.ResolvePath( file, options.IdentifierFile, csvFiles); if (identifierFile == null) { m_identifierValidationErrors.Add( NodesetIdentifierValidationError.MissingFile( file, options.IdentifierFile)); } else { m_identifierFiles.Add((info, identifierFile)); } } if (m_nodesets.TryGetValue(model.ModelUri, out NodesetFile? existing)) { // Keep the newest version under the model URI and record // the superseded one. Replacing unconditionally used to // drop a newer NodeSet that happened to be read first. if (CompareVersions(existing, info) < 0) { info.PreviousVersions = []; if (existing.PreviousVersions != null) { info.PreviousVersions.AddRange(existing.PreviousVersions); } existing.PreviousVersions = null; info.PreviousVersions.Add(existing); m_nodesets[model.ModelUri] = info; } else { existing.PreviousVersions ??= []; existing.PreviousVersions.Add(info); } continue; } m_nodesets[model.ModelUri] = info; } catch (Exception ex) { if (m_logger.IsEnabled(LogLevel.Critical)) { m_logger.LogCritical(ex, "Could not parse NodeSet ({File}).", file); } } } foreach (IGrouping group in m_identifierFiles .GroupBy(entry => entry.IdentifierFile, StringComparer.Ordinal) .OrderBy(entry => entry.Key, StringComparer.Ordinal)) { if (group.Count() > 1) { foreach ((NodesetFile nodeset, string identifierFile) in group .OrderBy(entry => entry.NodeSet.FileName, StringComparer.Ordinal)) { m_identifierValidationErrors.Add( NodesetIdentifierValidationError.AssignedToMultipleModels( nodeset.FileName!, identifierFile)); } continue; } (NodesetFile singleNodeset, string singleIdentifierFile) = group.Single(); m_identifierValidationErrors.AddRange( NodesetIdentifierFileValidator.Validate( fileSystem, singleNodeset.FileName!, singleNodeset.Info!.ModelUri!, singleIdentifierFile, telemetry)); } } /// /// Get nodeset and dependencies for the model uri /// /// public List? GetDesignFileListForModel( string modelUri, out NodesetFile nodeset, IReadOnlyDictionary? referencedModels = null) { if (!m_nodesets.TryGetValue(modelUri, out nodeset!)) { return null; } // Seed the visited set with the root: a dependency that lists the // root among its own NamespaceUris (mutual references) must not // add the root a second time. Dictionary dependencies = new(StringComparer.Ordinal) { [nodeset.Info!.ModelUri!] = nodeset }; if (!CollectDependencies(nodeset, dependencies, referencedModels)) { return null; } NodesetFile root = nodeset; List files = [$"{root.FileName},{root.Info.Prefix},{root.Info.Name}"]; foreach (NodesetFile dependency in dependencies.Values .Where(x => !ReferenceEquals(x, root) && x.Info!.ModelUri != Namespaces.OpcUa)) { files.Add($"{dependency.FileName},{dependency.Info!.Prefix},{dependency.Info.Name}"); } return files; } /// /// Collect dependencies /// private bool CollectDependencies( NodesetFile target, Dictionary dependencies, IReadOnlyDictionary? referencedModels) { if (target.NodeSet!.NamespaceUris == null) { return true; } foreach (string ns in target.NodeSet.NamespaceUris) { if (dependencies.ContainsKey(ns) || ns == target.Info!.ModelUri) { continue; } if (!m_nodesets.TryGetValue(ns, out NodesetFile? nodeset)) { if (referencedModels != null && referencedModels.ContainsKey(ns)) { // Dependency is supplied by a referenced assembly's // generated code; not present in AdditionalFiles is // expected. Skip without erroring. if (m_logger.IsEnabled(LogLevel.Information)) { m_logger.LogInformation( "NodeSet ({ModelUri}) dependency ({Namespace}) " + "satisfied by referenced assembly.", target.Info.ModelUri, ns); } continue; } m_logger.LogError( "NodeSet ({ModelUri}) dependency is missing ({Namespace}).", target.Info.ModelUri, ns); return false; } // favour the version in the same directory as the target. if (nodeset.PreviousVersions != null && Path.GetDirectoryName(nodeset.FileName) != Path.GetDirectoryName(target.FileName)) { foreach (NodesetFile ii in nodeset.PreviousVersions) { if (Path.GetDirectoryName(ii.FileName) == Path.GetDirectoryName(target.FileName)) { nodeset = ii; break; } } } dependencies[ns] = nodeset; if (!CollectDependencies(nodeset, dependencies, referencedModels)) { return false; } } return true; } /// /// Compares two NodeSets of the same model URI with /// : the ModelVersion (item metadata /// Version overrides the declared one) with SemVer precedence, then the /// publication date, then the declared Version as a last heuristic. The /// comparison must be a total order, because the newest of several /// inputs is picked by pairwise replacement. /// private static int CompareVersions(NodesetFile left, NodesetFile right) { // OPC 10000-6 F.2 Table F.1: ModelVersion, then PublicationDate; // the Version attribute only as a last heuristic. return SemVer.CompareModels( left.ModelVersion!, GetPublicationDate(left), (GetModel(left)?.Version)!, right.ModelVersion!, GetPublicationDate(right), (GetModel(right)?.Version)!); } private static ModelTableEntry? GetModel(NodesetFile nodeset) { ModelTableEntry[]? models = nodeset.NodeSet?.Models; return models != null && models.Length > 0 ? models[0] : null; } /// /// The first of the candidates that carries a value. /// private static string FirstNonEmpty(params string?[] candidates) { foreach (string? candidate in candidates) { if (!string.IsNullOrEmpty(candidate)) { return candidate; } } return string.Empty; } private static DateTime GetPublicationDate(NodesetFile nodeset) { ModelTableEntry[]? models = nodeset.NodeSet?.Models; return models != null && models.Length > 0 ? models[0].PublicationDate : DateTime.MinValue; } private static string GetNameFromUri(string uri) { var builder = new Uri(uri); string path = builder.LocalPath.TrimEnd('/'); if (path.StartsWith("/UA/", StringComparison.OrdinalIgnoreCase)) { path = path[4..]; } if (path.StartsWith("/OpcUa/", StringComparison.OrdinalIgnoreCase)) { path = path[7..]; } path = path.Trim('/') .Replace("/", string.Empty, StringComparison.Ordinal) .Replace('-', '_') .Replace('+', '_'); int colon = path.LastIndexOf(':'); if (colon != -1) { path = path[(colon + 1)..]; } if (string.IsNullOrEmpty(path)) { // "http://example.com/" has no path to name the model after. path = builder.IsAbsoluteUri ? builder.Host : string.Empty; } // Control characters became '_' on Windows (invalid file name // characters) and must keep doing so everywhere; every other // character an identifier cannot hold is repaired by the callers. return new string([.. path.Select(c => char.IsControl(c) ? '_' : c)]); } /// /// The default Namespaces constant name for a model URI: a single /// C# identifier. A name that already is one is returned unchanged; /// anything else (a leading digit as in "2013/01/ISA95", a '.', or any /// other character an identifier cannot hold) is repaired. The repair is /// the same on every OS - it used to depend on the platform's invalid /// file name characters. /// internal static string GetDefaultNameFromUri(string uri) { string name = GetNameFromUri(uri); return string.IsNullOrEmpty(name) ? name : ToKeywordSafeIdentifier(name); } /// /// A C# identifier that needs no '@' escape. The Name and Prefix are /// also used in generated member names ("Add{Prefix}"), Roslyn hint /// names and as XML namespace prefix, where the '@' that escapes a /// keyword ("@class") is not allowed - a keyword gets a trailing '_' /// ("class_") instead. Every other identifier is returned unchanged. /// private static string ToKeywordSafeIdentifier(string name) { string identifier = name.ToCSharpIdentifierPreserveCase(); return identifier.Length > 0 && identifier[0] == '@' ? identifier[1..] + "_" : identifier; } /// /// The default C# namespace (prefix) for a model URI: dot separated C# /// identifiers. Every segment that already is an identifier is kept /// unchanged, so a prefix that compiled before is not renamed. /// internal static string GetDefaultPrefixFromUri(string uri) { string name = GetNameFromUri(uri); if (string.IsNullOrEmpty(name)) { return name; } return string.Join( ".", name.Split(['.'], StringSplitOptions.RemoveEmptyEntries) .Select(ToKeywordSafeIdentifier)); } private readonly ILogger m_logger; private readonly Dictionary m_nodesets = []; /// /// Every input recognised as a NodeSet2 file, whether or not it loaded /// and whether or not it won its model URI. See . /// private readonly HashSet m_nodesetPaths = new(StringComparer.Ordinal); private readonly List<(NodesetFile NodeSet, string IdentifierFile)> m_identifierFiles = []; private readonly List m_identifierValidationErrors = []; } }