/* ========================================================================
* 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.Threading;
using System.Xml;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
namespace Opc.Ua.SourceGeneration
{
internal static partial class Extensions
{
///
/// Get options from file options
///
///
///
public static NodesetFileOptions ToNodeSetOptions(this AnalyzerConfigOptions options)
{
if (options == null)
{
return new NodesetFileOptions();
}
return new NodesetFileOptions
{
Ignore = options.GetBool(nameof(NodesetFileOptions.Ignore), false),
Prefix = options.GetString(nameof(NodesetFileOptions.Prefix), false),
Version = options.GetString(nameof(NodesetFileOptions.Version), false),
Name = options.GetString(nameof(NodesetFileOptions.Name), false),
ModelUri = options.GetString(nameof(NodesetFileOptions.ModelUri), false),
IdentifierFile = options.GetString(nameof(NodesetFileOptions.IdentifierFile), false)
};
}
///
/// Create collection
///
public static NodesetFileCollection ToNodeSetFileCollection(
this ImmutableArray<(AdditionalText, NodesetFileOptions)> nodeset2Files,
ImmutableArray csvFiles,
IFileSystem fileSystem,
ITelemetryContext telemetry)
{
return new NodesetFileCollection(
[.. nodeset2Files.Select(f => (f.Item1.Path, f.Item2))],
[.. csvFiles.Select(f => f.Path)],
fileSystem,
telemetry);
}
///
/// Design and NodeSet2 files are xml files whose root element is a
/// ModelDesign or a UANodeSet. Any other xml
/// AdditionalFile (a configuration or linker descriptor meant for
/// another tool) is not a model input and must neither be generated
/// nor abort the generation of the real models.
///
///
/// A file that cannot be read or parsed is kept as a model input so
/// the model pipeline reports why instead of silently dropping a
/// broken design.
///
public static bool IsDesignOrNodeset2File(
this AdditionalText text,
CancellationToken cancellationToken = default)
{
if (!text.HasFileExtension("xml"))
{
return false;
}
SourceText sourceText = text.GetText(cancellationToken);
if (sourceText == null)
{
return true;
}
try
{
using var reader = XmlReader.Create(
new SourceTextReader(sourceText),
new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Ignore,
XmlResolver = null,
IgnoreComments = true,
IgnoreProcessingInstructions = true,
IgnoreWhitespace = true
});
if (reader.MoveToContent() != XmlNodeType.Element)
{
return true;
}
return reader.LocalName switch
{
"ModelDesign" => reader.NamespaceURI == ModelDesignNamespaceUri,
"UANodeSet" => reader.NamespaceURI == NodeSetNamespaceUri,
_ => false
};
}
catch (XmlException)
{
return true;
}
}
private const string ModelDesignNamespaceUri = "http://opcfoundation.org/UA/ModelDesign.xsd";
private const string NodeSetNamespaceUri = "http://opcfoundation.org/UA/2011/03/UANodeSet.xsd";
///
/// Reads a without copying it into a string.
///
private sealed class SourceTextReader : System.IO.TextReader
{
public SourceTextReader(SourceText text)
{
m_text = text;
}
public override int Peek()
{
return m_position < m_text.Length ? m_text[m_position] : -1;
}
public override int Read()
{
return m_position < m_text.Length ? m_text[m_position++] : -1;
}
public override int Read(char[] buffer, int index, int count)
{
int length = Math.Min(count, m_text.Length - m_position);
if (length <= 0)
{
return 0;
}
m_text.CopyTo(m_position, buffer, index, length);
m_position += length;
return length;
}
private readonly SourceText m_text;
private int m_position;
}
///
/// The canonical, unconditionally recognized WoT Thing Model / Thing
/// Description file extensions.
///
internal static readonly string[] WotFileExtensions =
[
".tm.json",
".td.json",
".tm.jsonld",
".td.jsonld"
];
///
/// Per-file AdditionalFiles metadata name (without the
/// generator prefix) that opts a plain .jsonld file into WoT
/// model processing. Set
/// <ModelSourceGeneratorWot>true</ModelSourceGeneratorWot>
/// on the AdditionalFiles item.
///
private const string WotOptInPropertyName = "Wot";
///
/// WoT Thing Model and Thing Description files supported as model
/// inputs. The canonical .tm.json, .td.json,
/// .tm.jsonld and .td.jsonld extensions are always
/// recognized. A plain .jsonld file is treated as arbitrary
/// JSON-LD — not consumed as a model input — unless the
/// AdditionalFiles item explicitly opts in with
/// ModelSourceGeneratorWot=true metadata.
///
public static bool IsWotFile(this AdditionalText text, AnalyzerConfigOptions options)
{
string path = text.Path;
foreach (string extension in WotFileExtensions)
{
if (path.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
if (!path.EndsWith(".jsonld", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// A bare .jsonld (i.e. none of the canonical suffixes above matched)
// is only a WoT input when explicitly opted in per-file: arbitrary
// JSON-LD documents must not be silently consumed as model input.
return options != null &&
options.GetBool(WotOptInPropertyName, buildProperty: false);
}
///
/// Identifier files contain at least one legacy identifier CSV row.
///
public static bool IsIdentifierFile(this AdditionalText text)
{
if (!text.HasFileExtension("csv"))
{
return false;
}
SourceText sourceText = text.GetText();
if (sourceText == null)
{
return false;
}
foreach (TextLine sourceLine in sourceText.Lines)
{
string line = sourceLine.ToString().TrimStart('\uFEFF').Trim();
if (string.IsNullOrEmpty(line) ||
line.StartsWith("#", StringComparison.Ordinal))
{
continue;
}
string[] columns = line.Split(',');
if (columns.Length < 2)
{
continue;
}
string identifier = columns[1].Trim();
if (uint.TryParse(identifier, out _) ||
(identifier.Length > 1 &&
identifier.StartsWith("\"", StringComparison.Ordinal) &&
identifier.EndsWith("\"", StringComparison.Ordinal)))
{
return true;
}
}
return false;
}
///
/// Has file extension check
///
public static bool HasFileExtension(this AdditionalText text, string extension)
{
return text.Path.EndsWith("." + extension, StringComparison.OrdinalIgnoreCase);
}
///
/// Get value
///
///
public static T GetValue(
this AnalyzerConfigOptions options,
string propertyName,
Func converter,
bool buildProperty = true)
{
string prefix = buildProperty ? "build_property" : "build_metadata.AdditionalFiles";
options.TryGetValue(
$"{prefix}.{SourceGenerator.Name}{propertyName}".ToLowerInvariant(),
out string value);
return converter(value);
}
///
/// Get integer value from build properties
///
public static int GetInteger(
this AnalyzerConfigOptions config,
string propertyName,
bool buildProperty = true)
{
return config.GetValue(propertyName,
s => s != null && int.TryParse(s, out int integer) ?
integer :
0,
buildProperty);
}
///
/// Get boolean option from options
///
public static bool GetBool(
this AnalyzerConfigOptions config,
string propertyName,
bool buildProperty = true)
{
return config.GetValue(propertyName,
s => string.Equals(s, "true", StringComparison.OrdinalIgnoreCase),
buildProperty);
}
///
/// Get string option from options
///
public static string GetString(
this AnalyzerConfigOptions config,
string propertyName,
bool buildProperty = true)
{
return config.GetValue(propertyName, s => s, buildProperty)
?? string.Empty;
}
///
/// Get a list option from options. The list compares by content so
/// the options records carrying it stay equatable across generator
/// runs and the incremental cache can hit.
///
public static EquatableArray GetStrings(
this AnalyzerConfigOptions config,
string propertyName,
bool buildProperty = true)
{
return config.GetValue(propertyName, Split, buildProperty);
static EquatableArray Split(string s)
{
return s == null ? EquatableArray.Empty : EquatableArray.From(s
.Split(';', ',', '+')
.Select(e => e.Trim())
.Where(s => !string.IsNullOrEmpty(s)));
}
}
///
/// Get namespace information
///
public static string GetFullNamespace(this INamedTypeSymbol symbol)
{
var parts = new List();
INamespaceSymbol ns = symbol.ContainingNamespace;
while (ns != null && !ns.IsGlobalNamespace)
{
parts.Insert(0, ns.Name);
ns = ns.ContainingNamespace;
}
return string.Join(".", parts);
}
///
/// Get fully qualified type name string with global::
/// prefix and no global namespace
///
public static string GetFullyQualifiedTypeName(this ITypeSymbol type)
{
return "global::" +
type.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat
.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted));
}
///
/// Check whether the type implements an interface with the
/// given name, regardless of namespace. This is used to check for
/// specific interfaces without needing the full namespace.
///
public static bool ImplementsInterface(
this ITypeSymbol type,
string interfaceName)
{
return type.AllInterfaces.Any(i => i.Name == interfaceName);
}
///
/// Check if a type symbol has a specific attribute by name.
///
public static bool HasAttribute(
this ITypeSymbol type,
string attributeName)
{
return type.GetAttributes().Any(a =>
a.AttributeClass?.Name == attributeName);
}
///
/// Get a named argument from the attribute
///
public static string GetValue(
this AttributeData attr,
string name)
{
if (attr == null)
{
return null;
}
foreach (KeyValuePair kvp in attr.NamedArguments)
{
if (kvp.Key == name && kvp.Value.Value is string s)
{
return s;
}
}
return null;
}
///
/// Get a named string-array argument from the attribute. Returns
/// null when the argument is absent or empty; entries
/// that are not non-empty strings are skipped.
///
public static string[] GetStringArray(
this AttributeData attr,
string name)
{
if (attr == null)
{
return null;
}
foreach (KeyValuePair kvp in attr.NamedArguments)
{
if (kvp.Key != name ||
kvp.Value.Kind != TypedConstantKind.Array ||
kvp.Value.IsNull)
{
continue;
}
string[] values = [.. kvp.Value.Values
.Select(v => v.Value as string)
.Where(s => !string.IsNullOrEmpty(s))];
return values.Length > 0 ? values : null;
}
return null;
}
///
/// Get a named argument from the attribute
///
public static int GetInteger(
this AttributeData attr,
string name,
int defaultValue = default)
{
if (attr == null)
{
return defaultValue;
}
foreach (KeyValuePair kvp in attr.NamedArguments)
{
if (kvp.Key == name && kvp.Value.Value is int i)
{
return i;
}
}
return defaultValue;
}
}
}