mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
81f200c9d2
## Summary Align the generic WoT/NodeSet converter, WoT Connectivity registry, and runtime binding implementation with the OPC UA WoT Binding 1.1 draft. ### Conversion and preservation - fix the standard placeholder ModellingRule NodeIds; - make every produced/restored NodeSet importable by completing required aliases; - support `ua:ComponentOf`, arbitrary companion ReferenceTypes, BrowseNames, InverseNames, symmetric references, and exact direction; - enforce the projection annotation allowlist and deterministic total order; - emit and consume Method argument schemas, EventType data, Alarms & Conditions, event severity, engineering units/ranges, localization, ValueRank, and ArrayDimensions; - preserve unsupported content through JSON residue/native projection rather than silently dropping it; - compare residue conflicts with RFC 8785 canonical equality while retaining byte-exact residue digests; - stamp generated documents with `uav:bindingVersion: "1.1"`. ### Runtime bindings - resolve an EventType Thing Model referenced with `tm:ref`, recursively derive its `data` fields as the baseline selection, and overlay explicit `uav:eventSelectClauses` that also reference EventType TMs; - pre-resolve EventType links asynchronously through the bounded sibling-document resolver before synchronous protocol planning; - retain read compatibility with legacy implementation-specific `uav:eventFields`; - expose nested event `data` matching the TD while retaining the flat transport field index for compatibility; - apply minimum-security constraints through deterministic endpoint selection and fail-closed session verification; - use one Unicode code-point comparer for projection membership and endpoint ties. ### Validation and fixtures - add strict, profile-aware validation while keeping default parsing permissive and residue-preserving; - pin 27 normative examples byte-for-byte to the specification repository, with an offline SHA-256 manifest and regeneration test; - regenerate checked-in WoT documents with the final canonical writer and binding version; - document the generic converter separately from the legacy OPC 10100-1 asset path. ## Notable compatibility changes - converted NodeSets may gain a completed `<Aliases>` table so standard ReferenceType names can be imported; - previously inverted/dangling placeholder ModellingRules are corrected; - generated documents gain binding-version, localization, rank/dimension, and exact ReferenceType metadata; - event data now has the nested shape described by the binding; - the former NodeId-based `uav:typeDefinitionId` clause is removed; event selections now use EventType-TM `tm:ref` links; - `uav:eventFields` remains readable but new documents use `uav:eventSelectClauses`; - unknown well-formed future revision/profile claims produce unsupported warnings rather than strict rejection. ## Generated files Most of the 621-file diff is deterministic regeneration: 517 checked-in WoT documents gain the binding version and final canonical spelling. Regeneration was run twice and is byte-idempotent. ## Validation - `dotnet restore UA.slnx`; - `dotnet build UA.slnx -c Release` across all configured TFMs; - `Opc.Ua.Types.Tests`: 9,215 net10.0; 9,208 net48; 9,208 net472; - `Opc.Ua.WotCon.Bindings.Tests`: 611 each on net10.0, net9.0, and net8.0; - `Opc.Ua.WotCon.Tests`: 1,136 net10.0; - `Opc.Ua.WotCon.Samples.Tests`: 14 net10.0; - NativeAOT test executable: 136/136; - all 27 strict fixture conversion/import checks; - formatting, analyzers, `git diff --check`, and regeneration idempotence. The full net48 run retains one unrelated existing MAX_PATH failure in `FileWotRegistryStoreTests.MultiLevelDirectoryCreationSyncsEveryModifiedParentInOrder`. Solution-wide stress runs also include an intentional 60-minute soak and unrelated locale-sensitive/flaky tests; all affected WoT projects and tests pass. ## Specification Depends on OPCF-Members/spec-drafts#31. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6a9f41b1-72f0-4c74-bd7f-26fa531e5738
425 lines
17 KiB
C#
425 lines
17 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 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.IO;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.Text;
|
|
using Opc.Ua.Export;
|
|
using Opc.Ua.Wot;
|
|
|
|
namespace Opc.Ua.SourceGeneration
|
|
{
|
|
/// <summary>
|
|
/// The result of attempting to convert one WoT AdditionalFile input into
|
|
/// an in-memory NodeSet2 additional text. Conversion never throws: parse,
|
|
/// bounds, missing preservation/native mapping, dependency/resolver and
|
|
/// general conversion problems are all captured as <see cref="Diagnostic"/>s
|
|
/// instead, so a single malformed or unsupported WoT input can never fail
|
|
/// the whole generator run.
|
|
/// </summary>
|
|
internal sealed class WotConversionOutcome
|
|
{
|
|
/// <summary>
|
|
/// The path of the original WoT AdditionalFile.
|
|
/// </summary>
|
|
public string SourcePath { get; }
|
|
|
|
/// <summary>
|
|
/// The synthesized in-memory NodeSet2 additional text, or
|
|
/// <c>null</c> when conversion did not produce a usable result.
|
|
/// </summary>
|
|
public AdditionalText NodeSetText { get; }
|
|
|
|
/// <summary>
|
|
/// The AdditionalFiles options (Prefix, Name, ModelUri, Version,
|
|
/// Ignore) captured from the original WoT input, to be preserved
|
|
/// after wrapping it as a NodeSet2 file.
|
|
/// </summary>
|
|
public NodesetFileOptions Options { get; }
|
|
|
|
/// <summary>
|
|
/// The diagnostics produced while attempting the conversion, in the
|
|
/// order they occurred. Does not include virtual-path collision
|
|
/// diagnostics, which require the full set of inputs to detect.
|
|
/// </summary>
|
|
public ImmutableArray<Diagnostic> Diagnostics { get; }
|
|
|
|
public WotConversionOutcome(
|
|
string sourcePath,
|
|
AdditionalText nodeSetText,
|
|
NodesetFileOptions options,
|
|
ImmutableArray<Diagnostic> diagnostics)
|
|
{
|
|
SourcePath = sourcePath;
|
|
NodeSetText = nodeSetText;
|
|
Options = options;
|
|
Diagnostics = diagnostics;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Presents a WoT model input to the existing generator as an in-memory
|
|
/// NodeSet2 file, using the completed <see cref="Wot"/> converter
|
|
/// entirely in memory (no file or network I/O beyond the supplied
|
|
/// <see cref="AdditionalText"/> content).
|
|
/// </summary>
|
|
internal sealed class WotNodeSetAdditionalText : AdditionalText
|
|
{
|
|
private WotNodeSetAdditionalText(string path, SourceText text)
|
|
{
|
|
Path = path;
|
|
m_text = text;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override string Path { get; }
|
|
|
|
/// <inheritdoc/>
|
|
public override SourceText GetText(CancellationToken cancellationToken = default)
|
|
{
|
|
return m_text;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to convert a WoT AdditionalFile into an in-memory
|
|
/// NodeSet2 additional text. Never throws (other than on
|
|
/// cancellation): every failure mode is reported as a diagnostic on
|
|
/// the returned <see cref="WotConversionOutcome"/> instead, so a
|
|
/// malformed or unsupported document can never crash the generator.
|
|
/// </summary>
|
|
/// <param name="source">The original WoT AdditionalFile.</param>
|
|
/// <param name="options">The AdditionalFiles options to preserve.</param>
|
|
/// <param name="cancellationToken">A cancellation token.</param>
|
|
public static WotConversionOutcome Convert(
|
|
AdditionalText source,
|
|
NodesetFileOptions options,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
string sourcePath = source.Path;
|
|
ImmutableArray<Diagnostic>.Builder diagnostics = ImmutableArray.CreateBuilder<Diagnostic>();
|
|
|
|
SourceText sourceText;
|
|
try
|
|
{
|
|
sourceText = source.GetText(cancellationToken);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
diagnostics.Add(CreateParseDiagnostic(sourcePath, null, ex.Message));
|
|
return Failed(sourcePath, options, diagnostics);
|
|
}
|
|
if (sourceText is null)
|
|
{
|
|
diagnostics.Add(CreateParseDiagnostic(
|
|
sourcePath, null, "The WoT model source text could not be read."));
|
|
return Failed(sourcePath, options, diagnostics);
|
|
}
|
|
|
|
byte[] utf8Json;
|
|
try
|
|
{
|
|
utf8Json = GetUtf8Bytes(sourceText, cancellationToken);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
diagnostics.Add(CreateParseDiagnostic(sourcePath, sourceText, ex.Message));
|
|
return Failed(sourcePath, options, diagnostics);
|
|
}
|
|
|
|
WotDocument document;
|
|
try
|
|
{
|
|
document = WotDocument.Parse(utf8Json);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
diagnostics.Add(CreateParseDiagnostic(sourcePath, sourceText, ex));
|
|
return Failed(sourcePath, options, diagnostics);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
// Thrown when the document exceeds a configured size bound.
|
|
diagnostics.Add(CreateParseDiagnostic(sourcePath, sourceText, ex.Message));
|
|
return Failed(sourcePath, options, diagnostics);
|
|
}
|
|
|
|
UANodeSet nodeSet;
|
|
try
|
|
{
|
|
using (document)
|
|
{
|
|
// No external resolver: source generation performs no
|
|
// file or network I/O beyond the supplied AdditionalText
|
|
// content, so referenced TD/TM documents are left
|
|
// unresolved (reported as a WotDiagnosticCode.
|
|
// UnresolvedReference warning by the converter, and as a
|
|
// WotDiagnosticCode.EventSelectionUnresolved error where an
|
|
// event affordance states a field selection that only a
|
|
// resolved EventType definition can supply).
|
|
WotConversionResult<UANodeSet> result = WotNodeSetConverter.ToNodeSetResult(
|
|
document,
|
|
options: null);
|
|
AppendConversionDiagnostics(diagnostics, sourcePath, result.Diagnostics);
|
|
// Exclude any result that produced an error diagnostic even
|
|
// when a (partial or inconsistent) NodeSet value was still
|
|
// produced. This decision is independent of whether the
|
|
// MODELGEN031 error diagnostic is later reported or suppressed,
|
|
// so a suppressed conversion error can never emit a model.
|
|
nodeSet = result.Success ? result.Value : null;
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
// Defence in depth: the converter is documented to report
|
|
// diagnostics rather than throw. Guard against any residual
|
|
// exception so a single malformed input can never take down
|
|
// the whole generator run.
|
|
diagnostics.Add(CreateConversionExceptionDiagnostic(sourcePath, ex));
|
|
return Failed(sourcePath, options, diagnostics);
|
|
}
|
|
|
|
if (nodeSet is null)
|
|
{
|
|
// An error diagnostic explaining why was already appended.
|
|
return Failed(sourcePath, options, diagnostics);
|
|
}
|
|
|
|
string xml;
|
|
try
|
|
{
|
|
using var stream = new MemoryStream();
|
|
nodeSet.Write(stream);
|
|
xml = Encoding.UTF8.GetString(stream.ToArray());
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
diagnostics.Add(CreateConversionExceptionDiagnostic(sourcePath, ex));
|
|
return Failed(sourcePath, options, diagnostics);
|
|
}
|
|
if (xml.Length > 0 && xml[0] == '\uFEFF')
|
|
{
|
|
xml = xml[1..];
|
|
}
|
|
|
|
var nodeSetText = new WotNodeSetAdditionalText(
|
|
GetNodeSetPath(sourcePath),
|
|
SourceText.From(xml, Encoding.UTF8));
|
|
return new WotConversionOutcome(
|
|
sourcePath, nodeSetText, options, diagnostics.ToImmutable());
|
|
}
|
|
|
|
private static WotConversionOutcome Failed(
|
|
string sourcePath,
|
|
NodesetFileOptions options,
|
|
ImmutableArray<Diagnostic>.Builder diagnostics)
|
|
{
|
|
return new WotConversionOutcome(sourcePath, null, options, diagnostics.ToImmutable());
|
|
}
|
|
|
|
private static void AppendConversionDiagnostics(
|
|
ImmutableArray<Diagnostic>.Builder builder,
|
|
string sourcePath,
|
|
IReadOnlyList<WotDiagnostic> wotDiagnostics)
|
|
{
|
|
if (wotDiagnostics.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
Location location = CreateFileLocation(sourcePath);
|
|
for (int ii = 0; ii < wotDiagnostics.Count; ii++)
|
|
{
|
|
WotDiagnostic diagnostic = wotDiagnostics[ii];
|
|
DiagnosticDescriptor descriptor = diagnostic.Severity switch
|
|
{
|
|
WotDiagnosticSeverity.Error => SourceGenerator.WotConversionError,
|
|
WotDiagnosticSeverity.Warning => SourceGenerator.WotConversionWarning,
|
|
_ => SourceGenerator.WotConversionInfo
|
|
};
|
|
builder.Add(Diagnostic.Create(descriptor, location, sourcePath, diagnostic.ToString()));
|
|
}
|
|
}
|
|
|
|
private static Diagnostic CreateParseDiagnostic(
|
|
string sourcePath, SourceText sourceText, JsonException ex)
|
|
{
|
|
Location location = CreateLocation(sourcePath, sourceText, ex.LineNumber, ex.BytePositionInLine);
|
|
return Diagnostic.Create(SourceGenerator.WotParseError, location, sourcePath, ex.Message);
|
|
}
|
|
|
|
private static Diagnostic CreateParseDiagnostic(
|
|
string sourcePath, SourceText sourceText, string message)
|
|
{
|
|
Location location = CreateLocation(sourcePath, sourceText, null, null);
|
|
return Diagnostic.Create(SourceGenerator.WotParseError, location, sourcePath, message);
|
|
}
|
|
|
|
private static Diagnostic CreateConversionExceptionDiagnostic(string sourcePath, Exception ex)
|
|
{
|
|
Location location = CreateFileLocation(sourcePath);
|
|
return Diagnostic.Create(
|
|
SourceGenerator.WotConversionError,
|
|
location,
|
|
sourcePath,
|
|
$"{ex.GetType().Name}: {ex.Message}");
|
|
}
|
|
|
|
private static byte[] GetUtf8Bytes(SourceText sourceText, CancellationToken cancellationToken)
|
|
{
|
|
using var stream = new MemoryStream();
|
|
using (var writer = new StreamWriter(stream, new UTF8Encoding(false), 4096, leaveOpen: true))
|
|
{
|
|
sourceText.Write(writer, cancellationToken);
|
|
}
|
|
return stream.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a location anchored at the start of the given file. Used
|
|
/// when no more precise position is available.
|
|
/// </summary>
|
|
internal static Location CreateFileLocation(string path)
|
|
{
|
|
return Location.Create(path, default, default);
|
|
}
|
|
|
|
private static Location CreateLocation(
|
|
string path,
|
|
SourceText text,
|
|
long? lineNumber,
|
|
long? bytePositionInLine)
|
|
{
|
|
if (text is null || text.Lines.Count == 0)
|
|
{
|
|
return CreateFileLocation(path);
|
|
}
|
|
int line = 0;
|
|
int character = 0;
|
|
if (lineNumber >= 0 && lineNumber.Value < text.Lines.Count)
|
|
{
|
|
line = (int)lineNumber.Value;
|
|
if (bytePositionInLine >= 0)
|
|
{
|
|
character = GetCharacterIndexFromUtf8ByteOffset(text, line, bytePositionInLine.Value);
|
|
}
|
|
}
|
|
var position = new LinePosition(line, character);
|
|
int offset = text.Lines[line].Start + character;
|
|
var span = new TextSpan(offset, 0);
|
|
return Location.Create(path, span, new LinePositionSpan(position, position));
|
|
}
|
|
|
|
private static int GetCharacterIndexFromUtf8ByteOffset(SourceText text, int line, long byteOffset)
|
|
{
|
|
if (byteOffset <= 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
TextLine textLine = text.Lines[line];
|
|
int lineLength = textLine.Span.Length;
|
|
long bytesSeen = 0;
|
|
int character = 0;
|
|
while (character < lineLength)
|
|
{
|
|
char current = text[textLine.Start + character];
|
|
int characterCount = 1;
|
|
int byteCount;
|
|
if (char.IsHighSurrogate(current) &&
|
|
character + 1 < lineLength &&
|
|
char.IsLowSurrogate(text[textLine.Start + character + 1]))
|
|
{
|
|
characterCount = 2;
|
|
byteCount = 4;
|
|
}
|
|
else if (current <= '\u007F')
|
|
{
|
|
byteCount = 1;
|
|
}
|
|
else if (current <= '\u07FF')
|
|
{
|
|
byteCount = 2;
|
|
}
|
|
else
|
|
{
|
|
byteCount = 3;
|
|
}
|
|
|
|
long nextBytes = bytesSeen + byteCount;
|
|
if (byteOffset < nextBytes)
|
|
{
|
|
return character;
|
|
}
|
|
|
|
character += characterCount;
|
|
bytesSeen = nextBytes;
|
|
if (byteOffset == bytesSeen)
|
|
{
|
|
return character;
|
|
}
|
|
}
|
|
|
|
return lineLength;
|
|
}
|
|
|
|
private static string GetNodeSetPath(string sourcePath)
|
|
{
|
|
string directory = System.IO.Path.GetDirectoryName(sourcePath) ?? string.Empty;
|
|
string name = System.IO.Path.GetFileName(sourcePath);
|
|
// The canonical suffixes are checked before the bare ".jsonld"
|
|
// fallback used by opted-in plain JSON-LD inputs: none of the
|
|
// canonical suffixes is itself a suffix of another, but every one
|
|
// of them ends with ".jsonld" or ".json" as plain text, so the
|
|
// untyped fallback must come last.
|
|
foreach (string suffix in Extensions.WotFileExtensions)
|
|
{
|
|
if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
name = name[..^suffix.Length];
|
|
return System.IO.Path.Combine(directory, name + ".NodeSet2.xml");
|
|
}
|
|
}
|
|
const string plainJsonLd = ".jsonld";
|
|
if (name.EndsWith(plainJsonLd, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
name = name[..^plainJsonLd.Length];
|
|
}
|
|
return System.IO.Path.Combine(directory, name + ".NodeSet2.xml");
|
|
}
|
|
|
|
private readonly SourceText m_text;
|
|
}
|
|
}
|