mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 00:56:28 +08:00
76d33cb139
# 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
742 lines
29 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|