/* ======================================================================== * 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.Runtime.Serialization; using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace Opc.Ua.SourceGeneration { /// /// Handles a single [DataType]-annotated class or enum. /// Builds and validates a during /// construction. Models are collected via .Collect() and /// emitted as a batch (one file per namespace) by /// to avoid conflicting extension methods. /// /// /// This is the output of a ForAttributeWithMetadataName transform, /// which runs again on every compilation change. Every member compares by /// value (lists are , the location is a /// ) so an unchanged type leaves the emitted /// sources cached and does not pin old syntax trees. /// internal sealed record class DataTypeCompilation { /// /// The validated model, or null if structural validation failed. /// public TypeSourceModel Model { get; } /// /// The validated fields (empty for enums or on error). /// public EquatableArray ValidFields { get; } /// /// Diagnostics from field validation. /// public EquatableArray<(bool IsError, string Message)> Diagnostics { get; } /// /// Location for diagnostic reporting. /// public LocationInfo Location { get; } /// /// Why the annotated type cannot be generated (a struct, a generic /// type, a non-partial or inaccessible containing type), or /// null. /// public string UnsupportedReason { get; } /// /// True if the model has fatal errors. /// public bool HasErrors { get; } /// /// Structural error message (non-partial, no ctor). /// public string ErrorMessage { get; } /// /// The annotated type name used for diagnostics. /// public string TypeName { get; } /// /// An explicitly supplied namespace expression that Roslyn could not resolve. /// public string UnresolvedNamespaceExpression { get; } = string.Empty; /// /// The encodeable base type whose data type definition cannot be /// resolved (MODELGEN038), or empty. /// public string UnresolvedBaseDefinition { get; } = string.Empty; /// /// Check whether the generator can handle the node. /// public static bool Handles(SyntaxNode node, CancellationToken ct) { ct.ThrowIfCancellationRequested(); // BaseTypeDeclarationSyntax rather than TypeDeclarationSyntax: an // enum declaration is not a TypeDeclarationSyntax, and [DataType] on // an enum is supported (see BuildEnumModel). return node is BaseTypeDeclarationSyntax t && t.AttributeLists.Count > 0; } /// /// Create data type compilation from a Roslyn symbol. /// Builds and validates the model eagerly. /// public DataTypeCompilation( GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { var symbol = (INamedTypeSymbol)context.TargetSymbol; TypeName = symbol.ToDisplayString(); Location = LocationInfo.From(symbol.Locations.FirstOrDefault()); ValidFields = EquatableArray.Empty; Diagnostics = EquatableArray<(bool, string)>.Empty; UnsupportedReason = GetUnsupportedReason(symbol, cancellationToken); if (UnsupportedReason != null) { HasErrors = true; return; } AttributeData dataTypeAttr = context.Attributes.FirstOrDefault(); AttributeArgumentSyntax unresolvedNamespaceArgument = GetUnresolvedNamespaceArgument(dataTypeAttr, cancellationToken); if (unresolvedNamespaceArgument != null) { Location = LocationInfo.From(unresolvedNamespaceArgument.GetLocation()); UnresolvedNamespaceExpression = unresolvedNamespaceArgument.Expression.ToString(); HasErrors = true; return; } string dataTypeNamespace = dataTypeAttr.GetValue(nameof(DataTypeAttribute.Namespace)); string dataTypeId = dataTypeAttr.GetValue(nameof(DataTypeAttribute.DataTypeId)); string binaryEncodingId = dataTypeAttr.GetValue( nameof(DataTypeAttribute.BinaryEncodingId)); string xmlEncodingId = dataTypeAttr.GetValue(nameof(DataTypeAttribute.XmlEncodingId)); try { if (symbol.TypeKind == TypeKind.Enum) { Model = BuildEnumModel( symbol, dataTypeNamespace, dataTypeId, binaryEncodingId, xmlEncodingId); return; } if (!IsPartial(symbol, cancellationToken)) { HasErrors = true; ErrorMessage = "[DataType] class must be declared as partial."; return; } // The namespace-level activator calls the parameterless // constructor, so it must be reachable from there. bool hasCtor = symbol.InstanceConstructors .Any(c => c.Parameters.Length == 0 && c.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal); if (!hasCtor) { HasErrors = true; ErrorMessage = "[DataType] class must have a public or internal parameterless ctor."; return; } Model = BuildClassModel( symbol, dataTypeNamespace, dataTypeId, binaryEncodingId, xmlEncodingId, cancellationToken); IReadOnlyList diags = TypeSourceGenerator.ValidateAndFilter( Model, out IReadOnlyList valid); // Encode writes the base fields first, which the definition // can only describe through the base type's definition. if (Model.IsDerived && Model.BaseDefinitionActivator == null) { UnresolvedBaseDefinition = symbol.BaseType.ToDisplayString(); } ValidFields = EquatableArray.From(valid); Diagnostics = EquatableArray<(bool, string)>.From( diags.Select(d => (d.IsError, d.Message))); HasErrors = diags.Any(d => d.IsError); } catch (Exception ex) when (ex is not OperationCanceledException) { // Cancellation must reach the driver; caching it as a model // error would keep reporting it until an input changes. HasErrors = true; // The annotated type is reported as the diagnostic's first // argument, so the message itself carries only the failure. ErrorMessage = $"{ex.GetType().Name}: {ex.Message}"; } } private static bool IsPartial(INamedTypeSymbol symbol, CancellationToken cancellationToken) { return symbol.DeclaringSyntaxReferences .Any(r => r.GetSyntax(cancellationToken) is TypeDeclarationSyntax tds && tds.Modifiers.Any(SyntaxKind.PartialKeyword)); } /// /// Returns why the generated members cannot be attached to the /// annotated type, or null when they can. /// /// /// The generated declaration is a partial class or /// partial record class, so a struct cannot be completed. The /// activator and registration are emitted at namespace level, so a /// generic type (or a type nested in one) has no closed type to /// activate, and a nested type must be reachable from its namespace. /// A nested type is emitted inside partial declarations of its /// containing types, which therefore must be partial themselves. /// private static string GetUnsupportedReason( INamedTypeSymbol symbol, CancellationToken cancellationToken) { if (symbol.TypeKind == TypeKind.Struct) { return "[DataType] is not supported on a struct; declare the type as a " + "partial class or partial record class"; } if (symbol.IsGenericType) { return "[DataType] is not supported on a generic type or a type nested in a " + "generic type"; } // The generated code is emitted into the type's namespace, which // the global namespace cannot be written as. if (symbol.ContainingNamespace?.IsGlobalNamespace != false) { return "[DataType] is not supported on a type in the global namespace; " + "declare the type in a namespace"; } // An abstract class cannot be created by the generated activator. if (symbol.TypeKind == TypeKind.Class && symbol.IsAbstract && !symbol.IsStatic) { return "[DataType] is not supported on an abstract class, which the " + "generated activator cannot create"; } for (INamedTypeSymbol type = symbol; type != null; type = type.ContainingType) { // A file-local type is not visible outside its file, so the // generated partial declaration would declare a different type. if (type.IsFileLocal) { return "'" + type.ToDisplayString() + "' is file-local, which generated " + "code in another file cannot complete or reach"; } if (type.DeclaredAccessibility is Accessibility.Private or Accessibility.Protected or Accessibility.ProtectedAndInternal) { return "'" + type.ToDisplayString() + "' must be public or internal so " + "the generated activator can reach the [DataType] type"; } // An enum gets no generated declaration, so only a class // needs its containing types to be partial. if (!ReferenceEquals(type, symbol) && symbol.TypeKind != TypeKind.Enum && !IsPartial(type, cancellationToken)) { return "the containing type '" + type.ToDisplayString() + "' must be declared partial"; } } return null; } /// /// True if the type and all its containing types are declared public, /// so the namespace-level activator can be public as well. /// private static bool IsEffectivelyPublic(INamedTypeSymbol symbol) { for (INamedTypeSymbol type = symbol; type != null; type = type.ContainingType) { if (type.DeclaredAccessibility != Accessibility.Public) { return false; } } return true; } /// /// Fill in the nesting of a nested type: the partial declarations of /// its containing types, its qualified name and a unique identifier /// for the namespace-level activator. /// private static TypeSourceModel WithNesting( TypeSourceModel model, INamedTypeSymbol symbol) { if (symbol.ContainingType == null) { return model; } var declarations = new List(); var names = new List(); for (INamedTypeSymbol type = symbol.ContainingType; type != null; type = type.ContainingType) { declarations.Insert(0, GetPartialDeclaration(type)); names.Insert(0, type.Name); } names.Add(symbol.Name); return model with { ContainingTypeDeclarations = new EquatableArray([.. declarations]), TypeReference = symbol.GetFullyQualifiedTypeName(), SymbolName = string.Join("_", names), // Two nested types of the same name in one namespace URI // must not share the default DataTypeId and XML name. QualifiedName = string.Join(".", names), AccessModifier = symbol.DeclaredAccessibility switch { Accessibility.Internal => "internal", Accessibility.ProtectedOrInternal => "protected internal", _ => "public" } }; } /// /// A partial declaration of a containing type, without accessibility /// (a partial part may omit it) and without base list. /// private static string GetPartialDeclaration(INamedTypeSymbol type) { var modifiers = new List(); if (type.IsStatic) { modifiers.Add("static"); } if (type.TypeKind == TypeKind.Struct && type.IsReadOnly) { modifiers.Add("readonly"); } if (type.IsRefLikeType) { modifiers.Add("ref"); } modifiers.Add("partial"); modifiers.Add(type.TypeKind switch { TypeKind.Struct when type.IsRecord => "record struct", TypeKind.Struct => "struct", TypeKind.Interface => "interface", _ when type.IsRecord => "record class", _ => "class" }); modifiers.Add(type.Name); return string.Join(" ", modifiers); } /// /// Report the diagnostics of a batch of compilations. The locations /// are re-created in the syntax trees of /// so that #pragma warning, per-file severity configuration and /// #line mapping apply to them. Kept apart from /// so the source output does not depend on the /// compilation and stays cached. /// public static void ReportDiagnostics( SourceProductionContext sourceContext, ImmutableArray compilations, Compilation compilation) { foreach (DataTypeCompilation comp in compilations) { if (comp.UnsupportedReason == null && comp.UnresolvedNamespaceExpression.Length == 0 && comp.ErrorMessage == null && comp.Diagnostics.Count == 0 && comp.UnresolvedBaseDefinition.Length == 0) { continue; } Location location = comp.Location.ToLocation(compilation); if (comp.UnresolvedBaseDefinition.Length > 0) { sourceContext.ReportDiagnostic( Diagnostic.Create( SourceGenerator.DataTypeBaseDefinitionUnresolved, location, comp.TypeName, comp.UnresolvedBaseDefinition)); } if (comp.UnsupportedReason != null) { sourceContext.ReportDiagnostic( Diagnostic.Create( SourceGenerator.DataTypeUnsupportedTarget, location, comp.TypeName, comp.UnsupportedReason)); continue; } if (comp.UnresolvedNamespaceExpression.Length > 0) { sourceContext.ReportDiagnostic( Diagnostic.Create( SourceGenerator.DataTypeNamespaceUnresolved, location, comp.TypeName, comp.UnresolvedNamespaceExpression)); continue; } // MODELGEN003 takes two arguments ("... '{0}': {1}"); supplying // only one leaves the message rendered as the raw template. if (comp.ErrorMessage != null) { sourceContext.ReportDiagnostic( Diagnostic.Create( SourceGenerator.Exception, location, comp.TypeName, comp.ErrorMessage)); } foreach ((bool isError, string message) in comp.Diagnostics) { sourceContext.ReportDiagnostic( isError ? Diagnostic.Create( SourceGenerator.Exception, location, comp.TypeName, message) : Diagnostic.Create( SourceGenerator.GenericWarning, location, message)); } } } /// /// Emit a batch of compilations as one file per namespace. The /// diagnostics are reported by . /// public static void EmitBatch( SourceProductionContext sourceContext, ImmutableArray compilations, bool publicExtensions) { // Roslyn compares hint names case-insensitively, so namespaces // differing only in case ("Acme.Types" and "Acme.types") would // claim the same hint name and the second AddSource would throw. var hintNames = new HashSet(StringComparer.OrdinalIgnoreCase); List valid = [.. compilations .Where(c => !c.HasErrors && c.Model != null)]; Dictionary renamed = GetUniqueSymbolNames(valid); IEnumerable> validByNamespace = valid.GroupBy(c => c.Model.Namespace); foreach (IGrouping group in validByNamespace) { List entries = [.. group]; TypeSourceModel first = entries[0].Model; var allTypes = new List(); var allActivators = new List(); foreach (DataTypeCompilation comp in entries) { TypeSourceModel model = WithUniqueSymbolNames( comp.Model with { PublicExtensions = publicExtensions }, renamed); if (model.IsEnum) { allActivators.Add(model); } else { TypeSourceModel withValidFields = model with { Fields = comp.ValidFields }; allTypes.Add(withValidFields); allActivators.Add(withValidFields); } } string source = TypeSourceGenerator.GenerateBatch( first.Namespace, first.NamespaceSymbol, first.NamespaceUri, publicExtensions, allTypes, allActivators); // Keyed on the namespace itself, not on NamespaceSymbol: the // latter has the dots stripped to form a C# identifier, so // "A.BC" and "AB.C" would claim the same hint name and the // second AddSource would fail the generator. string hintName = first.Namespace + ".Types.g.cs"; for (int ii = 2; !hintNames.Add(hintName); ii++) { hintName = first.Namespace + ".Types" + ii.ToString(CultureInfo.InvariantCulture) + ".g.cs"; } sourceContext.AddSource(hintName, source); } } /// /// Makes the activator names unique within each namespace. A nested /// type is named after its nesting chain joined with '_', which can /// equal the name of a top-level type (Models.Foo and /// Models_Foo) or of another nested type (A.B_C and /// A_B.C). Top-level types keep their names; a colliding nested /// type gets a numeric suffix, assigned in the order of the fully /// qualified type names so it does not depend on the input order. /// /// The new symbol names by fully qualified type name. private static Dictionary GetUniqueSymbolNames( List compilations) { var renamed = new Dictionary(StringComparer.Ordinal); foreach (IGrouping group in compilations .Select(c => c.Model) .GroupBy(m => m.Namespace, StringComparer.Ordinal)) { var taken = new HashSet( group.Where(m => m.SymbolName == null).Select(m => m.ClassName), StringComparer.Ordinal); foreach (TypeSourceModel nested in group .Where(m => m.SymbolName != null) .OrderBy(m => m.TypeReference, StringComparer.Ordinal)) { string name = nested.SymbolName; for (int ii = 2; !taken.Add(name); ii++) { name = nested.SymbolName + "_" + ii.ToString(CultureInfo.InvariantCulture); } if (name != nested.SymbolName) { renamed[nested.TypeReference] = (nested.Namespace, name); } } } return renamed; } /// /// Applies the renames of to a /// model and to the activator of its base type. /// private static TypeSourceModel WithUniqueSymbolNames( TypeSourceModel model, Dictionary renamed) { if (renamed.Count == 0) { return model; } if (model.TypeReference != null && renamed.TryGetValue(model.TypeReference, out (string _, string SymbolName) self)) { model = model with { SymbolName = self.SymbolName }; } if (model.BaseDefinitionActivator != null && model.BaseTypeReference != null && renamed.TryGetValue(model.BaseTypeReference, out (string Namespace, string SymbolName) baseName)) { model = model with { BaseDefinitionActivator = "global::" + baseName.Namespace + "." + baseName.SymbolName + "Activator" }; } return model; } private static TypeSourceModel BuildClassModel( INamedTypeSymbol symbol, string dataTypeNamespace, string dataTypeId, string binaryEncodingId, string xmlEncodingId, CancellationToken ct) { string ns = symbol.GetFullNamespace(); bool baseTypeIsEncodeable = symbol.BaseType != null && symbol.BaseType.Name != "Object" && (symbol.BaseType.ImplementsInterface("IEncodeable") || symbol.BaseType.HasAttribute("DataTypeAttribute")); return WithNesting(new TypeSourceModel { ClassName = symbol.Name, Namespace = ns, NamespaceUri = ResolveNamespaceUri( symbol, dataTypeNamespace, ns), NamespaceSymbol = ns.Replace(".", string.Empty), DataTypeId = dataTypeId, BinaryEncodingId = binaryEncodingId, XmlEncodingId = xmlEncodingId, ContainingTypeDeclarations = EquatableArray.Empty, IsEffectivelyPublic = IsEffectivelyPublic(symbol), EnumMembers = EquatableArray.Empty, IsRecord = symbol.IsRecord, IsEnum = false, IsSealed = symbol.IsSealed, IsDerived = baseTypeIsEncodeable, IsInternal = symbol.DeclaredAccessibility is Accessibility.Internal or Accessibility.NotApplicable, BaseTypeIsEncodeable = baseTypeIsEncodeable, HasManualClone = symbol.GetMembers() .OfType() .Any(m => m.Name is "Clone" or "MemberwiseClone" && !m.IsImplicitlyDeclared), Fields = EquatableArray.From(CollectFields(symbol, ct)), BaseClassName = symbol.BaseType?.Name == "Object" ? null : symbol.BaseType?.Name, BaseDefinitionActivator = baseTypeIsEncodeable ? ResolveBaseDefinitionActivator(symbol.BaseType) : null, BaseTypeReference = baseTypeIsEncodeable ? symbol.BaseType.GetFullyQualifiedTypeName() : null }, symbol); } /// /// Resolves the activator of an encodeable base type that exposes the /// base type's data type definition: the {Name}Activator /// emitted next to every [DataType] type and every model generated /// structure. A [DataType] base in the same compilation is not /// visible yet (its activator is generated in this run) and is /// resolved by convention. /// private static string ResolveBaseDefinitionActivator(INamedTypeSymbol baseType) { if (baseType == null || baseType.IsGenericType) { return null; } string ns = baseType.GetFullNamespace(); // Nested types get a namespace-level activator named after their // nesting chain (see WithNesting), e.g. Outer_InnerActivator. string symbolName = baseType.Name; for (INamedTypeSymbol containing = baseType.ContainingType; containing != null; containing = containing.ContainingType) { symbolName = containing.Name + "_" + symbolName; } string activatorName = symbolName + "Activator"; string activator = string.IsNullOrEmpty(ns) ? "global::" + activatorName : "global::" + ns + "." + activatorName; if (baseType.HasAttribute("DataTypeAttribute")) { return activator; } INamedTypeSymbol existing = baseType.ContainingNamespace? .GetTypeMembers(activatorName) .FirstOrDefault(); if (existing != null && existing.ImplementsInterface("IDataTypeDefinitionSource") && existing.GetMembers("Instance").Any(m => m.IsStatic)) { return activator; } return null; } private static TypeSourceModel BuildEnumModel( INamedTypeSymbol symbol, string dataTypeNamespace, string dataTypeId, string binaryEncodingId, string xmlEncodingId) { string ns = symbol.GetFullNamespace(); var members = new List(); foreach (ISymbol member in symbol.GetMembers()) { if (member is IFieldSymbol field && field.HasConstantValue) { members.Add(new TypeEnumMember { Name = field.Name, // Invariant: the generator parses the value with the // invariant culture, and a culture with U+2212 as // negative sign would otherwise make it unparsable. Value = field.ConstantValue is null ? "0" : Convert.ToString(field.ConstantValue, CultureInfo.InvariantCulture) }); } } return WithNesting(new TypeSourceModel { ClassName = symbol.Name, Namespace = ns, NamespaceUri = ResolveNamespaceUri( symbol, dataTypeNamespace, ns), NamespaceSymbol = ns.Replace(".", string.Empty), DataTypeId = dataTypeId, BinaryEncodingId = binaryEncodingId, XmlEncodingId = xmlEncodingId, ContainingTypeDeclarations = EquatableArray.Empty, IsEffectivelyPublic = IsEffectivelyPublic(symbol), Fields = EquatableArray.Empty, IsEnum = true, IsFlags = symbol.GetAttributes().Any(a => a.AttributeClass?.Name == "FlagsAttribute"), EnumMembers = EquatableArray.From(members) }, symbol); } private static List CollectFields( INamedTypeSymbol symbol, CancellationToken ct) { // Get ALL non-abstract, non-static properties regardless of // accessibility for [DataTypeField] scanning. IPropertySymbol[] allProperties = [ .. symbol.GetMembers() .OfType() .Where(p => !p.IsAbstract && !p.IsStatic && !p.IsReadOnly) ]; // Check if any property has [DataTypeField] — if so, use only those Tuple[] selectedPropsWithAttribute = [ .. allProperties .Select(p => Tuple.Create(p, p.GetAttributes() .FirstOrDefault(a => a.AttributeClass?.Name == nameof(DataTypeFieldAttribute)))) .Where(p => p.Item2 != null) ]; var fields = new List(); // null if the constructor can assign any member. HashSet ctorAssigned = GetConstructorAssignedMembers(symbol, ct); int orderIndex = 0; if (selectedPropsWithAttribute.Length == 0) { // None annotated — auto-discover PUBLIC properties only foreach (IPropertySymbol prop in allProperties) { if (prop.DeclaredAccessibility != Accessibility.Public) { continue; } ct.ThrowIfCancellationRequested(); fields.Add(CreateField(prop, prop.Name, orderIndex++, false, ctorAssigned)); } } else { // Use only annotated properties (any accessibility) foreach (Tuple propWithAttribute in selectedPropsWithAttribute) { ct.ThrowIfCancellationRequested(); AttributeData dtfAttr = propWithAttribute.Item2; IPropertySymbol prop = propWithAttribute.Item1; orderIndex = dtfAttr.GetInteger( nameof(DataTypeFieldAttribute.Order), ++orderIndex); string fieldName = dtfAttr.GetValue( nameof(DataTypeFieldAttribute.Name)) ?? prop.Name; fields.Add(CreateField(prop, fieldName, orderIndex, true, ctorAssigned, dtfAttr)); } } fields.Sort((a, b) => a.Order.CompareTo(b.Order)); return fields; } private static TypeFieldModel CreateField( IPropertySymbol prop, string fieldName, int order, bool hasDataTypeFieldAttr, HashSet ctorAssigned, AttributeData dtfAttr = null) { ITypeSymbol type = prop.Type; string shortName = type.Name; bool isNullable = prop.NullableAnnotation == NullableAnnotation.Annotated; bool isEnum = type.TypeKind == TypeKind.Enum; bool isEncodeable = !isEnum && (type.ImplementsInterface(nameof(IEncodeable)) || type.HasAttribute(nameof(DataTypeAttribute))); bool isArray = false; bool isMatrix = false; string elementShortTypeName = null; string elementTypeName = null; ITypeSymbol encodeableType = type; if (shortName == "ArrayOf" && type is INamedTypeSymbol arrayType && arrayType.IsGenericType && arrayType.TypeArguments.Length == 1) { isArray = true; ITypeSymbol elem = arrayType.TypeArguments[0]; elementShortTypeName = elem.Name; elementTypeName = elem.GetFullyQualifiedTypeName(); isEnum = elem.TypeKind == TypeKind.Enum; isEncodeable = !isEnum && (elem.ImplementsInterface(nameof(IEncodeable)) || elem.HasAttribute(nameof(DataTypeAttribute))); encodeableType = elem; } else if (shortName == "MatrixOf" && type is INamedTypeSymbol matrixType && matrixType.IsGenericType && matrixType.TypeArguments.Length == 1) { isMatrix = true; ITypeSymbol elem = matrixType.TypeArguments[0]; elementShortTypeName = elem.Name; elementTypeName = elem.GetFullyQualifiedTypeName(); isEnum = elem.TypeKind == TypeKind.Enum; isEncodeable = !isEnum && (elem.ImplementsInterface(nameof(IEncodeable)) || elem.HasAttribute(nameof(DataTypeAttribute))); encodeableType = elem; } int structureHandling = 0; int defaultValueHandling = 0; if (dtfAttr != null) { foreach (KeyValuePair kvp in dtfAttr.NamedArguments) { switch (kvp.Key) { case "StructureHandling" when kvp.Value.Value is int sh: structureHandling = sh; break; case "DefaultValueHandling" when kvp.Value.Value is int dvh: defaultValueHandling = dvh; break; } } } bool fieldTypeIsSealed = encodeableType.IsSealed; bool fieldTypeHasEncodeableBase = encodeableType is INamedTypeSymbol namedFieldType && namedFieldType.BaseType != null && namedFieldType.BaseType.Name != "Object" && namedFieldType.BaseType.ImplementsInterface("IEncodeable"); string dataTypeNodeId = ResolveFieldDataTypeNodeId( isArray || isMatrix ? elementShortTypeName : shortName, isEncodeable, isEnum); // A field whose value equals the type default (OPC 10000-6 Table 1) // may be omitted on encode (DefaultValueHandling.Exclude), and a // missing field keeps the value the constructor assigned on decode // (a deliberate leniency, so configuration files may leave fields // out). Both only agree with a conformant peer, which decodes a // missing field as the type default (5.4.1, 5.3.5), when the // declared default is the type default: no initializer (or a // default literal) that no constructor overrides. Any other // declared default makes the field always encoded. string defaultValueLiteral = null; bool hasNonConstantInitializer = false; ExpressionSyntax initializer = GetPropertyInitializerSyntax(prop); bool defaultKnown = ctorAssigned != null && !ctorAssigned.Contains(prop.Name); if (defaultKnown && !IsAutoProperty(prop)) { // A property with accessor bodies is initialized through its // backing field: the field's initializer is the default. IFieldSymbol backingField = GetBackingField(prop); defaultKnown = backingField != null && !ctorAssigned.Contains(backingField.Name); initializer = defaultKnown ? GetFieldInitializerSyntax(backingField) : null; } if (!defaultKnown) { hasNonConstantInitializer = true; } else if (initializer != null && !IsDefaultLiteral(initializer)) { if (!isArray && !isMatrix && !isEnum && !isEncodeable && s_literalComparableTypes.Contains(shortName) && IsSimpleLiteral(initializer)) { defaultValueLiteral = initializer.ToString(); } else if (shortName == "String" && IsStringEmpty(initializer)) { defaultValueLiteral = "\"\""; } else { hasNonConstantInitializer = true; } } return new TypeFieldModel { PropertyName = prop.Name, FieldName = fieldName, TypeName = prop.Type.GetFullyQualifiedTypeName(), ShortTypeName = shortName, IsArray = isArray, IsMatrix = isMatrix, ElementShortTypeName = elementShortTypeName, ElementTypeName = elementTypeName, IsOptional = isNullable, IsEncodeable = isEncodeable, IsEnum = isEnum, Order = order, HasDataTypeFieldAttribute = hasDataTypeFieldAttr, DataTypeNodeId = dataTypeNodeId, StructureHandling = structureHandling, DefaultValueHandling = defaultValueHandling, DefaultValueLiteral = defaultValueLiteral, HasNonConstantInitializer = hasNonConstantInitializer, FieldTypeIsSealed = fieldTypeIsSealed, FieldTypeHasEncodeableBase = fieldTypeHasEncodeableBase, IsInitOnly = HasInitOnlySetter(prop), BackingFieldName = HasInitOnlySetter(prop) ? $"__{prop.Name}" : null, DefaultInitializer = HasInitOnlySetter(prop) ? GetPropertyInitializer(prop) : null }; } /// /// Resolves the OPC UA DataType NodeId expression for a field so a /// non-null StructureField.DataType can be emitted. OPC UA built-in /// types resolve to the exact fixed numeric DataType identifier /// (mirrors the canonical map in TypeInfo.GetDataTypeId). /// Complex (IEncodeable) and enum field types cannot be resolved to a /// concrete DataType NodeId from the attribute model alone (no /// companion DataType NodeId is carried on the field type), so they /// fall back to the Structure (i=22) / Enumeration (i=29) DataType /// respectively. This is a best-effort fallback; the definition is /// always non-null which is the hard contract. /// The expression uses an inline NodeId literal (rather than a /// DataTypeIds constant) so the generated code only depends on /// Opc.Ua.Types and not on the Opc.Ua.Core.Types assembly, matching /// the attribute generator's existing inline-literal convention. /// private static string ResolveFieldDataTypeNodeId( string shortTypeName, bool isEncodeable, bool isEnum) { if (shortTypeName != null && s_builtInDataTypeIdMap.TryGetValue(shortTypeName, out uint id)) { return FormatNodeIdLiteral(id); } if (isEnum) { // Enumeration (i=29) return FormatNodeIdLiteral(29); } if (isEncodeable) { // Structure (i=22) return FormatNodeIdLiteral(22); } // Last-resort fallback for an unexpected/unsupported scalar type; // BaseDataType (i=24) keeps the emitted definition non-null. return FormatNodeIdLiteral(24); } /// /// Formats a namespace-zero numeric DataType identifier as an inline /// NodeId literal expression. /// internal static string FormatNodeIdLiteral(uint identifier) { return $"new global::Opc.Ua.NodeId({identifier}u)"; } /// /// Maps OPC UA built-in C# short type names to the namespace-zero /// numeric DataType identifier. Mirrors the canonical BuiltInType to /// DataTypeId mapping in TypeInfo.GetDataTypeId. /// private static readonly Dictionary s_builtInDataTypeIdMap = new(StringComparer.OrdinalIgnoreCase) { ["Boolean"] = 1, ["bool"] = 1, ["SByte"] = 2, ["Byte"] = 3, ["Int16"] = 4, ["short"] = 4, ["UInt16"] = 5, ["ushort"] = 5, ["Int32"] = 6, ["int"] = 6, ["UInt32"] = 7, ["uint"] = 7, ["Int64"] = 8, ["long"] = 8, ["UInt64"] = 9, ["ulong"] = 9, ["Single"] = 10, ["float"] = 10, ["Double"] = 11, ["String"] = 12, ["DateTime"] = 13, ["DateTimeUtc"] = 13, ["Guid"] = 14, ["Uuid"] = 14, ["ByteString"] = 15, ["XmlElement"] = 16, ["NodeId"] = 17, ["ExpandedNodeId"] = 18, ["StatusCode"] = 19, ["QualifiedName"] = 20, ["LocalizedText"] = 21, ["ExtensionObject"] = 22, ["DataValue"] = 23, ["Variant"] = 24, ["DiagnosticInfo"] = 25 }; /// /// Extracts the default value initializer expression from a /// partial property definition (e.g. the "= true" part of /// "public partial bool Foo { get; init; } = true;"). /// Returns null if no initializer is present. /// private static string GetPropertyInitializer(IPropertySymbol prop) { return GetPropertyInitializerSyntax(prop)?.ToString(); } private static ExpressionSyntax GetPropertyInitializerSyntax(IPropertySymbol prop) { foreach (SyntaxReference syntaxRef in prop.DeclaringSyntaxReferences) { if (syntaxRef.GetSyntax() is PropertyDeclarationSyntax propSyntax && propSyntax.Initializer != null) { return propSyntax.Initializer.Value; } } return null; } /// /// The names of the members the constructor the activator runs (the /// parameterless one) assigns, or null if it can assign any /// member: it chains to another constructor or calls a method of the /// instance. The analysis is syntactic and over-approximates, which /// only keeps more fields on the wire. /// private static HashSet GetConstructorAssignedMembers( INamedTypeSymbol symbol, CancellationToken ct) { var assigned = new HashSet(StringComparer.Ordinal); IMethodSymbol ctor = symbol.InstanceConstructors .FirstOrDefault(c => c.Parameters.Length == 0 && !c.IsImplicitlyDeclared); if (ctor == null) { return assigned; } foreach (SyntaxReference reference in ctor.DeclaringSyntaxReferences) { if (reference.GetSyntax(ct) is not ConstructorDeclarationSyntax declaration) { continue; } if (declaration.Initializer != null && declaration.Initializer.IsKind(SyntaxKind.ThisConstructorInitializer)) { return null; } SyntaxNode body = (SyntaxNode)declaration.Body ?? declaration.ExpressionBody; if (body == null) { continue; } foreach (SyntaxNode node in body.DescendantNodesAndSelf()) { ExpressionSyntax target = node switch { AssignmentExpressionSyntax assignment => assignment.Left, PrefixUnaryExpressionSyntax prefix when prefix.IsKind(SyntaxKind.PreIncrementExpression) || prefix.IsKind(SyntaxKind.PreDecrementExpression) => prefix.Operand, PostfixUnaryExpressionSyntax postfix when postfix.IsKind(SyntaxKind.PostIncrementExpression) || postfix.IsKind(SyntaxKind.PostDecrementExpression) => postfix.Operand, ArgumentSyntax argument when !argument.RefKindKeyword.IsKind(SyntaxKind.None) => argument.Expression, _ => null }; if (target != null) { foreach (IdentifierNameSyntax name in target .DescendantNodesAndSelf() .OfType()) { assigned.Add(name.Identifier.ValueText); } } else if (node is InvocationExpressionSyntax invocation && IsInstanceCall(invocation.Expression)) { return null; } } } return assigned; // A call of an unqualified or this-qualified method (other than // nameof) can assign any member, e.g. an Initialize() helper. static bool IsInstanceCall(ExpressionSyntax expression) { return expression switch { IdentifierNameSyntax name => name.Identifier.ValueText != "nameof", GenericNameSyntax => true, MemberAccessExpressionSyntax memberAccess => memberAccess.Expression is ThisExpressionSyntax, _ => false }; } } /// /// True if no declaration of the property has accessor bodies, so /// the property initializer is its default value. /// private static bool IsAutoProperty(IPropertySymbol prop) { foreach (SyntaxReference syntaxRef in prop.DeclaringSyntaxReferences) { if (syntaxRef.GetSyntax() is not PropertyDeclarationSyntax propSyntax || propSyntax.ExpressionBody != null || propSyntax.AccessorList == null || propSyntax.AccessorList.Accessors.Any(a => a.Body != null || a.ExpressionBody != null)) { return false; } } return true; } /// /// The instance field a property getter returns unchanged /// (get => m_x;, get { return m_x; } or /// => this.m_x), or null. /// private static IFieldSymbol GetBackingField(IPropertySymbol prop) { foreach (SyntaxReference syntaxRef in prop.DeclaringSyntaxReferences) { if (syntaxRef.GetSyntax() is not PropertyDeclarationSyntax propSyntax) { continue; } ExpressionSyntax returned = propSyntax.ExpressionBody?.Expression; AccessorDeclarationSyntax getter = propSyntax.AccessorList?.Accessors .FirstOrDefault(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); if (returned == null && getter != null) { returned = getter.ExpressionBody?.Expression ?? (getter.Body?.Statements is { Count: 1 } statements && statements[0] is ReturnStatementSyntax ret ? ret.Expression : null); } string name = returned switch { IdentifierNameSyntax identifier => identifier.Identifier.ValueText, MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax, Name: IdentifierNameSyntax identifier } => identifier.Identifier.ValueText, _ => null }; if (name != null) { return prop.ContainingType.GetMembers(name) .OfType() .FirstOrDefault(f => !f.IsStatic && !f.IsConst); } } return null; } private static ExpressionSyntax GetFieldInitializerSyntax(IFieldSymbol field) { foreach (SyntaxReference syntaxRef in field.DeclaringSyntaxReferences) { if (syntaxRef.GetSyntax() is VariableDeclaratorSyntax declarator && declarator.Initializer != null) { return declarator.Initializer.Value; } } return null; } /// /// True for an initializer that assigns the CLR default anyway /// (null, default, null!). /// private static bool IsDefaultLiteral(ExpressionSyntax expression) { if (expression is PostfixUnaryExpressionSyntax postfix && postfix.IsKind(SyntaxKind.SuppressNullableWarningExpression)) { expression = postfix.Operand; } return expression.IsKind(SyntaxKind.NullLiteralExpression) || expression.IsKind(SyntaxKind.DefaultLiteralExpression); } /// /// True for a self-contained literal (number, string, boolean, /// optionally negated) that can be compared against in generated /// code without depending on the user's usings. /// private static bool IsSimpleLiteral(ExpressionSyntax expression) { if (expression is PrefixUnaryExpressionSyntax prefix && (prefix.IsKind(SyntaxKind.UnaryMinusExpression) || prefix.IsKind(SyntaxKind.UnaryPlusExpression))) { expression = prefix.Operand; return expression.IsKind(SyntaxKind.NumericLiteralExpression); } return expression.IsKind(SyntaxKind.NumericLiteralExpression) || expression.IsKind(SyntaxKind.StringLiteralExpression) || expression.IsKind(SyntaxKind.TrueLiteralExpression) || expression.IsKind(SyntaxKind.FalseLiteralExpression); } /// /// True for string.Empty (in any of its spellings). /// private static bool IsStringEmpty(ExpressionSyntax expression) { return expression is MemberAccessExpressionSyntax memberAccess && memberAccess.Name.Identifier.ValueText == "Empty" && memberAccess.Expression.ToString() is "string" or "String" or "System.String" or "global::System.String"; } /// /// Property types a literal initializer can be compared with. /// private static readonly HashSet s_literalComparableTypes = new(StringComparer.Ordinal) { "Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64", "Single", "Double", "String" }; /// /// Detects whether a property has an init-only setter by /// checking both the semantic model (IsInitOnly) and the /// syntax tree (init keyword). The syntax check is needed /// for partial property definitions where the semantic /// model may not expose the init accessor. /// private static bool HasInitOnlySetter(IPropertySymbol prop) { if (prop.SetMethod?.IsInitOnly == true) { return true; } // For partial property definitions the semantic model // may not expose IsInitOnly. Fall back to syntax check. foreach (SyntaxReference syntaxRef in prop.DeclaringSyntaxReferences) { if (syntaxRef.GetSyntax() is PropertyDeclarationSyntax propSyntax && propSyntax.AccessorList != null) { foreach (AccessorDeclarationSyntax accessor in propSyntax.AccessorList.Accessors) { if (accessor.Kind() == SyntaxKind.InitAccessorDeclaration) { return true; } } } } return false; } private static AttributeArgumentSyntax GetUnresolvedNamespaceArgument( AttributeData attribute, CancellationToken cancellationToken) { if (attribute?.ApplicationSyntaxReference?.GetSyntax(cancellationToken) is not AttributeSyntax attributeSyntax) { return null; } AttributeArgumentSyntax namespaceArgument = attributeSyntax.ArgumentList?.Arguments .FirstOrDefault(argument => argument.NameEquals?.Name.Identifier.ValueText == nameof(DataTypeAttribute.Namespace)); if (namespaceArgument == null) { return null; } foreach (KeyValuePair namedArgument in attribute.NamedArguments) { if (namedArgument.Key == nameof(DataTypeAttribute.Namespace)) { return namedArgument.Value.Kind == TypedConstantKind.Error ? namespaceArgument : null; } } return namespaceArgument; } private static string ResolveNamespaceUri( INamedTypeSymbol symbol, string dataTypeNamespace, string dotNetNamespace) { if (!string.IsNullOrEmpty(dataTypeNamespace)) { return dataTypeNamespace; } AttributeData dcAttr = symbol.GetAttributes() .FirstOrDefault(a => a.AttributeClass?.Name == nameof(DataContractAttribute)); if (dcAttr != null) { string dcNs = dcAttr.GetValue(nameof(DataContractAttribute.Namespace)); if (!string.IsNullOrEmpty(dcNs)) { return dcNs; } } return "urn:" + dotNetNamespace.ToLowerInvariant(); } } }