Files
Marc Schier 76d33cb139 Remove SourceGenerator.Foundations, ship analyzers under Roslyn-versioned folders for VS2022 and VS2026, and validate the packages (#4120)
# Description

The two OPC UA source generators were built on
`SourceGenerator.Foundations` (SGF), whose only job was getting the
generator's dependency closure loaded inside the compiler. It does so
with a **process-wide, first-generator-wins `AppDomain.AssemblyResolve`
hook backed by a non-concurrent `Dictionary` mutated from `AssemblyLoad`
callbacks**. Concurrent compilations in a shared `VBCSCompiler` race it,
which is the root cause of the intermittent clean-build failure:

```
CSC : error CS8784: Generator 'StackSourceGeneratorHoist' failed to initialize. ...
Exception was of type 'FileNotFoundException' with message 'Could not load file or assembly
'SourceGenerator.Foundations.Contracts, Version=2.0.14.0, ...'
```

The repository already solves that problem deterministically — #4041
made the NuGet packages ship the full closure under
`analyzers/dotnet/.../cs`, and `Directory.Build.targets` registers the
same closure as `Analyzer` items for in-repo `ProjectReference`
consumers. SGF was therefore pure liability and is removed.

## What changed

**1. SourceGenerator.Foundations removed.** `ModelSourceGenerator` and
`StackSourceGenerator` are plain `[Generator(LanguageNames.CSharp)]
IIncrementalGenerator` implementations again. SGF's implicit crash
handling is replaced by an explicit `SourceGenerator.Guard` that reports
`MODELGEN003` / `STACKGEN003` and rethrows cancellation. Its logger sink
was already dead code and is dropped.
`Opc.Ua.SourceGeneration.Stack.dll` shrank from **18 MB** (embedded
closure) to **29 KB**.

**2. Analyzers now ship under Roslyn-versioned folders.**
`analyzers/dotnet/roslyn4.14/cs` and `analyzers/dotnet/roslyn5.0/cs`
instead of `analyzers/dotnet/cs`, driven by `roslyn.props` at the
repository root, with new `*.Pack` projects owning the package identity.
The versioned folder matters: a host older than the band **skips** the
analyzer instead of loading it and failing inside generator
initialization.

**3. Dependencies flow automatically.** Both delivery paths discover the
payload by globbing the generator's output directory, so adding a
package to a generator's dependency tree requires **no** `.csproj`,
`.targets`, `.nuspec` or validation-script edit. Verified by temporarily
adding a `PackageReference` and watching it appear in the payload with
no other file touched.

## Two Roslyn bands: VS2022 and VS2026

Analyzers ship twice, once per band. The .NET SDK loads the highest band
its compiler supports and **silently skips** anything above it, so a
single-band package leaves every consumer below that band with no
diagnostics and no generated code.

| Band | Built against | Loaded by |
| --- | --- | --- |
| `roslyn4.14` | `Microsoft.CodeAnalysis.CSharp 4.14.0` | Visual Studio
2022 17.14+ / .NET 9 SDK |
| `roslyn5.0` | `Microsoft.CodeAnalysis.CSharp 5.0.0` | Visual Studio
2026 18.0+ / .NET 10 SDK |

Getting this wrong is invisible, which is most of why it took three
attempts. The rule:

> An analyzer must reference the **lowest**
`System.Collections.Immutable` / `System.Reflection.Metadata` across all
hosts it claims to support, and must never ship them. .NET binds a
reference *upward* but never downward.

Every failure mode is a compiler **warning**:

| Diagnostic | Cause |
| --- | --- |
| `CS9057` | Built against a newer Roslyn than the host, so it is
skipped entirely |
| `CS8784` | Loaded, then `MissingMethodException` - a shipped S.C.I
bound a second `ImmutableArray<T>` |
| `CS8032` | Analyzer instance could not be created |

This PR first shipped a `roslyn4.8` band that **never loaded** -
confirmed against a real Roslyn 4.8 compiler:

```
warning CS8784: Generator 'ModelSourceGenerator' failed to initialize ...
MissingMethodException: Method not found:
'IncrementalValueProvider`1<ImmutableArray`1<!!0>>
 IncrementalValueProviderExtensions.Collect(IncrementalValuesProvider`1<!!0>)'
```

That band was removed, and **this is not a regression**: master had the
same defect, pinned to Roslyn 4.14 while referencing
`System.Collections.Immutable 10.0.0.0`, so no 4.x host ever worked
there either.

The band that landed instead is `4.14`, and it is nearly free:
`Microsoft.CodeAnalysis 4.14` depends on the **same S.C.I 9.0.0 as
5.0.0** and its `csc` runs on `net9.0`. Both bands therefore share
`$(RoslynRuntimeVersion)`, so `Opc.Ua.Types` and
`Opc.Ua.SourceGeneration.Core` are compiled **once** and serve both;
only the five Roslyn-referencing assemblies get a second build, each
from a 12-line project that glob-links the primary sources and overrides
`RoslynApiVersion`. Going lower still - 4.8 wants S.C.I 7.x - would mean
rebuilding that whole closure, `Opc.Ua.Types` included, and remains out
of scope. (`System.Collections.Immutable 7.0.0` also does not resolve in
this repository: `NU1603`, 8.0.0 substituted.)

**Nothing else in the repository exercises the down-level payload**, and
since every failure is a warning, a broken band would ship silently -
exactly as the 4.8 one did. So `validate-source-generator-packages.ps1`
now restores a real `Microsoft.Net.Compilers.Toolset 4.14.0`, runs its
`csc.dll` over the packed `roslyn4.14` payload, and asserts **both**
that none of `CS9057`/`CS8784`/`CS8032`/`CS8034` is emitted **and** that
`/reportanalyzer` lists the generator as having executed - silence alone
would also pass if the generator were never handed to the compiler. The
migration analyzer, whose payload comes from a hand-written nuspec
rather than `SourceGeneratorPack.targets`, gets the same check plus a
band/assembly manifest assertion.

To stop the trap recurring, `Microsoft.CodeAnalysis*`,
`System.Collections.Immutable` and `System.Reflection.Metadata` are
excluded from both the package payload and the in-repo analyzer closure,
and asserted against in the validation script. `roslyn.props` and
`docs/DeveloperGuide.md` document what adding a band requires.
## The shipped package set is unchanged

Evaluating `PackageId`/`IsPackable` across every project in
`preview-pack.slnx` gives the **same 47 package ids** on `master` and on
this branch, and a full Release build plus `dotnet pack --no-build`
produces exactly those 47. Only the owning project changed:

| Package | master | this PR |
| --- | --- | --- |
| `…Opc.Ua.SourceGeneration` | `Opc.Ua.SourceGeneration.csproj` |
`Opc.Ua.SourceGeneration.Pack.csproj` |
| `…Opc.Ua.SourceGeneration.Stack` |
`Opc.Ua.SourceGeneration.Stack.csproj` |
`Opc.Ua.SourceGeneration.Stack.Pack.csproj` |

`.azurepipelines/expected-packages.txt` pins the expected set and the
validation script fails when the packed output drifts.

## Two new package checks

**`build/<PackageId>.props` naming.** NuGet only auto-imports that exact
name, and the file declares every `CompilerVisibleProperty` /
`CompilerVisibleItemMetadata` the model generator relies on. It was
previously packed as
`build/OPCFoundation.Opc.Ua.SourceGeneration.props`, which does **not**
match the package id — so **no NuGet consumer ever imported it**, and
every `ModelSourceGenerator*` property and `AdditionalFiles` metadata
was silently invisible. In-repo projects were unaffected because they
import it by relative path.

**An end-to-end consumer that actually drives the generator.** The
pre-existing clean-consumer check only proved the analyzer *loads*.
`Test-SourceGeneratingConsumer` builds a standalone project that feeds a
real NodeSet2 in as an `AdditionalFile`, pins a
`ModelSourceGeneratorPrefix` that cannot be derived from the model, and
references the emitted types from hand-written code. It deliberately
does **not** import the props by path, so the package's auto-import is
what is under test. Both checks were verified to fail against a package
rebuilt with the misnamed props.

## CI: parallelism re-enabled

`buildandtest.yml` pinned three `dotnet build` invocations to
`/p:UseSharedCompilation=false` and `-maxcpucount:1`. `dotnet build`
defaults to all logical processors, so `-maxcpucount:1` actively
disabled parallelism. Both flags were introduced silently in unrelated
PRs (`f358078b1`, `208c601b0`) and suppress exactly the conditions the
SGF race needed — now structurally gone. Compilation is ~72 % of what
that workflow executes (233 of 323 job-minutes in run `29916768976`), so
the queue-independent `Build` step durations are the baseline:

| Measurement | Baseline |
| --- | --- |
| `Build` step, 26 `test-*` jobs | mean 5.53 min, sum 143.9 min |
| `build-linux-all-tfm` | 56.3 min |
| `build-windows-all-tfm` | 32.5 min |

`codeql-analysis.yml` keeps the flag — the CodeQL tracer requires it.

## Incidental fixes

- `Microsoft.CodeAnalysis.CSharp` declares an exact `[version]` range on
`Microsoft.CodeAnalysis.Common`, so with
`CentralPackageTransitivePinningEnabled` the central pins must move in
lockstep or restore fails with `NU1109`. They now derive from
`$(RoslynApiVersionVS2026)`.
- `MigrationGeneratorTests` created its generator driver without parse
options — silently fine under Roslyn 4.14, `Inconsistent language
versions` under 5.0.
- The migration analyzer props injected `<Analyzer>` items by hand from
`analyzers/dotnet/cs`; NuGet registers analyzers itself and selects the
matching folder, so the injection was obsolete and would now force the
wrong assembly to load.
- Exception diagnostics reported `ex.StackTrace` (null for an exception
that never left its throw site, and carrying neither type nor inner
chain) — now `ex.ToString()` at all three call sites.
- The pack guard tested the flattened assembly list, so it only fired
when *no* variant produced output; it now checks each declared variant
contributed assemblies.
- `GeneratePackageOnBuild` is disabled for the generators; packing
happens in CI or on an explicit `dotnet pack`.

## Note for reviewers

**Building this repository requires a Roslyn 5.x host** (the .NET 10 SDK
or Visual Studio 2026). That is already the documented prerequisite and
every CI leg installs `10.0.x` last, but it is now a hard requirement.

## Related Issues

_No tracking issue: this is a follow-up to the CS8784 clean-build
failure reported against #4041, which fixed the packaged delivery path
but left the in-repo project-reference path relying on the SGF
resolver._

- Follow-up to #4041

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [x] 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.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

### Verification detail

| Check | Result |
| --- | --- |
| `dotnet build UA.slnx` (clean, and after merging master) | 0 errors;
18 warnings, all pre-existing `CA1873` in `Opc.Ua.Client` |
| `Opc.Ua.SourceGeneration.Tests` | 81/81 on net10.0 and net48 |
| `Opc.Ua.SourceGeneration.Stack.Tests` | 92/92 on net10.0, 89/89 on
net48 |
| `Opc.Ua.SourceGeneration.Core.Tests` | 3722 passed, 8 skipped |
| `Opc.Ua.MigrationAnalyzer.Tests` | 121/121 on net10.0 and net48 |
| Package id set, master vs branch | identical, 47 packages |
| Release build + `dotnet pack --no-build` | 47 packages, matching the
manifest; both `roslyn4.14` and `roslyn5.0` present; no host-provided
assemblies shipped |
| `validate-source-generator-packages.ps1` | passes, including manifest,
props-naming, end-to-end generator consumer and down-level host checks |
| Down-level band under real csc 4.14.0 | all three analyzer packages
load **and run** |
| Negative tests | misnamed props → both checks fail as designed; hidden
variant output → pack guard fires; 5.0 build placed in `roslyn4.14` →
real `CS9057`; non-running generator name → execution assertion fires;
expecting a `roslyn4.8` band → manifest check fires |

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ed53262-5cfa-429a-820e-bf6146a00d8b
2026-07-31 15:46:17 +00:00

742 lines
29 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.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>
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 IReadOnlyList<TypeFieldModel> ValidFields { get; }
/// <summary>
/// Diagnostics from field validation.
/// </summary>
public IReadOnlyList<TypeSourceGeneratorDiagnostic> Diagnostics { get; }
/// <summary>
/// Location for diagnostic reporting.
/// </summary>
public Location Location { 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>
/// Check whether the generator can handle the node.
/// </summary>
public static bool Handles(SyntaxNode node, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
return node is TypeDeclarationSyntax 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 = symbol.Locations.FirstOrDefault();
AttributeData dataTypeAttr = context.Attributes.FirstOrDefault();
AttributeArgumentSyntax unresolvedNamespaceArgument =
GetUnresolvedNamespaceArgument(dataTypeAttr, cancellationToken);
if (unresolvedNamespaceArgument != null)
{
Location = unresolvedNamespaceArgument.GetLocation();
UnresolvedNamespaceExpression =
unresolvedNamespaceArgument.Expression.ToString();
HasErrors = true;
ValidFields = [];
Diagnostics = [];
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);
ValidFields = [];
Diagnostics =
[];
return;
}
bool isPartial = symbol.DeclaringSyntaxReferences
.Any(r => r.GetSyntax(cancellationToken)
is TypeDeclarationSyntax tds &&
tds.Modifiers.Any(SyntaxKind.PartialKeyword));
if (!isPartial)
{
HasErrors = true;
ErrorMessage =
"[DataType] class must be declared as partial.";
ValidFields = [];
Diagnostics =
[];
return;
}
bool hasCtor = symbol.Constructors
.Any(c => c.Parameters.Length == 0);
if (!hasCtor)
{
HasErrors = true;
ErrorMessage =
"[DataType] class must have a parameterless ctor.";
ValidFields = [];
Diagnostics =
[];
return;
}
Model = BuildClassModel(
symbol, dataTypeNamespace, dataTypeId,
binaryEncodingId, xmlEncodingId,
cancellationToken);
IReadOnlyList<TypeSourceGeneratorDiagnostic> diags =
TypeSourceGenerator.ValidateAndFilter(
Model, out IReadOnlyList<TypeFieldModel> valid);
ValidFields = valid;
Diagnostics = diags;
HasErrors = diags.Any(d => d.IsError);
}
catch (Exception ex)
{
HasErrors = true;
ErrorMessage =
$"[DataType] generator error for '{symbol.Name}': " +
$"{ex.GetType().Name}: {ex.Message}";
ValidFields ??= [];
Diagnostics ??=
[];
}
}
/// <summary>
/// Emit a batch of compilations as one file per namespace.
/// </summary>
public static void EmitBatch(
SourceProductionContext sourceContext,
ImmutableArray<DataTypeCompilation> compilations,
bool publicExtensions)
{
foreach (DataTypeCompilation comp in compilations)
{
if (comp.UnresolvedNamespaceExpression.Length > 0)
{
sourceContext.ReportDiagnostic(
Diagnostic.Create(
SourceGenerator.DataTypeNamespaceUnresolved,
comp.Location,
comp.TypeName,
comp.UnresolvedNamespaceExpression));
continue;
}
if (comp.ErrorMessage != null)
{
sourceContext.ReportDiagnostic(
Diagnostic.Create(
SourceGenerator.Exception,
comp.Location,
comp.ErrorMessage));
}
foreach (TypeSourceGeneratorDiagnostic diag in comp.Diagnostics)
{
sourceContext.ReportDiagnostic(
Diagnostic.Create(
diag.IsError
? SourceGenerator.Exception
: SourceGenerator.GenericWarning,
comp.Location,
diag.Message));
}
}
IEnumerable<IGrouping<string, DataTypeCompilation>> validByNamespace =
compilations
.Where(c => !c.HasErrors && c.Model != null)
.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 = comp.Model with
{
PublicExtensions = publicExtensions
};
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);
sourceContext.AddSource(
first.NamespaceSymbol + ".Types.g.cs", source);
}
}
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 new TypeSourceModel
{
ClassName = symbol.Name,
Namespace = ns,
NamespaceUri = ResolveNamespaceUri(
symbol, dataTypeNamespace, ns),
NamespaceSymbol = ns.Replace(".", string.Empty),
DataTypeId = dataTypeId,
BinaryEncodingId = binaryEncodingId,
XmlEncodingId = xmlEncodingId,
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 = CollectFields(symbol, ct),
BaseClassName = symbol.BaseType?.Name == "Object"
? null : symbol.BaseType?.Name
};
}
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,
Value = field.ConstantValue?.ToString() ?? "0"
});
}
}
return new TypeSourceModel
{
ClassName = symbol.Name,
Namespace = ns,
NamespaceUri = ResolveNamespaceUri(
symbol, dataTypeNamespace, ns),
NamespaceSymbol = ns.Replace(".", string.Empty),
DataTypeId = dataTypeId,
BinaryEncodingId = binaryEncodingId,
XmlEncodingId = xmlEncodingId,
IsEnum = true,
IsFlags = symbol.GetAttributes().Any(a =>
a.AttributeClass?.Name == "FlagsAttribute"),
EnumMembers = members
};
}
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>();
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));
}
}
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, dtfAttr));
}
}
fields.Sort((a, b) => a.Order.CompareTo(b.Order));
return fields;
}
private static TypeFieldModel CreateField(
IPropertySymbol prop, string fieldName,
int order, bool hasDataTypeFieldAttr,
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);
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,
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)
{
foreach (SyntaxReference syntaxRef in prop.DeclaringSyntaxReferences)
{
if (syntaxRef.GetSyntax() is PropertyDeclarationSyntax propSyntax &&
propSyntax.Initializer != null)
{
return propSyntax.Initializer.Value.ToString();
}
}
return null;
}
/// <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();
}
}
}