Files
romanett 442889cba5 Fix source generator audit findings; conform structure-field matrices and type metadata to OPC UA 1.05 (#4569)
# Description

This PR fixes a bug audit of the source generation projects
(`tools/Opc.Ua.SourceGeneration*`: model loading, NodeSet import, all
emitters, the Roslyn hosts and packaging). It also fixes a max-effort
code review of those fixes, and a check of every spec-relevant change
against the OPC UA 1.05 text.

- **Scope:** about 85 audit findings, 48 review findings and 32
spec-grounding items were addressed. Each fix has a regression test that
fails without it.
- **Wire change:** it includes one deliberate, stack-wide wire change:
**structure-field matrices now follow OPC 10000-6 5.2.5 Table 28**.

Most changes are internal correctness fixes. The sections below list
every change a user can observe: wire formats, generated code and API,
generated address-space content, diagnostics and runtime behaviour.

## Behaviour / user-facing changes

### Wire format (encoders)
- **Binary inline matrices** (OPC 10000-6 5.2.5 Table 28).
- **Layout:** a multi-dimensional *structure field* is encoded as
`Int32[] Dimensions`, then the values **without a length prefix**, with
at least 2 dimensions.
- **Empty and null:** any dimension <= 0 means no values follow, and a
null matrix is `-1`. An empty matrix is written as `[0,0]`.
- **Compatibility:** this is **not compatible with earlier builds of
this SDK** for matrix fields. It applies to generated types,
`[DataType]` types, `Structure`, client ComplexTypes and
`WriteEncodeableMatrix`.
  - **Unchanged:** Variant matrices (5.2.2.16) keep their old encoding.
- **Declared rank decides matrix vs array.** A 1-D `MatrixOf<T>` in an
array field or a Variable value is written as an array again.
Matrix-declared fields go through the new public
`EncoderExtensions.WriteInlineMatrixValue`, which generated code also
uses. A non-empty 1-D value in a matrix field throws `BadEncodingError`.
- **Decoder hardening:**
- Before allocating, the decoder bounds all matrix dimensions against
overflow, `MaxArrayLength` and the remaining message length.
  - The decoder rejects:
    - a matrix with fewer than 2 dimensions;
- a populated matrix whose rank differs from the field's declared rank;
    - a union SwitchField greater than the number of fields;
    - an EncodingMask with unassigned bits set.
- **Encoder `MaxArrayLength`:** the encoder enforces `MaxArrayLength`
for inline matrices, as it already did for arrays.
- **XML structure fields** (5.3.1, 5.3.4, 5.3.5).
- **Fields:** `Structure` and client ComplexTypes write fields like
typed fields (`<A>1</A>`, array elements directly) instead of a Variant
body.
- **Matrix fields:** `Dimensions` and `Elements` go directly inside the
field element, with no `<Matrix>` wrapper.
- **Empty matrices:** written as nil, because XML dimensions must be
greater than 0.
  - **Compatibility:** decoders still accept the old shapes.
- **Enum fixes:** enum array elements with value 0 are no longer
dropped, and enum array element names are fixed.
- **Other encoding fixes:**
- A null `DiagnosticInfo` structure field writes `0x00` instead of
nothing.
- A null scalar field in a runtime `Structure` is written as its default
instead of zero bytes, which used to shift every following field.
- A Variant holding an empty matrix encodes as an empty array instead of
throwing.
  - `Variant.Copy` keeps the matrix identity of null and empty matrices.
- **NodeSet export and the DataContract surrogate** write Variable
values with the Variant rules again. A null or empty `MatrixOf` variable
no longer produces invalid XML or unreadable NodeSets.
- **`JsonEncoder.CanOmitFields`** is `true` only for Compact encoding.
Verbose and RawData JSON include default-valued fields.

### `[DataType]` source-generated types
- **Default-value handling** (OPC 10000-6 5.4.1, Table 1).
- **Encode:** a field is omitted only when its value *and* its declared
default are the type's default (0, false, null). Fields with a
non-default initializer or constructor default, and empty strings, are
always written, so configuration files get slightly larger.
- **JSON decode:** a field missing from JSON decodes to the type
default.
- **XML decode:** a field missing from XML keeps its declared default,
which older configuration files rely on.
- **Why:** this fixes silent round-trip loss, for example
`SamplingInterval = 0` read back as `-1`.
- **Nested `[DataType]` types:** they are now generated inside their
containing types. Without an explicit id, their default TypeId and
XmlName are qualified (e.g. `s=Outer.Foo`); **wire identity change** for
nested types. Colliding activator names get a `_2` suffix.
- **Activators:** they are `internal` when the type, or any containing
type, is not public (was CS0060).
- **Matrix properties:**
  - `MatrixOf<T>` properties use the inline matrix encoding.
- Matrices of non-sealed structures, or with
`StructureHandling.ExtensionObject`, are written as ExtensionObjects.
- Their published ValueRank is 2, because the rank can't be known from
`MatrixOf<T>`.
- **Published StructureDefinitions:**
  - They include base-class fields.
  - Nullable properties are no longer marked optional.
  - Inline structure fields name their concrete type.
- **`[Flags]` enums:** they are published as plain enumerations with
their member values, which matches how they are encoded.
- **Other generated code:**
- `Clone` deep-copies arrays and matrices of structures, Variants,
ExtensionObjects and DataValues.
- `IsEqual` and `GetHashCode` are consistent: EncodingMask, active union
member and NaN handling agree.
- Pooled `Reuse()` restores the freshly constructed state through the
new `protected virtual ResetForReuse()`, and returns an instance to its
runtime type's pool.
- **Enum capture:** enum member values are captured culture-invariantly.
Negative values were lost on cultures that use U+2212 as the minus sign.

### Model-driven generator: generated code, API and file names
- **Removed API:** generated `*_Placeholder` proxy accessors (e.g.
`PowerTrainTypeClient.GetMotorIdentifier_PlaceholderAsync`) and fluent
`X_Placeholder()` accessors are removed; they could never resolve
anything.
- Proxy accessors now look children up by BrowseName with the right
namespace.
- `RoboticsClient` finds motors and gears by type and drives via
`IsDrivenBy`. Its topology snapshots used to be empty.
- **Renamed files:** generated files for a bound `[NodeManager]` are now
named `{Namespace}.{Class}.NodeManager.g.cs` (also `.Factory` and
`.FluentBuilders`).
- **Generated NodeState code:**
- Attributes are set through `baseState`/`nodeState` locals, so children
named like base attributes (e.g. `DataType`, `EventNotifier`) compile.
- Children that hide base members are declared `public new`, using names
reflected from the runtime.
  - `global::` qualification is used throughout.
- **Proxies and fluent builders:** a proxy whose supertype is excluded
(e.g. Draft) derives from the nearest emitted ancestor. Fluent wrappers
and NodeState factories fall back the same way; they used to fail with
CS0246.
- **Derived structures:**
  - The optional-field `EncodingMask` continues across inheritance.
- A derived structure over a base type from another model no longer
relies on `base.ResetForReuse`.
- **Default values:**
- NodeId, QualifiedName and ExpandedNodeId defaults resolve by namespace
URI (`context.NamespaceUris.GetIndexOrAppend`) and server URI.
- StatusCode, negative enum, `double`/`float` (shortest round-trip
literal) and escaped string defaults now compile.
- **Event records:**
- Vendor event records have `Decoder.GetStandardFields(NamespaceTable)`,
and their browse paths use the model's namespace.
- A field whose namespace is unknown to the session no longer matches a
namespace-0 field.
  - Companion `StandardFields` is `[Obsolete]`.
- Vendor simple subtypes such as `Percent : Double` map to their
built-in type.
- **Other fixes:**
- `MethodState.CallAsync` passes the real `objectId` to synchronous
`OnCallMethod2` handlers; it used to pass `NodeId.Null`.
- Enumeration fields with an explicit `Identifier` keep it, instead of
taking a trailing `_<digits>`.
  - Explicit NumericIds are no longer reused by automatic numbering.
  - Dependencies are loaded in topological order.

### Generated address-space content
- **Arguments:**
- Methods without arguments no longer get empty
`InputArguments`/`OutputArguments` properties (OPC 10000-3 5.7).
- About 2,660 automatically assigned namespace-0 NodeIds of 15000 and
above shift. Every NodeId in `StandardTypes.csv` is unchanged.
- **Permissions:** type nodes and method declarations on types now carry
the model's `RolePermissions`/`AccessRestrictions`.
- **Restored nodes:** NodeSet import keeps nodes that were silently
dropped before:
- `NamespaceMetadata` objects and the NodeSet's own TypeDictionary
variables;
  - ISA95 placeholders linked only non-hierarchically;
  - instances whose parent is in another namespace.
- **Import details:**
  - ValueRank > 1 without ArrayDimensions keeps its rank.
  - `svr=` indexes map correctly.
  - Invalid MinimumSamplingInterval values import as -1, not 0.
  - Localized texts prefer the invariant or English translation.
- **StructureDefinitions:**
- They include inherited fields, including for subtypes without their
own fields (e.g. `AnonymousIdentityToken` now publishes PolicyId).
  - The StructureType kind covers inherited AllowSubTypes fields.
  - Unions ignore IsOptional.
- StructureFields never publish ValueRank 0; a OneOrMoreDimensions field
without dimensions becomes rank 2.
- Fields with ValueRank -2/-3 are published as scalar BaseDataType,
which is what is on the wire.
- **OptionSets:**
  - Implicit fields get the next free bit.
- `OptionSetValues` uses null for undefined bits instead of "Reserved",
and structure OptionSets publish all their bits (no 32/96-bit cap).
- Numeric OptionSets are limited to their base type's width, and UInt64
bit 63 works.
- **Schemas:**
- Structures with inline-matrix fields are left out of the `.bsd`
(5.2.5).
- In the BSD, Variant-encoded and non-structure subtyped fields are
described as `ua:Variant`.
- In the `.xsd`, matrix fields are typed `ua:Matrix` with
`Dimensions`/`Elements`, and `EncodingMask` is required.

### NodeSet / model input handling
- **Model selection:**
- The newest NodeSet of a model is chosen by `ModelVersion` (SemVer 2.0
precedence), then PublicationDate. The `Version` attribute, which Annex
F says is not for comparison, is only a tie-break.
- The order is total and does not depend on input order; referenced
assemblies use the same rule.
- **Default names:**
- Invalid default Name/Prefix values are repaired (`201301ISA95` becomes
`_201301ISA95`, `class` becomes `class_`).
  - Valid names are unchanged.
  - Sanitising no longer depends on the OS.
- **`ModelUri` metadata** that doesn't match the NodeSet raises a
warning instead of silently losing the model.
- **Input detection:**
- Only XML files whose root is `ModelDesign` or `UANodeSet` are treated
as models.
- A malformed NodeSet stays a NodeSet input, so it no longer breaks the
ModelDesign pass.
- **`IdentifierFile` paths:** they are normalized (`./`, `../`), and the
suffix match requires a folder boundary.
- **Dependency payloads** (`ModelDependencyV1`) carry field IsOptional,
AllowSubTypes, ArrayDimensions and the producer's namespace tables in
skippable trailers, so old and new readers stay compatible.

### Roslyn host, diagnostics, packaging
- **New diagnostics:**
- MODELGEN036: `[NodeManager]` target not supported (nested, generic,
file-local or global namespace).
- MODELGEN037: `[DataType]` target not supported (struct, generic,
abstract, file-local or global namespace).
- MODELGEN038: the definition of an encodeable base type can't be found.
- **New validator errors:**
- a structure with both optional and subtyped fields (OPC 10000-6 F.13);
  - multi-bit or duplicate OptionSet masks.
- **Diagnostic locations:** `[DataType]`/`[NodeManager]` diagnostics
point at real source locations again, so `#pragma` and `.editorconfig`
apply. MODELGEN010 is the exception.
- **Incremental caching:** pipeline values compare by value, so
unrelated edits no longer regenerate the whole model or all
`[DataType]`s.
- **`ModelSourceGeneratorIgnore`** on a ModelDesign input is honoured;
the design still resolves as a dependency.
- **Cancellation:** a cancelled generation run propagates
`OperationCanceledException` instead of reporting a MODELGEN003 error.
- **Stack package:** the `Stack` NuGet package now ships
`build/OPCFoundation.NetStandard.Opc.Ua.SourceGeneration.Stack.props`,
so `StackSourceGeneratorMode` and `StackSourceGeneratorExclude` reach
the compiler.

## Related Issues

- No tracking issue. The audit, review and spec-grounding reports are
available on request. Contains the matrix wire-format change discussed
for D-5 (OPC 10000-6 5.2.5 Table 28).

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
(`docs/SourceGeneratedDataTypes.md`, `docs/NodeManagers.md`, generator
readme)
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. (`UA.slnx` 0 warnings / 0 errors on net48 and
net10.0)
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. The
affected suites were run on net10.0: SourceGeneration Core/Roslyn/Stack,
Types, Core.Encoders, Encoders.Fuzz, Client, Client.ComplexTypes,
PubSub, Core, Server, GDS, Configuration, Di, Robotics, ISA95,
Positioning, OpenUsd, XRegistry. net48 was built; its test legs, and the
post-rebase verification, run in CI.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:47:40 +02:00

1415 lines
60 KiB
C#

/* ========================================================================
* 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
{
/// <summary>
/// Handles a single [DataType]-annotated class or enum.
/// Builds and validates a <see cref="TypeSourceModel"/> during
/// construction. Models are collected via <c>.Collect()</c> and
/// emitted as a batch (one file per namespace) by
/// <see cref="EmitBatch"/> to avoid conflicting extension methods.
/// </summary>
/// <remarks>
/// This is the output of a <c>ForAttributeWithMetadataName</c> transform,
/// which runs again on every compilation change. Every member compares by
/// value (lists are <see cref="EquatableArray{T}"/>, the location is a
/// <see cref="LocationInfo"/>) so an unchanged type leaves the emitted
/// sources cached and does not pin old syntax trees.
/// </remarks>
internal sealed record class DataTypeCompilation
{
/// <summary>
/// The validated model, or null if structural validation failed.
/// </summary>
public TypeSourceModel Model { get; }
/// <summary>
/// The validated fields (empty for enums or on error).
/// </summary>
public EquatableArray<TypeFieldModel> ValidFields { get; }
/// <summary>
/// Diagnostics from field validation.
/// </summary>
public EquatableArray<(bool IsError, string Message)> Diagnostics { get; }
/// <summary>
/// Location for diagnostic reporting.
/// </summary>
public LocationInfo Location { get; }
/// <summary>
/// Why the annotated type cannot be generated (a struct, a generic
/// type, a non-partial or inaccessible containing type), or
/// <c>null</c>.
/// </summary>
public string UnsupportedReason { get; }
/// <summary>
/// True if the model has fatal errors.
/// </summary>
public bool HasErrors { get; }
/// <summary>
/// Structural error message (non-partial, no ctor).
/// </summary>
public string ErrorMessage { get; }
/// <summary>
/// The annotated type name used for diagnostics.
/// </summary>
public string TypeName { get; }
/// <summary>
/// An explicitly supplied namespace expression that Roslyn could not resolve.
/// </summary>
public string UnresolvedNamespaceExpression { get; } = string.Empty;
/// <summary>
/// The encodeable base type whose data type definition cannot be
/// resolved (MODELGEN038), or empty.
/// </summary>
public string UnresolvedBaseDefinition { get; } = string.Empty;
/// <summary>
/// Check whether the generator can handle the node.
/// </summary>
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;
}
/// <summary>
/// Create data type compilation from a Roslyn symbol.
/// Builds and validates the model eagerly.
/// </summary>
public DataTypeCompilation(
GeneratorAttributeSyntaxContext context,
CancellationToken cancellationToken)
{
var symbol = (INamedTypeSymbol)context.TargetSymbol;
TypeName = symbol.ToDisplayString();
Location = LocationInfo.From(symbol.Locations.FirstOrDefault());
ValidFields = EquatableArray<TypeFieldModel>.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<TypeSourceGeneratorDiagnostic> diags =
TypeSourceGenerator.ValidateAndFilter(
Model, out IReadOnlyList<TypeFieldModel> 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<TypeFieldModel>.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));
}
/// <summary>
/// Returns why the generated members cannot be attached to the
/// annotated type, or <c>null</c> when they can.
/// </summary>
/// <remarks>
/// The generated declaration is a <c>partial class</c> or
/// <c>partial record class</c>, 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.
/// </remarks>
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;
}
/// <summary>
/// True if the type and all its containing types are declared public,
/// so the namespace-level activator can be public as well.
/// </summary>
private static bool IsEffectivelyPublic(INamedTypeSymbol symbol)
{
for (INamedTypeSymbol type = symbol; type != null; type = type.ContainingType)
{
if (type.DeclaredAccessibility != Accessibility.Public)
{
return false;
}
}
return true;
}
/// <summary>
/// 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.
/// </summary>
private static TypeSourceModel WithNesting(
TypeSourceModel model,
INamedTypeSymbol symbol)
{
if (symbol.ContainingType == null)
{
return model;
}
var declarations = new List<string>();
var names = new List<string>();
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<string>([.. 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"
}
};
}
/// <summary>
/// A partial declaration of a containing type, without accessibility
/// (a partial part may omit it) and without base list.
/// </summary>
private static string GetPartialDeclaration(INamedTypeSymbol type)
{
var modifiers = new List<string>();
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);
}
/// <summary>
/// Report the diagnostics of a batch of compilations. The locations
/// are re-created in the syntax trees of <paramref name="compilation"/>
/// so that <c>#pragma warning</c>, per-file severity configuration and
/// <c>#line</c> mapping apply to them. Kept apart from
/// <see cref="EmitBatch"/> so the source output does not depend on the
/// compilation and stays cached.
/// </summary>
public static void ReportDiagnostics(
SourceProductionContext sourceContext,
ImmutableArray<DataTypeCompilation> 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));
}
}
}
/// <summary>
/// Emit a batch of compilations as one file per namespace. The
/// diagnostics are reported by <see cref="ReportDiagnostics"/>.
/// </summary>
public static void EmitBatch(
SourceProductionContext sourceContext,
ImmutableArray<DataTypeCompilation> 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<string>(StringComparer.OrdinalIgnoreCase);
List<DataTypeCompilation> valid = [.. compilations
.Where(c => !c.HasErrors && c.Model != null)];
Dictionary<string, (string Namespace, string SymbolName)> renamed =
GetUniqueSymbolNames(valid);
IEnumerable<IGrouping<string, DataTypeCompilation>> validByNamespace =
valid.GroupBy(c => c.Model.Namespace);
foreach (IGrouping<string, DataTypeCompilation> group in validByNamespace)
{
List<DataTypeCompilation> entries = [.. group];
TypeSourceModel first = entries[0].Model;
var allTypes = new List<TypeSourceModel>();
var allActivators = new List<TypeSourceModel>();
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);
}
}
/// <summary>
/// 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 (<c>Models.Foo</c> and
/// <c>Models_Foo</c>) or of another nested type (<c>A.B_C</c> and
/// <c>A_B.C</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.
/// </summary>
/// <returns>The new symbol names by fully qualified type name.</returns>
private static Dictionary<string, (string Namespace, string SymbolName)> GetUniqueSymbolNames(
List<DataTypeCompilation> compilations)
{
var renamed = new Dictionary<string, (string, string)>(StringComparer.Ordinal);
foreach (IGrouping<string, TypeSourceModel> group in compilations
.Select(c => c.Model)
.GroupBy(m => m.Namespace, StringComparer.Ordinal))
{
var taken = new HashSet<string>(
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;
}
/// <summary>
/// Applies the renames of <see cref="GetUniqueSymbolNames"/> to a
/// model and to the activator of its base type.
/// </summary>
private static TypeSourceModel WithUniqueSymbolNames(
TypeSourceModel model,
Dictionary<string, (string Namespace, string SymbolName)> 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<string>.Empty,
IsEffectivelyPublic = IsEffectivelyPublic(symbol),
EnumMembers = EquatableArray<TypeEnumMember>.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<IMethodSymbol>()
.Any(m => m.Name is "Clone" or "MemberwiseClone" &&
!m.IsImplicitlyDeclared),
Fields = EquatableArray<TypeFieldModel>.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);
}
/// <summary>
/// Resolves the activator of an encodeable base type that exposes the
/// base type's data type definition: the <c>{Name}Activator</c>
/// 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.
/// </summary>
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<TypeEnumMember>();
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<string>.Empty,
IsEffectivelyPublic = IsEffectivelyPublic(symbol),
Fields = EquatableArray<TypeFieldModel>.Empty,
IsEnum = true,
IsFlags = symbol.GetAttributes().Any(a =>
a.AttributeClass?.Name == "FlagsAttribute"),
EnumMembers = EquatableArray<TypeEnumMember>.From(members)
}, symbol);
}
private static List<TypeFieldModel> CollectFields(
INamedTypeSymbol symbol, CancellationToken ct)
{
// Get ALL non-abstract, non-static properties regardless of
// accessibility for [DataTypeField] scanning.
IPropertySymbol[] allProperties =
[
.. symbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => !p.IsAbstract &&
!p.IsStatic &&
!p.IsReadOnly)
];
// Check if any property has [DataTypeField] — if so, use only those
Tuple<IPropertySymbol, AttributeData>[] 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<TypeFieldModel>();
// null if the constructor can assign any member.
HashSet<string> 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<IPropertySymbol, AttributeData> 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<string> 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<string, TypedConstant> 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
};
}
/// <summary>
/// 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 <c>TypeInfo.GetDataTypeId</c>).
/// 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 <c>NodeId</c> literal (rather than a
/// <c>DataTypeIds</c> 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.
/// </summary>
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);
}
/// <summary>
/// Formats a namespace-zero numeric DataType identifier as an inline
/// <c>NodeId</c> literal expression.
/// </summary>
internal static string FormatNodeIdLiteral(uint identifier)
{
return $"new global::Opc.Ua.NodeId({identifier}u)";
}
/// <summary>
/// Maps OPC UA built-in C# short type names to the namespace-zero
/// numeric DataType identifier. Mirrors the canonical BuiltInType to
/// DataTypeId mapping in <c>TypeInfo.GetDataTypeId</c>.
/// </summary>
private static readonly Dictionary<string, uint> 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
};
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// The names of the members the constructor the activator runs (the
/// parameterless one) assigns, or <c>null</c> 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.
/// </summary>
private static HashSet<string> GetConstructorAssignedMembers(
INamedTypeSymbol symbol,
CancellationToken ct)
{
var assigned = new HashSet<string>(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<IdentifierNameSyntax>())
{
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
};
}
}
/// <summary>
/// True if no declaration of the property has accessor bodies, so
/// the property initializer is its default value.
/// </summary>
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;
}
/// <summary>
/// The instance field a property getter returns unchanged
/// (<c>get => m_x;</c>, <c>get { return m_x; }</c> or
/// <c>=> this.m_x</c>), or <c>null</c>.
/// </summary>
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<IFieldSymbol>()
.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;
}
/// <summary>
/// True for an initializer that assigns the CLR default anyway
/// (<c>null</c>, <c>default</c>, <c>null!</c>).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// True for <c>string.Empty</c> (in any of its spellings).
/// </summary>
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";
}
/// <summary>
/// Property types a literal initializer can be compared with.
/// </summary>
private static readonly HashSet<string> s_literalComparableTypes =
new(StringComparer.Ordinal)
{
"Boolean",
"SByte",
"Byte",
"Int16",
"UInt16",
"Int32",
"UInt32",
"Int64",
"UInt64",
"Single",
"Double",
"String"
};
/// <summary>
/// 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.
/// </summary>
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<string, TypedConstant> 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();
}
}
}