diff --git a/Directory.Packages.props b/Directory.Packages.props index 9c3a633f9..1351bbb22 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,38 +4,52 @@ Global references are included in ALL projects in this repository --> - + + all + runtime; build; native; contentfiles; analyzers + + --> - + + + + + + + + - - + + + + + + - + @@ -46,9 +60,9 @@ - - - + + + diff --git a/docfx/ReadMe.md b/docfx/ReadMe.md index 2a9a14e43..4a5dfcdfa 100644 --- a/docfx/ReadMe.md +++ b/docfx/ReadMe.md @@ -78,11 +78,12 @@ Since this is generated it is listed in the [.gitignore](#gitignore) file. These folders (named after the `*` portion of the [api-*](#api-*) folder names contains manually written additional files, articles, samples etc... related to a given library. -## Guid to wrting XML DOC comments -When dealing with doc comments the XML can get in the way of general readability of the -source code. There is an inherent tension beween how a particular editor renders the docs -for a symbol/method (VS calls this "Quick Info") and how it is rendered in the final -documentation by docfx. This guides general use to simplify things as much as possible. +## Guide to wrting XML DOC comments +When dealing with doc comments the XML can sometimes get in the way of general readability +of the source code. There is an inherent tension beween how a particular editor renders the +docs for a symbol/method (VS calls this "Quick Info") and how it is rendered in the final +documentation by a tool like docfx. This guides general use to simplify things as much as +possible. ### Lists The largest intrusion of the XML into the source is that of lists. The XML doc comments @@ -115,7 +116,6 @@ versus: /// 3) Act on the results as proper for the application
/// a. This might include actions parsed but generally isolating the various stages is an easier to understand/maintain model
/// b. Usually this is just app specific code that uses the bound results to adapt behavior
-/// ``` Which one would ***YOU*** rather encounter in code? Which one is easier to understand when @@ -126,7 +126,7 @@ should reconsider... :grinning:) There is little that can be done to alter the rendering of any editor support, at most an editor might allow specification of a CSS file, but that is the lowest priority of doc comments. Readability by maintainers of the docs AND the rendering for final docs used by -consumers of of VASTLY higher importance. Still, the editor rendering ***is*** of value to +consumers is of VASTLY higher importance. Still, the editor rendering ***is*** of value to maintainers so should not be forgotten as it can make a "right mess of things" even if they render properly in final docs. @@ -135,8 +135,8 @@ render properly in final docs. a) Doing so will break the docfx rendering that allows for markdown lists 2) Use `
' tags to indicate a line break. This is used by the editor rendering to mark the end of a line and start a new one. (Stops auto reflow) -3) Accept that the in edotr rendering might "trim" the lines it shows, eliminating any - indentation. +3) Accept that the in editor rendering might "trim" the lines it shows, eliminating any + indentation. [Grrr... Looking at you VS!] a) Sadly, there is no avoiding this. Addition of any sort of "markup" to control that will interfere with the readability AND the final docs rendering. 4) Always use a different numbering style for sub lists/items @@ -147,6 +147,6 @@ render properly in final docs. API signaure and parameter info. Different editors may allow control of that. i) In VS [2019|2022] for C# it is controlled by `Text Editor > C# > Advanced > Editor Help: "Show remarks in Quick Info."` - ii) Turning this off can greatly reduce the noise AND reduce the problems of - different rende + 1) Turning this off can greatly reduce the noise AND reduce the problems of + different rendering as lists are generally not used in the other elements. diff --git a/src/Interop/Ubiquity.NET.Llvm.Interop/Library.cs b/src/Interop/Ubiquity.NET.Llvm.Interop/Library.cs index b8909b3da..2a54479b6 100644 --- a/src/Interop/Ubiquity.NET.Llvm.Interop/Library.cs +++ b/src/Interop/Ubiquity.NET.Llvm.Interop/Library.cs @@ -49,6 +49,12 @@ public static ILibLlvm InitializeLLVM( ) } // Verify the version of LibLLVM. + string verString = LibLLVMGetVersion()?.ToString() ?? string.Empty; + if(string.IsNullOrWhiteSpace(verString)) + { + throw new InvalidOperationException("Internal error: LLVM reported an empty string for version!"); + } + var libVersion = SemVer.Parse(LibLLVMGetVersion()?.ToString() ?? string.Empty, SemVerFormatProvider.CaseInsensitive); if( libVersion is CSemVerCI semVerCI) { diff --git a/src/Samples/CodeGenWithDebugInfo/Program.cs b/src/Samples/CodeGenWithDebugInfo/Program.cs index 31a8a9ca4..b9caa0289 100644 --- a/src/Samples/CodeGenWithDebugInfo/Program.cs +++ b/src/Samples/CodeGenWithDebugInfo/Program.cs @@ -113,13 +113,35 @@ public static void Main( string[] args ) , constArray ); - var bar = module.AddGlobal( fooType, false, 0, barValue, "bar" ); + var bar = module.AddGlobal( fooType, false, 0, barValue, "bar"u8 ); bar.Alignment = module.Layout.AbiAlignmentOf( fooType ); - bar.AddDebugInfo( diBuilder.CreateGlobalVariableExpression( compilationUnit, "bar", string.Empty, diFile, 8, fooType.DebugInfoType, false, null ) ); - - var baz = module.AddGlobal( fooType, false, Linkage.Common, Constant.NullValueFor( fooType ), "baz" ); + bar.AddDebugInfo( + diBuilder.CreateGlobalVariableExpression( + compilationUnit, + "bar"u8, + linkageName: string.Empty, + diFile, + lineNo: 8, + fooType.DebugInfoType, + isLocalToUnit: false, + value: null + ) + ); + + var baz = module.AddGlobal( fooType, false, Linkage.Common, Constant.NullValueFor( fooType ), "baz"u8 ); baz.Alignment = module.Layout.AbiAlignmentOf( fooType ); - baz.AddDebugInfo( diBuilder.CreateGlobalVariableExpression( compilationUnit, "baz", string.Empty, diFile, 9, fooType.DebugInfoType, false, null ) ); + baz.AddDebugInfo( + diBuilder.CreateGlobalVariableExpression( + compilationUnit, + "baz"u8, + linkageName: string.Empty, + diFile, + lineNo: 9, + fooType.DebugInfoType, + isLocalToUnit: false, + value: null + ) + ); // add module flags and compiler identifiers... // this can technically occur at any point, though placing it here makes diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/AnalyzerConfigOptionsProviderExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/AnalyzerConfigOptionsProviderExtensions.cs new file mode 100644 index 000000000..0fc338d3d --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/AnalyzerConfigOptionsProviderExtensions.cs @@ -0,0 +1,58 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +// Mostly from https://github.com/Sergio0694/PolySharp/blob/main/src/PolySharp.SourceGenerators/Extensions/AnalyzerConfigOptionsProviderExtensions.cs +// Reformatted and made to conform to repo guides + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Extension methods for the type. + public static class AnalyzerConfigOptionsProviderExtensions + { + /// Checks whether the input property has a valid value. + /// The input instance. + /// The Build property name. + /// The resulting property value, if invalid. + /// Whether the target property is a valid value. + public static bool IsValidBoolBuildProperty( + this AnalyzerConfigOptionsProvider options, + string propertyName, + [NotNullWhen( false )] out string? propertyValue + ) + { + return !options.GlobalOptions.TryGetValue( $"{BuildProperty}.{propertyName}", out propertyValue ) + || string.IsNullOrEmpty( propertyValue ) + || string.Equals( propertyValue, bool.TrueString, StringComparison.OrdinalIgnoreCase ) + || string.Equals( propertyValue, bool.FalseString, StringComparison.OrdinalIgnoreCase ); + } + + /// Gets the value of a build property. + /// The input instance. + /// The build property name. + /// The value of the specified build property. + /// + /// The return value is equivalent to a (case insensitive) '$(PropertyName)' == 'true' check. + /// That is, any other value, including empty/not present, is considered . + /// + public static bool GetBoolBuildProperty( this AnalyzerConfigOptionsProvider options, string propertyName ) + { + return options.GlobalOptions.TryGetValue( $"{BuildProperty}.{propertyName}", out string? propertyValue ) + && string.Equals( propertyValue, bool.TrueString, StringComparison.OrdinalIgnoreCase ); + } + + /// Gets the value of a Build property representing a semicolon-separated list of strings. + /// The input instance. + /// The build property name. + /// The value of the specified build property. + public static ImmutableArray GetStringArrayBuildProperty( this AnalyzerConfigOptionsProvider options, string propertyName ) + { + return options.GlobalOptions.TryGetValue( $"{BuildProperty}.{propertyName}", out string? propertyValue ) + ? [ .. propertyValue.Split( ',', ';' ) ] + : []; + } + + // MSBuild properties that are visible to the compiler are available with the "build_property." prefix + // See: https://andrewlock.net/creating-a-source-generator-part-13-providing-and-accessing-msbuild-settings-in-source-generators/ + private const string BuildProperty = "build_property"; + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/BaseTypeDeclarationSyntaxExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/BaseTypeDeclarationSyntaxExtensions.cs new file mode 100644 index 000000000..6ca2942b5 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/BaseTypeDeclarationSyntaxExtensions.cs @@ -0,0 +1,103 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Utility type to provide extensions for + public static class BaseTypeDeclarationSyntaxExtensions + { + // The following 2 extension methods are based on: + // https://andrewlock.net/creating-a-source-generator-part-5-finding-a-type-declarations-namespace-and-type-hierarchy/ + + /// Gets the declared namespace for a + /// Syntax to get the namespace for + /// Namespace of + public static string GetDeclaredNamespace(this BaseTypeDeclarationSyntax syntax) + { + // If we don't have a namespace at all we'll return an empty string + // This accounts for the "default namespace" case + string nameSpace = string.Empty; + + // Get the containing syntax node for the type declaration + // (could be a nested type, for example) + SyntaxNode? potentialNamespaceParent = syntax.Parent; + + // Keep moving "out" of nested classes etc until we get to a namespace + // or until we run out of parents + while (potentialNamespaceParent != null + && potentialNamespaceParent is not NamespaceDeclarationSyntax + && potentialNamespaceParent is not FileScopedNamespaceDeclarationSyntax) + { + potentialNamespaceParent = potentialNamespaceParent.Parent; + } + + // Build up the final namespace by looping until we no longer have a namespace declaration + if (potentialNamespaceParent is BaseNamespaceDeclarationSyntax namespaceParent) + { + // We have a namespace. Use that as the type + nameSpace = namespaceParent.Name.ToString(); + + // Keep moving "out" of the namespace declarations until there + // are no more nested namespace declarations. + while (true) + { + if (namespaceParent.Parent is not NamespaceDeclarationSyntax parent) + { + break; + } + + // Add the outer namespace as a prefix to the final namespace + nameSpace = $"{namespaceParent.Name}.{nameSpace}"; + namespaceParent = parent; + } + } + + // return the final namespace + return nameSpace; + } + + /// Gets the nested class name for a + /// Syntax to get the name for + /// Flag to indicate if the type itself is included in the name [Default: + /// of the syntax or + public static NestedClassName? GetNestedClassName( this BaseTypeDeclarationSyntax syntax, bool includeSelf = false) + { + // Try and get the parent syntax. If it isn't a type like class/struct, this will be null + TypeDeclarationSyntax? parentSyntax = includeSelf ? syntax as TypeDeclarationSyntax : syntax.Parent as TypeDeclarationSyntax; + NestedClassName? parentClassInfo = null; + + // We can only be nested in class/struct/record + + // Keep looping while we're in a supported nested type + while (parentSyntax is not null) + { + // NOTE: due to bug https://github.com/dotnet/roslyn/issues/78042 this + // is not using a local static function to evaluate this in the condition + // of the while loop [Workaround: go back to "old" extension syntax...] + var rawKind = parentSyntax.Kind(); + bool isAllowedKind + = rawKind == SyntaxKind.ClassDeclaration + || rawKind == SyntaxKind.StructDeclaration + || rawKind == SyntaxKind.RecordDeclaration; + + if (!isAllowedKind) + { + break; + } + + // Record the parent type keyword (class/struct etc), name, and constraints + parentClassInfo = new NestedClassName( + keyword: parentSyntax.Keyword.ValueText, + name: parentSyntax.Identifier.ToString() + parentSyntax.TypeParameterList, + constraints: parentSyntax.ConstraintClauses.ToString(), + children: parentClassInfo is null ? [] : [parentClassInfo]); // set the child link (null initially) + + // Move to the next outer type + parentSyntax = parentSyntax.Parent as TypeDeclarationSyntax; + } + + // return a link to the outermost parent type + return parentClassInfo; + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/CompilationExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/CompilationExtensions.cs new file mode 100644 index 000000000..eb631224e --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/CompilationExtensions.cs @@ -0,0 +1,177 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +// Mostly from: https://github.com/Sergio0694/PolySharp/blob/main/src/PolySharp.SourceGenerators/Extensions/CompilationExtensions.cs +// Reformated and adapted to support repo guidelines + +using Microsoft.CodeAnalysis.VisualBasic; + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Structure for a runtime version + /// Name of the runtime + /// Version of the runtime + [SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Simple record" )] + public readonly record struct RuntimeVersion( string RuntimeName, Version Version ); + + /// Extension methods for the type. + public static class CompilationExtensions + { + /// Checks whether a given compilation (assumed to be for C#) is using at least a given language version. + /// The to consider for analysis. + /// The minimum language version to check. + /// Whether is using at least the specified language version. + public static bool HasLanguageVersionAtLeastEqualTo( this Compilation compilation, Microsoft.CodeAnalysis.CSharp.LanguageVersion languageVersion ) + { + return compilation is not CSharpCompilation csharpCompilation + ? throw new ArgumentNullException( nameof( compilation ) ) + : csharpCompilation.LanguageVersion >= languageVersion; + } + + /// Checks whether a given VB compilation is using at least a given language version. + /// The to consider for analysis. + /// The minimum language version to check. + /// Whether is using at least the specified language version. + [SuppressMessage( "StyleCop.CSharp.NamingRules", "SA1305:Field names should not use Hungarian notation", Justification = "not Hungarian" )] + public static bool HasLanguageVersionAtLeastEqualTo( this Compilation compilation, Microsoft.CodeAnalysis.VisualBasic.LanguageVersion languageVersion ) + { + return compilation is not VisualBasicCompilation vbCompilation + ? throw new ArgumentNullException( nameof( compilation ) ) + : vbCompilation.LanguageVersion >= languageVersion; + } + + /// Gets the runtime version by extracting the version from the assembly implementing + /// Compilation to get the version information from + /// Version of the runtime the compilation is targetting + public static RuntimeVersion GetRuntimeVersion(this Compilation self) + { + var objectType = self.GetSpecialType(SpecialType.System_Object); + var runtimeAssembly = objectType.ContainingAssembly; + return new(runtimeAssembly.Identity.Name, runtimeAssembly.Identity.Version); + } + + /// Gets a value indicating wheter the compilation has a minium version of the runtime + /// Compilation to test + /// Minimum version accepted + /// if the runtime version targetted by the compilation is at least ; otherwise + public static bool HasRuntimeVersionAtLeast(this Compilation self, RuntimeVersion minVersion) + { + var runtimeVersion = GetRuntimeVersion(self); + return runtimeVersion.RuntimeName == minVersion.RuntimeName && runtimeVersion.Version >= minVersion.Version; + } + + /// Checks whether or not a type with a specified metadata name is accessible from a given instance. + /// The to consider for analysis. + /// The fully-qualified metadata type name to find. + /// Whether a type with the specified metadata name can be accessed from the given compilation. + /// + /// This method enumerates candidate type symbols to find a match in the following order: + /// 1) If only one type with the given name is found within the compilation and its referenced assemblies, check its accessibility.
+ /// 2) If the current defines the symbol, check its accessibility.
+ /// 3) Otherwise, check whether the type exists and is accessible from any of the referenced assemblies.
+ ///
+ public static bool HasAccessibleTypeWithMetadataName( this Compilation compilation, string fullyQualifiedMetadataName ) + { + if(compilation is null) + { + throw new ArgumentNullException( nameof( compilation ) ); + } + + if(string.IsNullOrWhiteSpace( fullyQualifiedMetadataName )) + { + throw new ArgumentException( $"'{nameof( fullyQualifiedMetadataName )}' cannot be null or whitespace.", nameof( fullyQualifiedMetadataName ) ); + } + + // If there is only a single matching symbol, check its accessibility + if(compilation.GetTypeByMetadataName( fullyQualifiedMetadataName ) is INamedTypeSymbol typeSymbol) + { + return compilation.IsSymbolAccessibleWithin( typeSymbol, compilation.Assembly ); + } + + // Otherwise, check all available types + foreach(INamedTypeSymbol currentTypeSymbol in compilation.GetTypesByMetadataName( fullyQualifiedMetadataName )) + { + if(compilation.IsSymbolAccessibleWithin( currentTypeSymbol, compilation.Assembly )) + { + return true; + } + } + + return false; + } + + /// Checks whether or not a type with a specified metadata name is accessible from a given instance. + /// The to consider for analysis. + /// The fully-qualified metadata type name to find. + /// Name of the member + /// Whether a type with the specified metadata name can be accessed from the given compilation. + /// + /// This method enumerates candidate type symbols to find a match in the following order: + /// 1) If only one type with the given name is found within the compilation and its referenced assemblies, check its accessibility.
+ /// 2) If the current defines the symbol, check its accessibility.
+ /// 3) Otherwise, check whether the type exists and is accessible from any of the referenced assemblies.
+ ///
+ public static bool HasAccessibleMember( this Compilation compilation, string fullyQualifiedMetadataName, string memberName ) + { + // If there is only a single matching symbol, check its accessibility + if(compilation.GetTypeByMetadataName( fullyQualifiedMetadataName ) is INamedTypeSymbol typeSymbol) + { + return compilation.IsSymbolAccessibleWithin( typeSymbol, compilation.Assembly ) + && compilation.HasAccessibleMemberWithin( typeSymbol, memberName, compilation.Assembly); + } + + // Otherwise, check all available types + foreach(INamedTypeSymbol currentTypeSymbol in compilation.GetTypesByMetadataName( fullyQualifiedMetadataName )) + { + if(compilation.IsSymbolAccessibleWithin( currentTypeSymbol, compilation.Assembly ) + && compilation.HasAccessibleMemberWithin( currentTypeSymbol, memberName, compilation.Assembly) + ) + { + return true; + } + } + + return false; + } + + /// Tests if a has a type with an accessible member of a given name + /// to test + /// Type symbol for the type to test + /// Name of the member to test for + /// Symbol to test if the member is accessible within + /// Symbol to use for "protected access" [default: null] + /// if the member is accesible and + public static bool HasAccessibleMemberWithin( + this Compilation self, + ITypeSymbol typeSymbol, + string memberName, + ISymbol within, + ITypeSymbol? throughType = null + ) + { + if(self is null) + { + throw new ArgumentNullException( nameof( self ) ); + } + + if(typeSymbol is null) + { + throw new ArgumentNullException( nameof( typeSymbol ) ); + } + + if(string.IsNullOrEmpty( memberName )) + { + throw new ArgumentException( $"'{nameof( memberName )}' cannot be null or empty.", nameof( memberName ) ); + } + + if(within is null) + { + throw new ArgumentNullException( nameof( within ) ); + } + + var memberSymbol = typeSymbol.GetMembers().Where(s=>s.Name == memberName).FirstOrDefault(); + return memberSymbol is not null + && self.IsSymbolAccessibleWithin(memberSymbol, within, throughType); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/DiagnosticInfo.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/DiagnosticInfo.cs new file mode 100644 index 000000000..fe67ebd7a --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/DiagnosticInfo.cs @@ -0,0 +1,46 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +// Modified from idea in blog post: https://andrewlock.net/creating-a-source-generator-part-9-avoiding-performance-pitfalls-in-incremental-generators/ + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Contains diagnostic information collected for reporting to the host + /// + /// This is an equatable type and therefore is legit for use in generators/analyzers where + /// that is needed for caching. A is not, so this record bundles + /// the parameters needed for creation of one and defers the construction until needed. + /// + public sealed record DiagnosticInfo + { + /// Initializes a new instance of the class. + /// Descriptor for the diagnostic + /// Location in the source file that triggered this diagnostic + /// Args for the message + public DiagnosticInfo(DiagnosticDescriptor descriptor, Location? location, params IEnumerable msgArgs) + { + Descriptor = descriptor; + Location = location; + Params = msgArgs.ToImmutableArray(); + } + + /// Gets the parameters for this diagnostic + public EquatableArray Params { get; } + + /// Gets the descriptor for this diagnostic + public DiagnosticDescriptor Descriptor { get; } + + // Location is an abstract type but all derived types implement IEquatable where T is Location + // Thus a location is equatable even though the base abstract type doesn't implement that interface. + + /// Gets the location of the source of this diagnostic + public Location? Location { get; } + + /// Factory to create a from the information contained in this holder + /// that represents this information + public Diagnostic CreateDiagnostic() + { + return Diagnostic.Create(Descriptor, Location, Params.ToArray()); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableArray.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableArray.cs new file mode 100644 index 000000000..29a5c8e3f --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableArray.cs @@ -0,0 +1,209 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +#pragma warning disable SA1642 // Constructor summary documentation should begin with standard text +#pragma warning disable SA1615 // Element return value should be documented +#pragma warning disable SA1604 // Element documentation should have summary +#pragma warning disable SA1611 // Element parameters should be documented +#pragma warning disable CA1000 // Do not declare static members on generic types +#pragma warning disable CA2225 // Operator overloads have named alternates + +// ORIGINALLY FROM: https://github.com/CommunityToolkit/dotnet/blob/7b53ae23dfc6a7fb12d0fc058b89b6e948f48448/src/CommunityToolkit.Mvvm.SourceGenerators/Helpers/EquatableArray%7BT%7D.cs + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Extensions for . + public static class EquatableArray + { + /// Creates an instance from a given . + /// The type of items in the input array. + /// The input instance. + /// An instance from a given . + public static EquatableArray AsEquatableArray(this ImmutableArray array) + where T : IEquatable + { + return array.IsDefault ? throw new ArgumentNullException(nameof(array)) + : new(array); + } + } + + /// + /// An immutable, equatable array. This is equivalent to but with value equality of members support. + /// + /// The type of values in the array. + public readonly struct EquatableArray + : IEquatable> + , IEnumerable + where T : IEquatable + { + /// + /// The underlying array. + /// + private readonly T[]? array; + + /// + /// Creates a new instance. + /// + /// The input to wrap. + public EquatableArray(ImmutableArray array) + { + this.array = Unsafe.As, T[]?>(ref array); + } + + /// + /// Gets a reference to an item at a specified position within the array. + /// + /// The index of the item to retrieve a reference to. + /// A reference to an item at a specified position within the array. + public ref readonly T this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => ref AsImmutableArray().ItemRef(index); + } + + /// + /// Gets a value indicating whether the current array is empty. + /// + public bool IsEmpty + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => AsImmutableArray().IsEmpty; + } + + /// Gets the length of the array + public int Length => array?.Length ?? 0; + + /// + public bool Equals(EquatableArray array) + { + return AsSpan().SequenceEqual(array.AsSpan()); + } + + /// + public override bool Equals([NotNullWhen(true)] object? obj) + { + return obj is EquatableArray array && Equals(this, array); + } + + /// + public override int GetHashCode() + { + if (this.array is not T[] array) + { + return 0; + } + + HashCode hashCode = default; + + foreach (T item in array) + { + hashCode.Add(item); + } + + return hashCode.ToHashCode(); + } + + /// + /// Gets an instance from the current . + /// + /// The from the current . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ImmutableArray AsImmutableArray() + { + return Unsafe.As>(ref Unsafe.AsRef(in array)); + } + + /// + /// Creates an instance from a given . + /// + /// The input instance. + /// An instance from a given . + public static EquatableArray FromImmutableArray(ImmutableArray array) + { + return new(array); + } + + /// + /// Returns a wrapping the current items. + /// + /// A wrapping the current items. + public ReadOnlySpan AsSpan() + { + return AsImmutableArray().AsSpan(); + } + + /// + /// Copies the contents of this instance to a mutable array. + /// + /// The newly instantiated array. + public T[] ToArray() + { + return [.. AsImmutableArray()]; + } + + /// + /// Gets an value to traverse items in the current array. + /// + /// An value to traverse items in the current array. + public ImmutableArray.Enumerator GetEnumerator() + { + return AsImmutableArray().GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)AsImmutableArray()).GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)AsImmutableArray()).GetEnumerator(); + } + + /// + /// Implicitly converts an to . + /// + /// An instance from a given . + public static implicit operator EquatableArray(ImmutableArray array) + { + return FromImmutableArray(array); + } + + /// + /// Implicitly converts an to . + /// + /// An instance from a given . + public static implicit operator ImmutableArray(EquatableArray array) + { + return array.AsImmutableArray(); + } + + /// + /// Checks whether two values are the same. + /// + /// The first value. + /// The second value. + /// Whether and are equal. + public static bool operator ==(EquatableArray left, EquatableArray right) + { + return left.Equals(right); + } + + /// + /// Checks whether two values are not the same. + /// + /// The first value. + /// The second value. + /// Whether and are not equal. + public static bool operator !=(EquatableArray left, EquatableArray right) + { + return !left.Equals(right); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/GlobalNamespaceImports.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/GlobalNamespaceImports.cs new file mode 100644 index 000000000..ffd720fe7 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/GlobalNamespaceImports.cs @@ -0,0 +1,36 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +/* +NOTE: +While the MsBuild `ImplicitUsings` property is banned from this repo, the C# language feature of global usings is NOT. +The build property will auto include an invisible and undiscoverable (without looking up obscure documentation) +set of namespaces that is NOT consistent or controlled by the developer. THAT is what is BAD/BROKEN about that feature. +By banning it's use and then providing a `GlobalNamespaceImports.cs` source file with ONLY global using statements ALL of +that is eliminated. Such use of the language feature restores FULL control and visibility of the namespaces to the developer, +where it belongs. For a good explanation of this problem see: https://rehansaeed.com/the-problem-with-csharp-10-implicit-usings/. +For an explanation of the benefits of the language feature see: https://www.hanselman.com/blog/implicit-usings-in-net-6 +*/ + +// BUG: False positive from IDE0005 - Using directive is unnecessary +// Attempts to remove/sort are at least able to figure it out and do the right thing. +// Bug seems to be related to multi-targetting. +#pragma warning disable IDE0005 + +global using System; +global using System.CodeDom.Compiler; +global using System.Collections; +global using System.Collections.Generic; +global using System.Collections.Immutable; +global using System.Diagnostics.CodeAnalysis; +global using System.IO; +global using System.Linq; +global using System.Reflection; +global using System.Runtime.CompilerServices; +global using System.Text; + +global using Microsoft.CodeAnalysis; +global using Microsoft.CodeAnalysis.CSharp; +global using Microsoft.CodeAnalysis.CSharp.Syntax; +global using Microsoft.CodeAnalysis.Diagnostics; +global using Microsoft.CodeAnalysis.Text; diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/GlobalSuppressions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/GlobalSuppressions.cs new file mode 100644 index 000000000..2b0ab7675 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/GlobalSuppressions.cs @@ -0,0 +1,7 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/MemberDeclarationSyntaxExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/MemberDeclarationSyntaxExtensions.cs new file mode 100644 index 000000000..2177bf75c --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/MemberDeclarationSyntaxExtensions.cs @@ -0,0 +1,82 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Utility class to provide extensions for + public static class MemberDeclarationSyntaxExtensions + { + /// Determines if a contains a specified attribute or not + /// to test + /// name of the attribute + /// if the attribute is found or if not + /// is + public static bool HasAttribute(this MemberDeclarationSyntax self, string attributeName) + { + return self is not null + ? self.TryGetAttribute(attributeName, out _) + : throw new ArgumentNullException(nameof(self)); + } + + /// Tries to get an from a + /// The to get the attribute from + /// name of the attribute + /// resulting or if not found + /// if the attribute is found or if not + public static bool TryGetAttribute( + this MemberDeclarationSyntax self, + string attributeName, + [NotNullWhen(true)] out AttributeSyntax? value + ) + { + value = null; + string shortName = attributeName; + if (attributeName.EndsWith("Attribute")) + { + shortName = shortName[..^9]; + } + else + { + attributeName += "Attribute"; + } + + var q = from attributeList in self.AttributeLists + from attribute in attributeList.Attributes + let name = attribute.GetIdentifierName() + where name == attributeName || name == shortName + select attribute; + + value = q.FirstOrDefault(); + return value != null; + } + + /// Determines if a declartion has an extern modifier + /// to test + /// if has the modifier or not + /// is null + public static bool IsExtern(this MemberDeclarationSyntax self) + { + return self is not null + ? self.Modifiers.HasExtern() + : throw new ArgumentNullException(nameof(self)); + } + + /// Determines if a declartion has a partial modifier + /// + public static bool IsPartial(this MemberDeclarationSyntax self) + { + return self is not null + ? self.Modifiers.HasPartialKeyword() + : throw new ArgumentNullException(nameof(self)); + } + + /// Determines if a declartion has a static modifier + /// + public static bool IsStatic(this MemberDeclarationSyntax self) + { + return self is not null + ? self.Modifiers.HasStatic() + : throw new ArgumentNullException(nameof(self)); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/MethodDeclarationSyntaxExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/MethodDeclarationSyntaxExtensions.cs new file mode 100644 index 000000000..2f4198212 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/MethodDeclarationSyntaxExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Utility class to provide extensions for + public static class MethodDeclarationSyntaxExtensions + { + // LibraryImportAttribute - intentionally NOT reported as a P/Invoke + // It uses different qualifiers and, technically, is NOT a P/Invoke + // signature (It's a generated marshaling function with a nested private + // P/Invoke using NO marshaling) + + /// Determines if a method declaration is a P/Invoke + /// The to test + /// if is a P/Invoke declaration or if not + /// + /// LibraryImportAttribute is intentionally NOT reported as a P/Invoke. It uses different qualifiers and, + /// technically, is NOT a P/Invoke signature (It's a marker for a Roslyn source generator. The generated function + /// contains the marshaling with a nested private P/Invoke using NO marshaling) + /// + public static bool IsPInvoke(this MethodDeclarationSyntax self) + { + return self.IsStatic() + && self.IsExtern() + && self.HasAttribute("System.Runtime.InteropServices.DllImportAttribute"); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/NestedClassName.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/NestedClassName.cs new file mode 100644 index 000000000..7aea1b6ef --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/NestedClassName.cs @@ -0,0 +1,86 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +// Originally FROM: https://andrewlock.net/creating-a-source-generator-part-5-finding-a-type-declarations-namespace-and-type-hierarchy/ +// Modified to support IEquatable for caching +// Additional functionality as needed to generalize it. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Cacheable storage of nested class names for use in source generation + public sealed class NestedClassName + : IEquatable + { + /// Initializes a new instance of the class. + /// Keyword for this declaration + /// Name of the type + /// Constraints for this type + /// Names of any nested child types to form hiearachies + /// + /// is normally one of ("class", "struct", "interface", "record [class|struct]?"). + /// + public NestedClassName(string keyword, string name, string constraints, params IEnumerable children) + { + Keyword = keyword; + Name = name; + Constraints = constraints; + Children = children.ToImmutableArray().AsEquatableArray(); + } + + /// Gets child nested types + public EquatableArray Children { get; } + + /// Gets the keyword for this type + /// + /// This is normally one of ("class", "struct", "interface", "record [class|struct]?" + /// + public string Keyword { get; } + + /// Gets the name of the nested type + public string Name { get; } + + /// Gets the constraints for a nested type + public string Constraints { get; } + + /// Gets a value indicating whether this name contains constraints + public bool HasConstraints => !string.IsNullOrWhiteSpace(Constraints); + + /// Compares this instance with another + /// Value to compare this instance with + /// if the is equal to this instance + /// + /// This is, at worst, a recursive O(n) operation! However, since it is used for nested types + /// the actual depth is statistically rather small and nearly always 0 (Children is empty). + /// Deeply nested type declarations is a VERY rare anti-pattern so not a real world problem. + /// + public bool Equals(NestedClassName other) + { + if (other == null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + // NOTE: This is a recursive O(n) operation! + return Equals(Children, other.Children) + && Name.Equals( other.Name, StringComparison.Ordinal ) + && Constraints.Equals( other.Constraints, StringComparison.Ordinal ); + } + + /// + public override bool Equals(object obj) + { + return obj is NestedClassName parentClass && Equals(parentClass); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(Children, Keyword, Name, Constraints); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/Result.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/Result.cs new file mode 100644 index 000000000..68af2179f --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/Result.cs @@ -0,0 +1,87 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Container for a result or an error descriptor () + /// Value contained in the result [Constrained to ] + /// + /// + /// It is debatable if an incremental generator should produce diagnostics. The official + /// cookbook + /// recommends against it [Section: "Issue diagnostics"]. Instead it recommends use of an analyzer. While it + /// doesn't say what a generator is supposed to do in the event of an input error explicitly, it is implied + /// that it should silently ignore them. + /// + /// This type allows generating diagnostics in the final source production stage of the pipeline by plumbing + /// through all of the available data AND diagnostics via this generic record. This acts as a sort of + /// discriminated union of results or diagnostics (or possibly a combination of both). All while maintaining + /// support for caching with . + /// + /// + public readonly record struct Result + where T : IEquatable + { + /// Initializes a new instance of the struct from a value [No diagnostics] + /// Value of the result + public Result(T value) + : this(value, []) + { + } + + /// Initializes a new instance of the struct from diagnostics + /// Information describing the diagnostics for this result + public Result(params IEnumerable diagnostics) + : this(default, [.. diagnostics]) + { + } + + /// Initializes a new instance of the struct from a nullable value and set of potentially empty diagnostics + /// Value of the result (may be null to indicate no results) + /// Array of to describe any diagnostics/warnings encountered while producing + /// + /// This is the most generalized from of constructor. It supports BOTH a value and diagnostics as it is possible that + /// a value is producible, but there are warnings or other informative diagnostics to include with it. Attempts to construct + /// a result with no value and no diagnostics throws an exception. + /// + /// Both and are or empty + public Result(T? value, ImmutableArray diagnostics) + { + if (value is null && diagnostics.IsDefaultOrEmpty) + { + throw new ArgumentException($"Either {nameof(Value)} or {nameof(diagnostics)} must contain a value"); + } + + Value = value; + Diagnostics = diagnostics; + } + + /// Gets the value produced for this result or if no value produced + public T? Value { get; init; } = default; + + /// Gets a value indicating whether a value was produced for this result + public bool HasValue => Value is not null; + + /// Gets the diagnostics produced for this result (if any) + /// This may provide an empty array but is never + public EquatableArray Diagnostics { get; init; } = ImmutableArray.Empty; + + /// Gets a value indicating whether this result contains any diagnostics + /// This is a shorthand for testing the length of the property + public bool HasDiagnostics => !Diagnostics.IsEmpty; + + /// Report all diagnostics to the provided + /// to report the diagnostics to + /// + /// This supports the deferral of reporting with a collection of cahceable . This allows + /// for a generatr to report critical internal problems. + /// + public void ReportDiagnostics(SourceProductionContext ctx) + { + foreach (var di in Diagnostics) + { + ctx.ReportDiagnostic(di.CreateDiagnostic()); + } + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/RoslynExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/RoslynExtensions.cs new file mode 100644 index 000000000..ba45e641a --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/RoslynExtensions.cs @@ -0,0 +1,48 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Utility class for general Roslyn extensions + /// + /// This is a place-holder for extensions that don't fit anywhere else and don't really warrant their own type/file. + /// + public static class RoslynExtensions + { + /// Gets an identifier name or () if the is not + /// to get the identifier from + /// Identifier name + /// is + [SuppressMessage( "Style", "IDE0046:Convert to conditional expression", Justification = "Nested conditionals are NOT simpler" )] + public static string GetIdentifierName(this AttributeSyntax self) + { + if(self is null) + { + throw new ArgumentNullException(nameof(self)); + } + + return self.Name is not IdentifierNameSyntax identifier + ? string.Empty + : identifier.Identifier.ValueText; + } + + /// Adds a source file from a manifest resource + /// The to add the source to + /// Assembly hosting the resource + /// Name of the resource + /// Hint name for the generated file + /// Encoding for the source [Default: ] + public static void AddSourceFromResource( + this IncrementalGeneratorPostInitializationContext self, + Assembly resourceAssembly, + string resourceName, + string hintName, + Encoding? encoding = null + ) + { + encoding ??= Encoding.UTF8; + using var reader = new StreamReader(resourceAssembly.GetManifestResourceStream(resourceName), encoding); + self.AddSource(hintName, SourceText.From(reader, checked((int)reader.BaseStream.Length), encoding)); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/SourceProductionContextExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/SourceProductionContextExtensions.cs new file mode 100644 index 000000000..d48f94280 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/SourceProductionContextExtensions.cs @@ -0,0 +1,88 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Utility class to provide extensions for a + public static class SourceProductionContextExtensions + { + /// Reports deferred diagnostics for a + /// Type of the result values + /// The context to report any diagnostics to + /// The result + /// + /// if has no diagnostics associated with it this is a NOP. + /// + public static void ReportDiagnostic(this SourceProductionContext self, Result result) + where T : IEquatable + { + if(result.HasDiagnostics) + { + for(int i = 0; i < result.Diagnostics.Length; ++i) + { + self.ReportDiagnostic(result.Diagnostics[i]); + } + } + } + + /// Reports a diagnostic to + /// The context to report the diagnostic to + /// Cached info to report + public static void ReportDiagnostic( this SourceProductionContext self, DiagnosticInfo info) + { + self.ReportDiagnostic(info.CreateDiagnostic()); + } + + /// Reports a diagnostic to + /// The context to report the diagnostic to + /// Descriptor of the diagnostic + /// Message arguments + public static void ReportDiagnostic( this SourceProductionContext self, DiagnosticDescriptor descriptor, params object[] messageArgs) + { + self.ReportDiagnostic(Diagnostic.Create(descriptor, null, messageArgs)); + } + + /// Reports a diagnostic to + /// The context to report the diagnostic to + /// Location of the source of this diagnostic + /// Descriptor for the diagnostic + /// Argumnets, if any, for the diagnostic message + public static void ReportDiagnostic( + this SourceProductionContext self, + Location location, + DiagnosticDescriptor descriptor, + params object[] messageArgs + ) + { + self.ReportDiagnostic(Diagnostic.Create(descriptor, location, messageArgs)); + } + + /// Reports a diagnostic to + /// The context to report the diagnostic to + /// Node as the source of the diagnostic + /// Descriptor for the diagnostic + /// Argumnets, if any, for the diagnostic message + public static void ReportDiagnostic( + this SourceProductionContext self, + CSharpSyntaxNode node, + DiagnosticDescriptor descriptor, + params object[] messageArgs + ) + { + self.ReportDiagnostic(node.GetLocation(), descriptor, messageArgs); + } + + /// Report diagnostics for results to + /// Type of the result + /// The context to report the diagnostic to + /// Array of for the results + public static void ReportDiagnostics( this SourceProductionContext self, ImmutableArray> results) + where T : IEquatable + { + for(int i = 0; i < results.Length; ++i) + { + self.ReportDiagnostic(results[i]); + } + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/StringBuilderText.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/StringBuilderText.cs new file mode 100644 index 000000000..628ea851a --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/StringBuilderText.cs @@ -0,0 +1,82 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// A implementation over a + /// + /// This provides a implementation over a + /// it provides access to the underlying builder to allow construction of an + /// to manage indentation in the output. Manual generation of the output with indentation tracking is the + /// recommended approach to generating source output. + /// + /// + public class StringBuilderText + : SourceText + { + /// Initializes a new instance of the class + /// Builder to use for building strings + /// Encoding to use for the strings + /// Hash algorithm to use for debug symbols in the source + public StringBuilderText(StringBuilder builder, Encoding encoding, SourceHashAlgorithm algorithm = SourceHashAlgorithm.Sha1) + : base(checksumAlgorithm: algorithm) + { + Builder = builder; + InternalEncoding = encoding; + } + + /// Initializes a new instance of the class + /// Encoding to use for the strings + /// Hash algorithm to use for debug symbols in the source + /// + /// This constructor overload will create a new as the underlying + /// store for the strings. This is likely the most common case. + /// + public StringBuilderText(Encoding encoding, SourceHashAlgorithm algorithm = SourceHashAlgorithm.Sha1) + : this(new StringBuilder(), encoding, algorithm) + { + } + + /// Creates a new over the internal + /// The newly created + /// + /// The created, does NOT dispose of or invalidate the underlying + /// . This allows things like + /// to work even after is called. + /// The created writer is commonly wrapped in an instance of + /// for generating source output in a source generator. + /// + public StringWriter CreateWriter() + { + return new StringWriter(Builder); + } + + /// Gets the internal builder + public StringBuilder Builder { get; init; } + + /// + public override char this[int position] => Builder[position]; + + /// + public override Encoding Encoding => InternalEncoding; + + /// + public override int Length => Builder.Length; + + /// + public override void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count) + { + Builder.CopyTo(sourceIndex, destination, destinationIndex, count); + } + + /// Converts the specified span in the underlying builder into a string + /// Span to convert + /// Text from the builder as a string + public override string ToString(TextSpan span) + { + return Builder.ToString(span.Start, span.Length); + } + + private readonly Encoding InternalEncoding; + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/SyntaxTokenListExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/SyntaxTokenListExtensions.cs new file mode 100644 index 000000000..bf0287dd9 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/SyntaxTokenListExtensions.cs @@ -0,0 +1,31 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Utility class for extensions to + public static class SyntaxTokenListExtensions + { + /// Gets a value indicating whether has the "extern" keyword + /// to test + /// if the keyword is found if not + public static bool HasExtern(this SyntaxTokenList self) + { + return self.Any(SyntaxKind.ExternKeyword); + } + + /// Gets a value indicating whether has the "partial" keyword + /// + public static bool HasPartialKeyword(this SyntaxTokenList self) + { + return self.Any(SyntaxKind.PartialKeyword); + } + + /// Gets a value indicating whether has the "static" keyword + /// + public static bool HasStatic(this SyntaxTokenList self) + { + return self.Any(SyntaxKind.StaticKeyword); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/TypeSyntaxExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/TypeSyntaxExtensions.cs new file mode 100644 index 000000000..4088b132c --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/TypeSyntaxExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Utility class to provide extensions for + public static class TypeSyntaxExtensions + { + /// Tests if is a string + /// to test + /// if is a string and if not + public static bool IsString(this TypeSyntax? self) + { + return (self is PredefinedTypeSyntax pts && pts.Keyword.IsKind(SyntaxKind.StringKeyword)) + || (self is QualifiedNameSyntax qns && qns.Left.ToString() == "System" && qns.Right.ToString() == "String"); + } + + /// Tests if is a void + /// to test + /// if is a void and if not + public static bool IsVoid(this TypeSyntax? self) + { + return self is PredefinedTypeSyntax pts + && pts.Keyword.IsKind(SyntaxKind.VoidKeyword); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj b/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj new file mode 100644 index 000000000..0d7dabb50 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj @@ -0,0 +1,30 @@ + + + + netstandard2.0 + enable + + + preview + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/src/Ubiquity.NET.Extensions.UT/Ubiquity.NET.Extensions.UT.csproj b/src/Ubiquity.NET.Extensions.UT/Ubiquity.NET.Extensions.UT.csproj index 4013ddc85..d3d73d298 100644 --- a/src/Ubiquity.NET.Extensions.UT/Ubiquity.NET.Extensions.UT.csproj +++ b/src/Ubiquity.NET.Extensions.UT/Ubiquity.NET.Extensions.UT.csproj @@ -14,7 +14,18 @@
+ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/src/Ubiquity.NET.Extensions/AssemblyExtensions.cs b/src/Ubiquity.NET.Extensions/AssemblyExtensions.cs index a6ba44c0f..552ed2092 100644 --- a/src/Ubiquity.NET.Extensions/AssemblyExtensions.cs +++ b/src/Ubiquity.NET.Extensions/AssemblyExtensions.cs @@ -7,12 +7,12 @@ namespace Ubiquity.NET.Extensions [SuppressMessage( "Design", "CA1034:Nested types should not be visible", Justification = "Extension" )] public static class AssemblyExtensions { - // VS2026 builds of this are OK, however command line/PR/CI builds will generate an error - // No idea why there's a difference the $(NETCoreSdkVersion) is the same in both so it's - // unclear why the two things behave differently. This is just another example of why the - // `extension` keyword is "not yet ready for prime time". Too many things don't support it - // properly yet. [Hopefully, that works itself out in short order as it's useless unless - // fully supported] + // VS2026 builds of this are OK, however VS2019, command line/PR/CI builds will generate an error. + // The VS builds use the VS provided MSBuild, while the command line uses the .NET Core build. + // This is just another example of why the `extension` keyword is "not yet ready for prime time". + // Too many things don't support it properly yet so use needs justification as the ONLY option and + // HEAVY testing to ensure all the issues are accounted for. + // [Hopefully, that works itself out in short order as it's a mostly useless feature unless fully supported] #if COMPILER_SUPPORTS_CALLER_ATTRIBUES_ON_EXTENSION extension(Assembly self) { diff --git a/src/Ubiquity.NET.Extensions/DisposableAction.cs b/src/Ubiquity.NET.Extensions/DisposableAction.cs index 8938c5d58..a214fed35 100644 --- a/src/Ubiquity.NET.Extensions/DisposableAction.cs +++ b/src/Ubiquity.NET.Extensions/DisposableAction.cs @@ -21,11 +21,9 @@ public DisposableAction( Action onDispose, [CallerArgumentExpression(nameof(onDi } /// Runs the action provided in the constructor () - /// This instance is already disposed public void Dispose( ) { var disposeOp = Interlocked.Exchange(ref OnDispose, null); - ObjectDisposedException.ThrowIf(disposeOp is null, this); disposeOp!(); } diff --git a/src/Ubiquity.NET.Extensions/FluentValidation/ExceptionValidationExtensions.cs b/src/Ubiquity.NET.Extensions/FluentValidation/ExceptionValidationExtensions.cs index 21c6bb383..ec3908330 100644 --- a/src/Ubiquity.NET.Extensions/FluentValidation/ExceptionValidationExtensions.cs +++ b/src/Ubiquity.NET.Extensions/FluentValidation/ExceptionValidationExtensions.cs @@ -46,9 +46,9 @@ public static class ExceptionValidationExtensions [DebuggerStepThrough] public static T ThrowIfNull( [NotNull] this T? self, [CallerArgumentExpression( nameof( self ) )] string? exp = null ) { - ArgumentNullException.ThrowIfNull( self, exp ); - - return self; + return self is null + ? throw new ArgumentNullException(exp) + : self; } /// Throws an exception if an argument is outside of a given (Inclusive) range @@ -59,13 +59,12 @@ public static T ThrowIfNull( [NotNull] this T? self, [CallerArgumentExpressio /// Name or expression of the value in [Default: provided by compiler] /// [DebuggerStepThrough] + [SuppressMessage( "Style", "IDE0046:Convert to conditional expression", Justification = "Not simpler, more readable this way" )] public static T ThrowIfOutOfRange( this T self, T min, T max, [CallerArgumentExpression( nameof( self ) )] string? exp = null ) where T : struct, IComparable { - ArgumentNullException.ThrowIfNull(self, exp); - ArgumentOutOfRangeException.ThrowIfLessThan( self, min, exp ); - ArgumentOutOfRangeException.ThrowIfGreaterThan( self, max, exp ); - + ArgumentOutOfRangeException.ThrowIfLessThan(self, min, exp); + ArgumentOutOfRangeException.ThrowIfGreaterThan(self, max, exp); return self; } @@ -104,9 +103,8 @@ public static T ThrowIfNotDefined( this T self, [CallerArgumentExpression( na // least includes the original value in question. (Normally an enum does fit an int, but for // interop might not) the resulting exception will have "ParamName" as the default of "null"! // - // TODO: Move the exception message to a resource for globalization // This matches the overloaded constructor version but allows for reporting enums with non-int underlying type. - throw new InvalidEnumArgumentException( $"The value of argument '{exp}' ({self}) is invalid for Enum of type '{typeof( T )}'" ); + throw new InvalidEnumArgumentException( SR.Format( nameof( Resources.InvalidEnumArgument_NonInt ), exp, self, typeof( T ) ) ); } } } diff --git a/src/Ubiquity.NET.Extensions/GlobalNamespaceImports.cs b/src/Ubiquity.NET.Extensions/GlobalNamespaceImports.cs index 2ca3f6006..8137e3280 100644 --- a/src/Ubiquity.NET.Extensions/GlobalNamespaceImports.cs +++ b/src/Ubiquity.NET.Extensions/GlobalNamespaceImports.cs @@ -26,13 +26,10 @@ set of namespaces that is NOT consistent or controlled by the developer. THAT is global using System.Diagnostics.CodeAnalysis; global using System.Globalization; global using System.IO; +global using System.Linq; global using System.Reflection; global using System.Runtime.CompilerServices; global using System.Text; global using System.Threading; -global using Ubiquity.NET.Extensions; global using Ubiquity.NET.Extensions.Properties; - -// alias allows simpler porting of polyfill from .NET sources -global using SR = Ubiquity.NET.Extensions.Properties.Resources; diff --git a/src/Ubiquity.NET.Extensions/PolyFillExceptionValidators.cs b/src/Ubiquity.NET.Extensions/PolyFillExceptionValidators.cs deleted file mode 100644 index 7f4c3250a..000000000 --- a/src/Ubiquity.NET.Extensions/PolyFillExceptionValidators.cs +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright (c) Ubiquity.NET Contributors. All rights reserved. -// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. - -// .NET 7 added the various exception static methods for parameter validation -// This will back fill them for earlier versions. -// -// NOTE: C #14 extension keyword support is required to make this work. -#if !NET7_0_OR_GREATER - -#pragma warning disable IDE0130 // Namespace does not match folder structure - -namespace System -{ - /// poly fill extensions for static methods added in .NET 7 - /// - /// This requires support of the C#14 keyword `extension` to work properly. There is - /// no other way to add static methods to non-partial types for source compatibility. - /// Otherwise code cannot use the modern .NET runtime implementations and instead - /// must always use some extension methods, or litter around a LOT of #if/#else/#endif - /// based on the framework version... - /// - [SuppressMessage( "Design", "CA1034:Nested types should not be visible", Justification = "Extension - Broken analyzer" )] - [SuppressMessage( "Naming", "CA1708:Identifiers should differ by more than case", Justification = "Extension - broken analyzer" )] - public static class PolyFillExceptionValidators - { - /// Poly fill Extensions for - extension( ArgumentException ) - { - /// Throw an if a string is m empty, or all whitepsace. - /// input string to test - /// expression or name of the string to test; normally provided by compiler - /// string is m empty, or all whitepsace - public static void ThrowIfNullOrWhiteSpace( - [NotNull] string? argument, - [CallerArgumentExpression( nameof( argument ) )] string? paramName = null - ) - { - ArgumentNullException.ThrowIfNull(argument, paramName); - - // argument is non-null verified by this, sadly older frameworks don't have - // attributes to declare that. - if(string.IsNullOrWhiteSpace( argument )) - { - throw new ArgumentException( SR.Argument_EmptyOrWhiteSpaceString, paramName ); - } - } - } - - /// Poly fill Extensions for - extension( ArgumentNullException ) - { - /// Throws an aexception if the tested argument is - /// value to test - /// expression for the name of the value; normally provided by compiler - /// is - public static void ThrowIfNull( - [NotNull] object? argument, - [CallerArgumentExpression( nameof( argument ) )] string? paramName = default - ) - { - if(argument is null) - { - throw new ArgumentNullException( paramName ); - } - } - } - - /// Poly fill Extensions for - extension( ObjectDisposedException ) - { - /// Throws an if is . - /// Condition to determine if the instance is disposed - /// instance that is tested; Used to get type name for exception - /// is - public static void ThrowIf( - [DoesNotReturnIf( true )] bool condition, - object instance - ) - { - if(condition) - { - throw new ObjectDisposedException( instance?.GetType().FullName ); - } - } - } - - /// Poly fill Extensions for - extension( ArgumentOutOfRangeException ) - { - /// Throws an if is equal to . - /// The argument to validate as not equal to . - /// The value to compare with . - /// The name of the parameter with which corresponds. - public static void ThrowIfEqual( T value, T other, [CallerArgumentExpression( nameof( value ) )] string? paramName = null ) - where T : IEquatable? - { - if(EqualityComparer.Default.Equals( value, other )) - { - ThrowEqual( value, other, paramName ); - } - } - - /// Throws an if is not equal to . - /// The argument to validate as equal to . - /// The value to compare with . - /// The name of the parameter with which corresponds. - public static void ThrowIfNotEqual( T value, T other, [CallerArgumentExpression( nameof( value ) )] string? paramName = null ) - where T : IEquatable? - { - if(!EqualityComparer.Default.Equals( value, other )) - { - ThrowNotEqual( value, other, paramName ); - } - } - - /// Throws an if is greater than . - /// The argument to validate as less or equal than . - /// The value to compare with . - /// The name of the parameter with which corresponds. - public static void ThrowIfGreaterThan( T value, T other, [CallerArgumentExpression( nameof( value ) )] string? paramName = null ) - where T : IComparable - { - if(value.CompareTo( other ) > 0) - { - ThrowGreater( value, other, paramName ); - } - } - - /// Throws an if is greater than or equal . - /// The argument to validate as less than . - /// The value to compare with . - /// The name of the parameter with which corresponds. - public static void ThrowIfGreaterThanOrEqual( T value, T other, [CallerArgumentExpression( nameof( value ) )] string? paramName = null ) - where T : IComparable - { - if(value.CompareTo( other ) >= 0) - { - ThrowGreaterEqual( value, other, paramName ); - } - } - - /// Throws an if is less than . - /// The argument to validate as greatar than or equal than . - /// The value to compare with . - /// The name of the parameter with which corresponds. - public static void ThrowIfLessThan( T value, T other, [CallerArgumentExpression( nameof( value ) )] string? paramName = null ) - where T : IComparable - { - if(value.CompareTo( other ) < 0) - { - ThrowLess( value, other, paramName ); - } - } - - /// Throws an if is less than or equal . - /// The argument to validate as greatar than than . - /// The value to compare with . - /// The name of the parameter with which corresponds. - public static void ThrowIfLessThanOrEqual( T value, T other, [CallerArgumentExpression( nameof( value ) )] string? paramName = null ) - where T : IComparable - { - if(value.CompareTo( other ) <= 0) - { - ThrowLessEqual( value, other, paramName ); - } - } - - [DoesNotReturn] - private static void ThrowZero( T value, string? paramName ) => - throw new ArgumentOutOfRangeException( paramName, value, SR.Format( SR.ArgumentOutOfRange_Generic_MustBeNonZero, paramName, value ) ); - - [DoesNotReturn] - private static void ThrowNegative( T value, string? paramName ) => - throw new ArgumentOutOfRangeException( paramName, value, SR.Format( SR.ArgumentOutOfRange_Generic_MustBeNonNegative, paramName, value ) ); - - [DoesNotReturn] - private static void ThrowNegativeOrZero( T value, string? paramName ) => - throw new ArgumentOutOfRangeException( paramName, value, SR.Format( SR.ArgumentOutOfRange_Generic_MustBeNonNegativeNonZero, paramName, value ) ); - - [DoesNotReturn] - private static void ThrowGreater( T value, T other, string? paramName ) => - throw new ArgumentOutOfRangeException( paramName, value, SR.Format( SR.ArgumentOutOfRange_Generic_MustBeLessOrEqual, paramName, value, other ) ); - - [DoesNotReturn] - private static void ThrowGreaterEqual( T value, T other, string? paramName ) => - throw new ArgumentOutOfRangeException( paramName, value, SR.Format( SR.ArgumentOutOfRange_Generic_MustBeLess, paramName, value, other ) ); - - [DoesNotReturn] - private static void ThrowLess( T value, T other, string? paramName ) => - throw new ArgumentOutOfRangeException( paramName, value, SR.Format( SR.ArgumentOutOfRange_Generic_MustBeGreaterOrEqual, paramName, value, other ) ); - - [DoesNotReturn] - private static void ThrowLessEqual( T value, T other, string? paramName ) => - throw new ArgumentOutOfRangeException( paramName, value, SR.Format( SR.ArgumentOutOfRange_Generic_MustBeGreater, paramName, value, other ) ); - - [DoesNotReturn] - private static void ThrowEqual( T value, T other, string? paramName ) => - throw new ArgumentOutOfRangeException( paramName, value, SR.Format( SR.ArgumentOutOfRange_Generic_MustBeNotEqual, paramName, (object?)value ?? "null", (object?)other ?? "null" ) ); - - [DoesNotReturn] - private static void ThrowNotEqual( T value, T other, string? paramName ) => - throw new ArgumentOutOfRangeException( paramName, value, SR.Format( SR.ArgumentOutOfRange_Generic_MustBeEqual, paramName, (object?)value ?? "null", (object?)other ?? "null" ) ); - } - } -} -#endif diff --git a/src/Ubiquity.NET.Extensions/PolyFillStringExtensions.cs b/src/Ubiquity.NET.Extensions/PolyFillStringExtensions.cs deleted file mode 100644 index b7f38dbff..000000000 --- a/src/Ubiquity.NET.Extensions/PolyFillStringExtensions.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Ubiquity.NET Contributors. All rights reserved. -// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. - -#if !NET6_0_OR_GREATER - -// from .NET sources -// see: https://github.com/dotnet/runtime/blob/1d1bf92fcf43aa6981804dc53c5174445069c9e4/src/libraries/System.Private.CoreLib/src/System/String.Manipulation.cs - -#pragma warning disable IDE0130 // Namespace does not match folder structure -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member -#pragma warning disable SA1600 // Elements should be documented [Duplicate of CS1591] - -using System.Text.RegularExpressions; - -namespace System.Text -{ - /// Pollyfill extensions for support not present in older runtimes - /// - public static class PolyFillStringExtensions - { - /// Replace line endings in the string with environment specific forms - /// string to change line endings for - /// string with environment specific line endings - [SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters should be documented", Justification = "Extension" )] - public static string ReplaceLineEndings(this string self) => ReplaceLineEndings(self, Environment.NewLine); - - // This is NOT the most performant implementation, it's going for simplistic pollyfill that has - // the correct behavior, even if not the most performant. If performance is critical, use a - // later version of the runtime! - - /// Replace line endings in the string with a given string - /// string to change line endings for - /// Text to replace all of the line endings in - /// string with line endings replaced by - [MethodImpl( MethodImplOptions.AggressiveInlining )] - [SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters should be documented", Justification = "Extension" )] - public static string ReplaceLineEndings( this string self, string replacementText ) - { - ArgumentNullException.ThrowIfNull(self); - ArgumentNullException.ThrowIfNull(replacementText); - - string retVal = UnicodeNewLinesRegEx.Replace(self, replacementText); - - // if the result of replacement is the same, just return the original - // This is wasted overhead, but at least matches the behavior - return self == retVal ? self : retVal; - } - - // The Unicode Standard, Sec. 5.8, Recommendation R4 and Table 5-2 state that the CR, LF, - // CRLF, NEL, LS, FF, and PS sequences are considered newline functions. That section - // also specifically excludes VT from the list of newline functions, so we do not include - // it in the regular expression match. - - // language=regex - private const string UnicodeNewLinesRegExPattern = @"(\r\n|\r|\n|\f|\u0085|\u2028|\u2029)"; - - private static Regex UnicodeNewLinesRegEx { get; } = new Regex( UnicodeNewLinesRegExPattern ); - } -} -#endif diff --git a/src/Ubiquity.NET.Extensions/Properties/ResourceNotFoundException.cs b/src/Ubiquity.NET.Extensions/Properties/ResourceNotFoundException.cs new file mode 100644 index 000000000..2fe41e37d --- /dev/null +++ b/src/Ubiquity.NET.Extensions/Properties/ResourceNotFoundException.cs @@ -0,0 +1,37 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.Extensions.Properties +{ + /// Exception thornw if a resource is missing + /// + /// This is ALWAYS a bug in the application and should not be caught or suppressed in any way. + /// It indicates that a named resource does not exist, either add the resource or correct the + /// spelling of the name - NEVER dismiss this. + /// + [Serializable] + public class ResourceNotFoundException + : Exception + { + /// Initializes a new instance of the class. + public ResourceNotFoundException( ) + { + } + + /// + public ResourceNotFoundException( string resourceName ) + : base( string.Format( Resources.Culture, Resources.Missing_Resource_Exception_Message_fmt, resourceName ) ) + { + ResourceName = resourceName; + } + + /// + public ResourceNotFoundException( string message, Exception inner ) + : base( message, inner ) + { + } + + /// Gets the name of the resource missing + public string ResourceName { get; } = string.Empty; + } +} diff --git a/src/Ubiquity.NET.Extensions/Properties/Resources.Designer.cs b/src/Ubiquity.NET.Extensions/Properties/Resources.Designer.cs index d7fa71cf2..71cb072c4 100644 --- a/src/Ubiquity.NET.Extensions/Properties/Resources.Designer.cs +++ b/src/Ubiquity.NET.Extensions/Properties/Resources.Designer.cs @@ -149,5 +149,23 @@ internal static string ArgumentOutOfRange_Generic_MustBeNotEqual { return ResourceManager.GetString("ArgumentOutOfRange_Generic_MustBeNotEqual", resourceCulture); } } + + /// + /// Looks up a localized string similar to The value of argument '{0}' ({1}) is invalid for Enum of type '{2}'. + /// + internal static string InvalidEnumArgument_NonInt { + get { + return ResourceManager.GetString("InvalidEnumArgument_NonInt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing resource. Name='{0}'. + /// + internal static string Missing_Resource_Exception_Message_fmt { + get { + return ResourceManager.GetString("Missing_Resource_Exception_Message_fmt", resourceCulture); + } + } } } diff --git a/src/Ubiquity.NET.Extensions/Properties/Resources.resx b/src/Ubiquity.NET.Extensions/Properties/Resources.resx index 68a149afe..62c8ccfc7 100644 --- a/src/Ubiquity.NET.Extensions/Properties/Resources.resx +++ b/src/Ubiquity.NET.Extensions/Properties/Resources.resx @@ -147,4 +147,11 @@ The value cannot be an empty string or composed entirely of whitespace. - + + The value of argument '{0}' ({1}) is invalid for Enum of type '{2}' + Message used for enums with an underlying type that is not able to fit into an int. {0} - expression that generated the exception; {1} value of the expression that is invalid; {2} is the type of the enumeration. + + + Missing resource. Name='{0}' + + \ No newline at end of file diff --git a/src/Ubiquity.NET.Extensions/Properties/SR.cs b/src/Ubiquity.NET.Extensions/Properties/SR.cs new file mode 100644 index 000000000..d59355747 --- /dev/null +++ b/src/Ubiquity.NET.Extensions/Properties/SR.cs @@ -0,0 +1,46 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +namespace Ubiquity.NET.Extensions.Properties +{ + // RESX file generator does not use `partial` and VS2022 doesn't correctly support + // C# 14 `extension` so derivation is the only option at present... + // CONSIDER: Generator to create this from an attribute that includes the type of the Resources + // generator/Analyzer should validate that the type has a ResourceManager member. + // (Effectively a C++ concept since the type is generated by a SingleFileGenerator + // it doesn't have an interface or base type to use as a constraint) + internal static class SR + { + internal static string Format( [NotNull] string resourceName, TArg0 arg0 ) + { + ArgumentNullException.ThrowIfNull(resourceName); + + string? fmt = Resources.ResourceManager.GetString(resourceName, Resources.Culture) ?? throw new ResourceNotFoundException(resourceName); + return string.Format(Resources.Culture, fmt, arg0); + } + + internal static string Format( [NotNull] string resourceName, TArg0 arg0, TArg1 arg1 ) + { + ArgumentNullException.ThrowIfNull(resourceName); + + string? fmt = Resources.ResourceManager.GetString(resourceName, Resources.Culture) ?? throw new ResourceNotFoundException(resourceName); + return string.Format(Resources.Culture, fmt, arg0, arg1); + } + + internal static string Format( [NotNull] string resourceName, TArg0 arg0, TArg1 arg1, TArg2 arg2 ) + { + ArgumentNullException.ThrowIfNull(resourceName); + + string? fmt = Resources.ResourceManager.GetString(resourceName, Resources.Culture) ?? throw new ResourceNotFoundException(resourceName); + return string.Format(Resources.Culture, fmt, arg0, arg1, arg2); + } + + internal static string Format( [NotNull] string resourceName, params IEnumerable args ) + { + ArgumentNullException.ThrowIfNull(resourceName); + + string? fmt = Resources.ResourceManager.GetString(resourceName, Resources.Culture) ?? throw new ResourceNotFoundException(resourceName); + return string.Format(Resources.Culture, fmt, args); + } + } +} diff --git a/src/Ubiquity.NET.Extensions/Properties/StringResourceExtensions.cs b/src/Ubiquity.NET.Extensions/Properties/StringResourceExtensions.cs deleted file mode 100644 index f3bdfada4..000000000 --- a/src/Ubiquity.NET.Extensions/Properties/StringResourceExtensions.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Ubiquity.NET Contributors. All rights reserved. -// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. - -using Ubiquity.NET.Extensions.FluentValidation; - -namespace Ubiquity.NET.Extensions.Properties -{ - internal static class StringResourceExtensions - { - // RESX file generator does not use `partial` so C# 14 extension is the only option... - extension(Ubiquity.NET.Extensions.Properties.Resources) - { - internal static string Format( [NotNull][StringSyntax(StringSyntaxAttribute.CompositeFormat)] string fmt, TArg0 arg0 ) - { - fmt.ThrowIfNull(); - return string.Format( CultureInfo.CurrentCulture, fmt, arg0 ); - } - - internal static string Format( [NotNull][StringSyntax( StringSyntaxAttribute.CompositeFormat )] string fmt, TArg0 arg0, TArg1 arg1 ) - { - fmt.ThrowIfNull(); - return string.Format( CultureInfo.CurrentCulture, fmt, arg0, arg1 ); - } - - internal static string Format( [NotNull][StringSyntax( StringSyntaxAttribute.CompositeFormat )] string fmt, TArg0 arg0, TArg1 arg1, TArg3 arg3 ) - { - fmt.ThrowIfNull(); - return string.Format( CultureInfo.CurrentCulture, fmt, arg0, arg1, arg3 ); - } - } - -#if NET7_0_OR_GREATER - // This is just a utility method, not an extension - internal static CompositeFormat ParseAsFormat( [NotNull][StringSyntax( StringSyntaxAttribute.CompositeFormat )] this string? self ) - { - ArgumentException.ThrowIfNullOrWhiteSpace( self ); - return CompositeFormat.Parse( self ); - } -#endif - } -} diff --git a/src/Ubiquity.NET.Extensions/SourcePosition.cs b/src/Ubiquity.NET.Extensions/SourcePosition.cs index 0f0e0bcbc..d71460980 100644 --- a/src/Ubiquity.NET.Extensions/SourcePosition.cs +++ b/src/Ubiquity.NET.Extensions/SourcePosition.cs @@ -34,7 +34,14 @@ public required int Column get; init { +#if NET7_0_OR_GREATER ArgumentOutOfRangeException.ThrowIfLessThan(value, 0); +#else + if(value.CompareTo(0) < 0) + { + throw new ArgumentOutOfRangeException(SR.Format(nameof(Resources.ArgumentOutOfRange_Generic_MustBeGreaterOrEqual), nameof(value), value, 0)); + } +#endif field = value; } } @@ -45,7 +52,14 @@ public required int Line get; init { +#if NET7_0_OR_GREATER ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(value, 0); +#else + if(value.CompareTo(0) <= 0) + { + throw new ArgumentOutOfRangeException(SR.Format(nameof(Resources.ArgumentOutOfRange_Generic_MustBeGreater), nameof(value), value, 0)); + } +#endif field = value; } } diff --git a/src/Ubiquity.NET.Extensions/Ubiquity.NET.Extensions.csproj b/src/Ubiquity.NET.Extensions/Ubiquity.NET.Extensions.csproj index 23b7f2797..752d42abc 100644 --- a/src/Ubiquity.NET.Extensions/Ubiquity.NET.Extensions.csproj +++ b/src/Ubiquity.NET.Extensions/Ubiquity.NET.Extensions.csproj @@ -31,17 +31,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers - @@ -54,16 +49,19 @@ + Resources.resx True True - Resources.resx - ResXFileCodeGenerator Resources.Designer.cs + ResXFileCodeGenerator + + + diff --git a/src/Ubiquity.NET.InteropHelpers/CStringHandle.cs b/src/Ubiquity.NET.InteropHelpers/CStringHandle.cs index 1f4847359..3966f9b41 100644 --- a/src/Ubiquity.NET.InteropHelpers/CStringHandle.cs +++ b/src/Ubiquity.NET.InteropHelpers/CStringHandle.cs @@ -18,7 +18,7 @@ public abstract class CStringHandle , IEquatable { /// - public override bool IsInvalid => handle == nint.Zero; + public override bool IsInvalid => handle == IntPtr.Zero; /// Gets a readonly span for the data in this string /// Span of the ANSI characters in this string (as byte) @@ -53,7 +53,8 @@ public ReadOnlySpan ReadOnlySpan /// public override bool Equals( object? obj ) { - return ((IEquatable)this).Equals( obj as CStringHandle ); + return obj is CStringHandle other + && ((IEquatable)this).Equals( other ); } /// @@ -64,6 +65,7 @@ public override int GetHashCode( ) return ToString()?.GetHashCode() ?? 0; } +#if !NETSTANDARD2_0 /// Returns the hash code for this string using the specified rules. /// One of the enumeration values that specifies the rules to use in the comparison. /// A 32-bit signed integer hash code. @@ -72,6 +74,7 @@ public int GetHashCode( StringComparison comparisonType ) ObjectDisposedException.ThrowIf( IsClosed, this ); return ToString()?.GetHashCode( comparisonType ) ?? 0; } +#endif /// public bool Equals( CStringHandle? other ) @@ -91,7 +94,7 @@ public bool Equals( ReadOnlySpan otherSpan ) /// Initializes a new instance of the class. protected CStringHandle( ) - : base( nint.Zero, ownsHandle: true ) + : base( IntPtr.Zero, ownsHandle: true ) { unsafe { diff --git a/src/Ubiquity.NET.InteropHelpers/EncodingExtensions.cs b/src/Ubiquity.NET.InteropHelpers/EncodingExtensions.cs index fa359fb15..76c114540 100644 --- a/src/Ubiquity.NET.InteropHelpers/EncodingExtensions.cs +++ b/src/Ubiquity.NET.InteropHelpers/EncodingExtensions.cs @@ -15,6 +15,26 @@ namespace Ubiquity.NET.InteropHelpers /// public static class EncodingExtensions { +#if NETSTANDARD2_0 + /// Encodes into a span of bytes a set of characters from the specified read-only span. + /// Encoding to extend + /// The span containing the set of characters to encode. + /// The byte span to hold the encoded bytes. + /// The number of encoded bytes. + [SuppressMessage( "StyleCop.CSharp.LayoutRules", "SA1519:Braces should not be omitted from multi-line child statement", Justification = "multiple fixed statements" )] + internal static int GetBytes( this Encoding self, ReadOnlySpan chars, Span bytes ) + { + unsafe + { + fixed(Char* pChar = chars) + fixed(byte* pBytes = bytes) + { + return self.GetBytes(pChar, chars.Length, pBytes, bytes.Length); + } + } + } +#endif + /// Provides conversion of a span of bytes to managed code /// The encoding to use for conversion /// Input span to convert with or without a null terminator. diff --git a/src/Ubiquity.NET.InteropHelpers/ExecutionEncodingStringMarshaller.cs b/src/Ubiquity.NET.InteropHelpers/ExecutionEncodingStringMarshaller.cs index abe99e8b9..ea439eb0b 100644 --- a/src/Ubiquity.NET.InteropHelpers/ExecutionEncodingStringMarshaller.cs +++ b/src/Ubiquity.NET.InteropHelpers/ExecutionEncodingStringMarshaller.cs @@ -1,6 +1,5 @@ // Copyright (c) Ubiquity.NET Contributors. All rights reserved. // Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. - namespace Ubiquity.NET.InteropHelpers { /// Represents a marshaller for native strings using . @@ -9,8 +8,10 @@ namespace Ubiquity.NET.InteropHelpers /// to the default string marshalling support except that it does all conversion to/from native code via /// a static property, [defaults to UTF8] so that applications can have control of that. /// +#if !NETSTANDARD2_0 [CustomMarshaller( typeof( string ), MarshalMode.Default, typeof( ExecutionEncodingStringMarshaller ) )] [CustomMarshaller( typeof( string ), MarshalMode.ManagedToUnmanagedIn, typeof( ManagedToUnmanagedIn ) )] +#endif public static unsafe class ExecutionEncodingStringMarshaller { /// Gets or sets the ExecutionEncoding for the native code @@ -34,6 +35,7 @@ public static unsafe class ExecutionEncodingStringMarshaller /// public static Encoding Encoding { get; set; } = Encoding.UTF8; +#if !NETSTANDARD2_0 /// Creates a ReadOnlySpan from a null terminated native string /// The null terminated string /// ReadOnlySpan for the string @@ -66,6 +68,14 @@ public static ReadOnlySpan ReadOnlySpanFromNullTerminated( byte* nativePtr return mem; } + /// Frees the memory for the unmanaged string representation allocated by + /// The memory allocated for the unmanaged string. + public static void Free( byte* unmanaged ) + { + NativeMemory.Free( unmanaged ); + } +#endif + /// Converts an unmanaged string to a managed version. /// The unmanaged string to convert. /// A managed string. @@ -74,13 +84,7 @@ public static ReadOnlySpan ReadOnlySpanFromNullTerminated( byte* nativePtr return Encoding.MarshalString( unmanaged ); } - /// Frees the memory for the unmanaged string representation allocated by - /// The memory allocated for the unmanaged string. - public static void Free( byte* unmanaged ) - { - NativeMemory.Free( unmanaged ); - } - +#if !NETSTANDARD2_0 /// Custom marshaller to marshal a managed string as an unmanaged string using the property for encoding the native string. [SuppressMessage( "Design", "CA1034:Nested types should not be visible", Justification = "Standard pattern for custom marshalers" )] public ref struct ManagedToUnmanagedIn @@ -140,5 +144,6 @@ public readonly void Free( ) private byte* NativePointer; private bool AllocatedByThisMarshaller; } +#endif } } diff --git a/src/Ubiquity.NET.InteropHelpers/GlobalNamespaceImports.cs b/src/Ubiquity.NET.InteropHelpers/GlobalNamespaceImports.cs index 73aea4bd9..6d1bd8f69 100644 --- a/src/Ubiquity.NET.InteropHelpers/GlobalNamespaceImports.cs +++ b/src/Ubiquity.NET.InteropHelpers/GlobalNamespaceImports.cs @@ -22,7 +22,10 @@ set of namespaces that is NOT consistent or controlled by the developer. THAT is global using System.Resources; global using System.Runtime.CompilerServices; global using System.Runtime.InteropServices; + +#if !NETSTANDARD2_0 global using System.Runtime.InteropServices.Marshalling; +#endif global using System.Text; global using System.Threading; diff --git a/src/Ubiquity.NET.InteropHelpers/LazyEncodedString.cs b/src/Ubiquity.NET.InteropHelpers/LazyEncodedString.cs index ce6bce0df..6b98f0c24 100644 --- a/src/Ubiquity.NET.InteropHelpers/LazyEncodedString.cs +++ b/src/Ubiquity.NET.InteropHelpers/LazyEncodedString.cs @@ -18,11 +18,15 @@ namespace Ubiquity.NET.InteropHelpers /// of a terminator even if the span provided to the constructor doesn't include one. (It has to copy the string anyway /// so why not be nice and robust at the cost of one byte of allocated space) /// +#if NET7_0_OR_GREATER [NativeMarshalling( typeof( LazyEncodedStringMarshaller ) )] +#endif public sealed class LazyEncodedString : IEquatable , IEquatable +#if NET7_0_OR_GREATER , IEqualityOperators +#endif { /// Initializes a new instance of the class from an existing managed string /// string to lazy encode for native code use @@ -32,7 +36,11 @@ public LazyEncodedString( string managed, Encoding? encoding = null ) EncodingCodePage = (encoding ?? Encoding.UTF8).CodePage; // Pre-Initialize with the provided string +#if NETSTANDARD2_0 // pre-initialized Lazy supported in .NET Standard 2.1 and all runtimes after that. + ManagedString = new Lazy( ( ) => managed ); +#else ManagedString = new( managed ); +#endif NativeBytes = new( GetNativeArrayWithTerminator ); unsafe byte[] GetNativeArrayWithTerminator( ) @@ -56,11 +64,23 @@ unsafe byte[] GetNativeArrayWithTerminator( ) public LazyEncodedString( ReadOnlySpan span, Encoding? encoding = null ) { EncodingCodePage = (encoding ?? Encoding.UTF8).CodePage; +#if NETSTANDARD2_0 // pre-initialized Lazy only supported in .NET Standard 2.1 and all runtimes after that. + // can't capture span for a lambda, so make array locally. + byte[] tmp = GetNativeArrayWithTerminator( span ); + NativeBytes = new( ( ) => tmp ); +#else NativeBytes = new( GetNativeArrayWithTerminator( span ) ); +#endif ManagedString = new( ConvertString, LazyThreadSafetyMode.ExecutionAndPublication ); // drop the terminator for conversion to managed so it won't appear in the string - string ConvertString( ) => NativeBytes.Value.Length > 0 ? Encoding.GetString( NativeBytes.Value[ ..^1 ] ) : string.Empty; + string ConvertString( ) + { + byte[] nativeArray = NativeBytes.Value; + return nativeArray.Length > 0 + ? Encoding.GetString( nativeArray, 0, nativeArray.Length - 1 ) + : string.Empty; + } // This incurs the cost of a copy but the lifetime of the span is not known or // guaranteed beyond this call so it has to make a copy. @@ -276,6 +296,16 @@ public static bool IsNullOrWhiteSpace( [NotNullWhen( false )] LazyEncodedString? return span.IsEmpty ? Empty : new( span ); } +#if NETSTANDARD2_0 + /// + /// with the joined result + /// + /// This will join the managed form of any LazyEncodedString (Technically the results + /// of calling on each value provided) to produce a final + /// joined string. The result will only have the managed form created but will lazily + /// provide the managed form if/when needed (conversion still only happens once). + /// +#else /// /// with the joined result /// @@ -284,6 +314,7 @@ public static bool IsNullOrWhiteSpace( [NotNullWhen( false )] LazyEncodedString? /// joined string. The result will only have the managed form created but will lazily /// provide the managed form if/when needed (conversion still only happens once). /// +#endif public static LazyEncodedString Join( char separator, params IEnumerable values ) { return new( string.Join( separator, values ) ); @@ -323,7 +354,7 @@ public static LazyEncodedString Join( char separator, params IEnumerableImplicit cast to a string via /// instance to cast - [return: NotNullIfNotNull(nameof(self))] + [return: NotNullIfNotNull( nameof( self ) )] public static implicit operator string?( LazyEncodedString? self ) { return self?.ToString(); @@ -352,14 +383,28 @@ public static implicit operator ReadOnlySpan( LazyEncodedString self ) [SuppressMessage( "Usage", "CA2225:Operator overloads have named alternates", Justification = "It's a convenience wrapper around an existing constructor" )] public static implicit operator LazyEncodedString( ReadOnlySpan utf8Data ) => new( utf8Data ); - /// +#if NETSTANDARD2_0 + /// Compares two values to determine equality. + /// The value to compare with . + /// The value to compare with . + /// if left is equal to right; otherwise, . +#else + /// +#endif public static bool operator ==( LazyEncodedString? left, LazyEncodedString? right ) { return ReferenceEquals( left, right ) || (left is not null && left.Equals( right )); } - /// +#if NETSTANDARD2_0 + /// Compares two values to determine equality. + /// The value to compare with . + /// The value to compare with . + /// if left is not equal to right; otherwise, . +#else + /// +#endif public static bool operator !=( LazyEncodedString? left, LazyEncodedString? right ) { return !ReferenceEquals( left, right ) diff --git a/src/Ubiquity.NET.InteropHelpers/LazyEncodedStringMarshaller.cs b/src/Ubiquity.NET.InteropHelpers/LazyEncodedStringMarshaller.cs index fdf77c00c..07d7f1be8 100644 --- a/src/Ubiquity.NET.InteropHelpers/LazyEncodedStringMarshaller.cs +++ b/src/Ubiquity.NET.InteropHelpers/LazyEncodedStringMarshaller.cs @@ -1,6 +1,6 @@ // Copyright (c) Ubiquity.NET Contributors. All rights reserved. // Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. - +#if NET7_0_OR_GREATER namespace Ubiquity.NET.InteropHelpers { /// Represents a marshaller for . @@ -84,3 +84,4 @@ public readonly void Free( ) } } } +#endif diff --git a/src/Ubiquity.NET.InteropHelpers/NativeLibraryHandle.cs b/src/Ubiquity.NET.InteropHelpers/NativeLibraryHandle.cs index 6d0ac302c..198fd2915 100644 --- a/src/Ubiquity.NET.InteropHelpers/NativeLibraryHandle.cs +++ b/src/Ubiquity.NET.InteropHelpers/NativeLibraryHandle.cs @@ -1,6 +1,7 @@ // Copyright (c) Ubiquity.NET Contributors. All rights reserved. // Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. +#if NET8_0_OR_GREATER // NativeLibrary only exists in later runtimes (.NET 8 is latest under support) namespace Ubiquity.NET.InteropHelpers { /// Safe handle for a @@ -66,3 +67,4 @@ private NativeLibraryHandle( nint osHandle ) } } } +#endif diff --git a/src/Ubiquity.NET.InteropHelpers/Ubiquity.NET.InteropHelpers.csproj b/src/Ubiquity.NET.InteropHelpers/Ubiquity.NET.InteropHelpers.csproj index a77ca4aea..63a90ff97 100644 --- a/src/Ubiquity.NET.InteropHelpers/Ubiquity.NET.InteropHelpers.csproj +++ b/src/Ubiquity.NET.InteropHelpers/Ubiquity.NET.InteropHelpers.csproj @@ -1,6 +1,6 @@  - net8.0 + net8.0;netstandard2.0 preview enable @@ -27,16 +27,21 @@ snupkg - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + - + @@ -54,4 +59,8 @@ + diff --git a/src/Ubiquity.NET.Llvm.slnx b/src/Ubiquity.NET.Llvm.slnx index e95f8bfbf..ed4dcfdfd 100644 --- a/src/Ubiquity.NET.Llvm.slnx +++ b/src/Ubiquity.NET.Llvm.slnx @@ -80,15 +80,18 @@ + + + diff --git a/src/Ubiquity.NET.Llvm/DebugInfo/DIBuilderAlias.cs b/src/Ubiquity.NET.Llvm/DebugInfo/DIBuilderAlias.cs index 9dccbfcf3..f62d79d9f 100644 --- a/src/Ubiquity.NET.Llvm/DebugInfo/DIBuilderAlias.cs +++ b/src/Ubiquity.NET.Llvm/DebugInfo/DIBuilderAlias.cs @@ -771,7 +771,7 @@ public DIGlobalVariableExpression CreateGlobalVariableExpression( DINode? scope , uint lineNo , DIType? type , bool isLocalToUnit - , DIExpression? value + , DIExpression? expression , DINode? declaration = null , UInt32 bitAlign = 0 ) @@ -791,7 +791,7 @@ public DIGlobalVariableExpression CreateGlobalVariableExpression( DINode? scope , lineNo , type?.Handle ?? default , isLocalToUnit - , value?.Handle ?? default + , expression?.Handle ?? CreateExpression( ).Handle , declaration?.Handle ?? default , bitAlign ); diff --git a/src/Ubiquity.NET.Llvm/DebugInfo/IDIBuilder.cs b/src/Ubiquity.NET.Llvm/DebugInfo/IDIBuilder.cs index 6d0c9e6ae..00cdad3f6 100644 --- a/src/Ubiquity.NET.Llvm/DebugInfo/IDIBuilder.cs +++ b/src/Ubiquity.NET.Llvm/DebugInfo/IDIBuilder.cs @@ -39,7 +39,7 @@ public enum MacroKind /// /// LLVM Source Level Debugging public interface IDIBuilder - : IEquatable + : IEquatable { /// Gets the module associated with this builder /// diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillEncodingExtensions.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillEncodingExtensions.cs new file mode 100644 index 000000000..0f3acb4db --- /dev/null +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillEncodingExtensions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using global::System; +using global::System.Text; + +namespace System.Text +{ + internal static class PolyFillEncodingExtensions + { + public static unsafe string GetString(this Encoding self, ReadOnlySpan bytes) + { + fixed (byte* bytesPtr = bytes) + { + return self.GetString(bytesPtr, bytes.Length); + } + } + } +} diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs new file mode 100644 index 000000000..476003986 --- /dev/null +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs @@ -0,0 +1,334 @@ +// +#nullable enable + +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +// .NET 7 added the various exception static methods for parameter validation +// This will back fill them for earlier versions. +// +// NOTE: C #14 extension keyword support is required to make this work. + +#pragma warning disable IDE0130 // Namespace does not match folder structure + +namespace System +{ + file enum ResourceId + { + None = 0, + Argument_EmptyOrWhiteSpaceString, + ArgumentOutOfRange_Generic_MustBeEqual, + ArgumentOutOfRange_Generic_MustBeGreater, + ArgumentOutOfRange_Generic_MustBeGreaterOrEqual, + ArgumentOutOfRange_Generic_MustBeLess, + ArgumentOutOfRange_Generic_MustBeLessOrEqual, + ArgumentOutOfRange_Generic_MustBeNonNegative, + ArgumentOutOfRange_Generic_MustBeNonNegativeNonZero, + ArgumentOutOfRange_Generic_MustBeNonZero, + ArgumentOutOfRange_Generic_MustBeNotEqual + } + + // Sadly, these are NOT localized messages as the official forms are. + // There is no way, at least no-known way (easy or not) to inject resources + // that would participate in loclization. (If the consumer even does that...) + // The actual strings used are the same as the values in the official runtime + // support so are at least compatible for "en-us". This fakes it to make it + // more readable AND make it easier to shift if a means of injecting resources + // is found. + file static class ResourceIdExtensions + { + internal static string GetResourceString(this ResourceId id) + { + return id switch + { + ResourceId.Argument_EmptyOrWhiteSpaceString => "The value cannot be an empty string or composed entirely of whitespace.", + ResourceId.ArgumentOutOfRange_Generic_MustBeEqual => "{0} ('{1}') must be equal to '{2}'.", + ResourceId.ArgumentOutOfRange_Generic_MustBeGreater => "{0} ('{1}') must be greater than '{2}'.", + ResourceId.ArgumentOutOfRange_Generic_MustBeGreaterOrEqual => "{0} ('{1}') must be greater than or equal to '{2}'.", + ResourceId.ArgumentOutOfRange_Generic_MustBeLess => "{0} ('{1}') must be less than '{2}'.", + ResourceId.ArgumentOutOfRange_Generic_MustBeLessOrEqual => "{0} ('{1}') must be less than or equal to '{2}'.", + ResourceId.ArgumentOutOfRange_Generic_MustBeNonNegative => "{0} ('{1}') must be a non-negative value.", + ResourceId.ArgumentOutOfRange_Generic_MustBeNonNegativeNonZero => "{0} ('{1}') must be a non-negative and non-zero value.", + ResourceId.ArgumentOutOfRange_Generic_MustBeNonZero => "{0} ('{1}') must be a non-zero value.", + ResourceId.ArgumentOutOfRange_Generic_MustBeNotEqual => "{0} ('{1}') must not be equal to '{2}'.", + + _ => throw new global::System.ComponentModel.InvalidEnumArgumentException(nameof(id), (int)id, typeof(ResourceId)) + }; + } + } + + /// poly fill extensions for static methods added in .NET 7 + /// + /// This requires support of the C#14 keyword `extension` to work properly. There is + /// no other way to add static methods to non-partial types for source compatibility. + /// Otherwise code cannot use the modern .NET runtime implementations and instead + /// must always use some extension methods, or litter around a LOT of #if/#else/#endif + /// based on the framework version... + /// + internal static class PolyFillExceptionValidators + { + /// Poly fill Extensions for + extension( global::System.ArgumentException ) + { + /// Throw an if a string is m empty, or all whitepsace. + /// input string to test + /// expression or name of the string to test; normally provided by compiler + /// string is m empty, or all whitepsace + public static void ThrowIfNullOrWhiteSpace( + [global::System.Diagnostics.CodeAnalysis.NotNullAttribute] string? argument, + [global::System.Runtime.CompilerServices.CallerArgumentExpressionAttribute( nameof( argument ) )] string? paramName = null + ) + { + global::System.ArgumentNullException.ThrowIfNull( argument, paramName ); + + // argument is non-null verified by this, sadly older frameworks don't have + // attributes to declare that. + if(string.IsNullOrWhiteSpace( argument )) + { + throw new global::System.ArgumentException( "The value cannot be an empty string or composed entirely of whitespace.", paramName ); + } + } + } + + /// Poly fill Extensions for + extension( global::System.ArgumentNullException ) + { + /// Throws an aexception if the tested argument is + /// value to test + /// expression for the name of the value; normally provided by compiler + /// is + public static void ThrowIfNull( + [global::System.Diagnostics.CodeAnalysis.NotNullAttribute] object? argument, + [global::System.Runtime.CompilerServices.CallerArgumentExpressionAttribute( nameof( argument ) )] string? paramName = default + ) + { + if(argument is null) + { + throw new global::System.ArgumentNullException( paramName ); + } + } + } + + /// Poly fill Extensions for + extension( global::System.ObjectDisposedException ) + { + /// Throws an if is . + /// Condition to determine if the instance is disposed + /// instance that is tested; Used to get type name for exception + /// is + public static void ThrowIf( + [global::System.Diagnostics.CodeAnalysis.DoesNotReturnIfAttribute( true )] bool condition, + object instance + ) + { + if(condition) + { + throw new global::System.ObjectDisposedException( instance?.GetType().FullName ); + } + } + } + + /// Poly fill Extensions for + extension( global::System.ArgumentOutOfRangeException ) + { + /// Throws an if is equal to . + /// The argument to validate as not equal to . + /// The value to compare with . + /// The name of the parameter with which corresponds. + public static void ThrowIfEqual( T value, T other, [global::System.Runtime.CompilerServices.CallerArgumentExpressionAttribute( nameof( value ) )] string? paramName = null ) + where T : IEquatable? + { + if(global::System.Collections.Generic.EqualityComparer.Default.Equals( value, other )) + { + ThrowEqual( value, other, paramName ); + } + } + + /// Throws an if is not equal to . + /// The argument to validate as equal to . + /// The value to compare with . + /// The name of the parameter with which corresponds. + public static void ThrowIfNotEqual( T value, T other, [global::System.Runtime.CompilerServices.CallerArgumentExpressionAttribute( nameof( value ) )] string? paramName = null ) + where T : global::System.IEquatable? + { + if(!global::System.Collections.Generic.EqualityComparer.Default.Equals( value, other )) + { + ThrowNotEqual( value, other, paramName ); + } + } + + /// Throws an if is greater than . + /// The argument to validate as less or equal than . + /// The value to compare with . + /// The name of the parameter with which corresponds. + public static void ThrowIfGreaterThan( T value, T other, [global::System.Runtime.CompilerServices.CallerArgumentExpressionAttribute( nameof( value ) )] string? paramName = null ) + where T : global::System.IComparable + { + if(value.CompareTo( other ) > 0) + { + ThrowGreater( value, other, paramName ); + } + } + + /// Throws an if is greater than or equal . + /// The argument to validate as less than . + /// The value to compare with . + /// The name of the parameter with which corresponds. + public static void ThrowIfGreaterThanOrEqual( T value, T other, [global::System.Runtime.CompilerServices.CallerArgumentExpressionAttribute( nameof( value ) )] string? paramName = null ) + where T : global::System.IComparable + { + if(value.CompareTo( other ) >= 0) + { + ThrowGreaterEqual( value, other, paramName ); + } + } + + /// Throws an if is less than . + /// The argument to validate as greatar than or equal than . + /// The value to compare with . + /// The name of the parameter with which corresponds. + public static void ThrowIfLessThan( T value, T other, [global::System.Runtime.CompilerServices.CallerArgumentExpressionAttribute( nameof( value ) )] string? paramName = null ) + where T : global::System.IComparable + { + if(value.CompareTo( other ) < 0) + { + ThrowLess( value, other, paramName ); + } + } + + /// Throws an if is less than or equal . + /// The argument to validate as greatar than than . + /// The value to compare with . + /// The name of the parameter with which corresponds. + public static void ThrowIfLessThanOrEqual( T value, T other, [global::System.Runtime.CompilerServices.CallerArgumentExpressionAttribute( nameof( value ) )] string? paramName = null ) + where T : global::System.IComparable + { + if(value.CompareTo( other ) <= 0) + { + ThrowLessEqual( value, other, paramName ); + } + } + + [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void ThrowZero( T value, string? paramName ) + { + string msg = string.Format( global::System.Globalization.CultureInfo.CurrentCulture + , ResourceId.ArgumentOutOfRange_Generic_MustBeNonNegative.GetResourceString() + , paramName + , value + ); + + throw new global::System.ArgumentOutOfRangeException( paramName, value, msg ); + } + + [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void ThrowNegative( T value, string? paramName ) + { + string msg = string.Format( global::System.Globalization.CultureInfo.CurrentCulture + , ResourceId.ArgumentOutOfRange_Generic_MustBeNonZero.GetResourceString() + , paramName + , value + ); + + throw new global::System.ArgumentOutOfRangeException( paramName, value, msg ); + } + + [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void ThrowNegativeOrZero( T value, string? paramName ) + { + string msg = string.Format( global::System.Globalization.CultureInfo.CurrentCulture + , ResourceId.ArgumentOutOfRange_Generic_MustBeNonNegativeNonZero.GetResourceString() + , paramName + , value + ); + + throw new global::System.ArgumentOutOfRangeException( paramName, value, msg ); + } + + [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void ThrowGreater( T value, T other, string? paramName ) + { + var msg = string.Format( + global::System.Globalization.CultureInfo.CurrentCulture, + ResourceId.ArgumentOutOfRange_Generic_MustBeLessOrEqual.GetResourceString(), + paramName, + value, + other + ); + + throw new global::System.ArgumentOutOfRangeException( paramName, value, msg); + } + + [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void ThrowGreaterEqual( T value, T other, string? paramName ) + { + + var msg = string.Format( + global::System.Globalization.CultureInfo.CurrentCulture, + ResourceId.ArgumentOutOfRange_Generic_MustBeLess.GetResourceString(), + paramName, + value, + other + ); + + throw new global::System.ArgumentOutOfRangeException( paramName, value, msg ); + } + + [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void ThrowLess( T value, T other, string? paramName ) + { + var msg = string.Format( + global::System.Globalization.CultureInfo.CurrentCulture, + ResourceId.ArgumentOutOfRange_Generic_MustBeGreaterOrEqual.GetResourceString(), + paramName, + value, + other + ); + + throw new global::System.ArgumentOutOfRangeException( paramName, value, msg ); + } + + [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void ThrowLessEqual( T value, T other, string? paramName ) + { + var msg = string.Format( + global::System.Globalization.CultureInfo.CurrentCulture, + ResourceId.ArgumentOutOfRange_Generic_MustBeGreater.GetResourceString(), + paramName, + value, + other + ); + + throw new global::System.ArgumentOutOfRangeException( paramName, value, msg ); + } + + [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void ThrowEqual( T value, T other, string? paramName ) + { + var msg = string.Format( + global::System.Globalization.CultureInfo.CurrentCulture, + ResourceId.ArgumentOutOfRange_Generic_MustBeNotEqual.GetResourceString(), + paramName, + value, + other + ); + + throw new global::System.ArgumentOutOfRangeException( paramName, value, msg); + } + + [global::System.Diagnostics.CodeAnalysis.DoesNotReturn] + private static void ThrowNotEqual( T value, T other, string? paramName ) + { + var msg = string.Format( + global::System.Globalization.CultureInfo.CurrentCulture, + ResourceId.ArgumentOutOfRange_Generic_MustBeEqual.GetResourceString(), + paramName, + value, + other + ); + + throw new global::System.ArgumentOutOfRangeException( paramName, value, msg ); + } + } + } +} diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillMemoryMarshal.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillMemoryMarshal.cs new file mode 100644 index 000000000..59818985a --- /dev/null +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillMemoryMarshal.cs @@ -0,0 +1,42 @@ +// +#nullable enable + +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +#pragma warning disable IDE0130 // Namespace does not match folder structure +#pragma warning disable CS3021 // Type or member does not need a CLSCompliant attribute because the assembly does not have a CLSCompliant attribute + +namespace System.Runtime.InteropServices +{ + /// Extensions for to allow downlevel compatibility + internal static class PolyFillMemoryMarshal + { + extension(global::System.Runtime.InteropServices.MemoryMarshal) + { + /// Creates a new read-only span for a null-terminated UTF-8 string. + /// The pointer to the null-terminated string of bytes. + /// A read-only span representing the specified null-terminated string, or an empty span if the pointer is null. + /// The returned span does not include the null terminator, nor does it validate the well-formedness of the UTF-8 data. + /// The string is longer than . + [global::System.CLSCompliant(false)] + public static unsafe global::System.ReadOnlySpan CreateReadOnlySpanFromNullTerminated(byte* value) + { + return value != null + ? new global::System.ReadOnlySpan(value, StrLen(value)) + : default; + } + } + + private static unsafe int StrLen(byte* p) + { + // Crude but functional - definately NOT perf optimized. + int indexOfTerminator = 0; + for(; *p != 0; ++p, ++indexOfTerminator) + { + } + + return indexOfTerminator; + } + } +} diff --git a/src/Ubiquity.NET.Extensions/PolyFillOperatingSystem.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillOperatingSystem.cs similarity index 73% rename from src/Ubiquity.NET.Extensions/PolyFillOperatingSystem.cs rename to src/Ubiquity.NET.PollyFill.SharedSources/PolyFillOperatingSystem.cs index 8ddbb6e49..c7b1a834f 100644 --- a/src/Ubiquity.NET.Extensions/PolyFillOperatingSystem.cs +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillOperatingSystem.cs @@ -1,7 +1,8 @@ -// Copyright (c) Ubiquity.NET Contributors. All rights reserved. -// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. +// +#nullable enable -#if !NET5_0_OR_GREATER +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. #pragma warning disable IDE0130 // Namespace does not match folder structure @@ -9,17 +10,17 @@ namespace System { /// Poly fill extensions for /// - [SuppressMessage( "Design", "CA1034:Nested types should not be visible", Justification = "Extension" )] - public static class PolyFillOperatingSystem + [global::System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1034: Nested types should not be visible", Justification = "extension, broken analyzer")] + internal static class PolyFillOperatingSystem { /// Poly fill Extensions for - extension( OperatingSystem ) + extension(global::System.OperatingSystem) { /// Indicates whether the current application is running on Windows. /// if the current application is running on Windows; otherwise. - public static bool IsWindows( ) + public static bool IsWindows() { - return Environment.OSVersion.Platform switch + return global::System.Environment.OSVersion.Platform switch { PlatformID.Win32S or PlatformID.Win32Windows or @@ -35,4 +36,3 @@ PlatformID.Win32NT or } } } -#endif diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillRuntimeHelpersExtensions.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillRuntimeHelpersExtensions.cs new file mode 100644 index 000000000..6698c72d6 --- /dev/null +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillRuntimeHelpersExtensions.cs @@ -0,0 +1,64 @@ +// +#nullable enable + +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +#if COMPILER_LOOKS_FOR_EXTENSIONS +// based on work from: https://github.com/Sergio0694/PolySharp/issues/104 + +#pragma warning disable IDE0130 // Namespace does not match folder structure + +namespace System.Runtime.CompilerServices +{ + /// Poly Fill Extensions to + [global::System.Diagnostics.CodeAnalysis.SuppressMessage("Design","CA1034: Nested types should not be visible", Justification = "extension, broken analyzer")] + internal static class PolyFillRuntimeHelpersExtensions + { + /// Poly Fill Extensions to + extension( global::System.Runtime.CompilerServices.RuntimeHelpers ) + { + /// + /// Slices the specified array using the specified range. + /// Adapted from source: https://source.dot.net/#System.Private.CoreLib/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.cs. + /// Required for be able to use `array[10..20] ` syntax in .NET Standard 2.0 and net48. + /// + public static T[] GetSubArray( T[] array, global::System.Range range ) + { + global::System.ArgumentNullException.ThrowIfNull(array); + (int offset, int length) = range.GetOffsetAndLength( array.Length ); + + if(length == 0) + { + return []; + } + + T[] dest; + + if(typeof( T[] ) == array.GetType()) + { + // We know the type of the array to be exactly T[]. + dest = new T[ length ]; + } + else + { + // exception should never hit since array is T[], but this keeps compiler/analyzers happy + global::System.Type elementType = array.GetType().GetElementType() ?? throw new global::System.InvalidOperationException("element type is not known!"); + + // The array is actually a U[] where U:T. We'll make sure to create + // an array of the exact same backing type. The cast to T[] will + // never fail. + dest = (T[])global::System.Array.CreateInstance( elementType, length ); + } + + // In either case, the newly-allocated array is the exact same type as the + // original incoming array. It's safe for us to Array.Copy the contents + // from the source array to the destination array, otherwise the contents + // wouldn't have been valid for the source array in the first place. + global::System.Array.Copy( array, offset, dest, 0, length ); + return dest; + } + } + } +} +#endif diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs new file mode 100644 index 000000000..7c5a0e173 --- /dev/null +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs @@ -0,0 +1,91 @@ +// +#nullable enable + +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +// from .NET sources +// see: https://github.com/dotnet/runtime/blob/1d1bf92fcf43aa6981804dc53c5174445069c9e4/src/libraries/System.Private.CoreLib/src/System/String.Manipulation.cs + +#pragma warning disable IDE0130 // Namespace does not match folder structure + +using global::System.Diagnostics; + +namespace System +{ + /// Pollyfill extensions for support not present in older runtimes + /// + internal static class PolyFillStringExtensions + { + extension(string) + { + /// Concatenates the members of a collection, using the specified separator between each member. + /// The type of the members of values. + /// The character to use as a separator. separator is included in the returned string only if values has more than one element. + /// A collection that contains the objects to concatenate. + /// + /// A string that consists of the members of values delimited by the separator character. + /// -or- System.String.Empty if values has no elements. + /// + /// is + /// The length of the resulting string overflows the maximum allowed length (). + public static string Join( char separator, IEnumerable values ) + { + return string.Join(separator.ToString(), values); + } + } + + public static int GetHashCode( this string self, StringComparison comparisonType ) + { + if(comparisonType != StringComparison.Ordinal) + { + throw new global::System.ComponentModel.InvalidEnumArgumentException(nameof(comparisonType), (int)comparisonType, typeof(StringComparison)); + } + + return self.GetHashCode(); + } + + /// Replace line endings in the string with environment specific forms + /// string to change line endings for + /// string with environment specific line endings + public static string ReplaceLineEndings(this string self) + { + return ReplaceLineEndings(self, global::System.Environment.NewLine); + } + + // This is NOT the most performant implementation, it's going for simplistic pollyfill that has + // the correct behavior, even if not the most performant. If performance is critical, use a + // later version of the runtime! + + /// Replace line endings in the string with a given string + /// string to change line endings for + /// Text to replace all of the line endings in + /// string with line endings replaced by + [global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + public static string ReplaceLineEndings(this string self, string replacementText) + { + global::System.ArgumentNullException.ThrowIfNull(self); + global::System.ArgumentNullException.ThrowIfNull(replacementText); + + string retVal = UnicodeNewLinesRegEx.Replace(self, replacementText); + + // if the result of replacement is the same, just return the original + // This is wasted overhead, but at least matches the behavior + return self == retVal ? self : retVal; + } + + // The Unicode Standard, Sec. 5.8, Recommendation R4 and Table 5-2 state that the CR, LF, + // CRLF, NEL, LS, FF, and PS sequences are considered newline functions. That section + // also specifically excludes VT from the list of newline functions, so we do not include + // it in the regular expression match. + + // language=regex + private const string UnicodeNewLinesRegExPattern = @"(\r\n|\r|\n|\f|\u0085|\u2028|\u2029)"; + + // NOTE: can't use source generated RegEx here as there's no way to declare the depency on + // the output of one generator as the input for another. They all see the same input, therefore + // the partial implementation would never be filled in and produces a compilation error instead. + private static global::System.Text.RegularExpressions.Regex UnicodeNewLinesRegEx { get; } + = new global::System.Text.RegularExpressions.Regex(UnicodeNewLinesRegExPattern); + } +} diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/ReadMe.md b/src/Ubiquity.NET.PollyFill.SharedSources/ReadMe.md new file mode 100644 index 000000000..8ec41a65f --- /dev/null +++ b/src/Ubiquity.NET.PollyFill.SharedSources/ReadMe.md @@ -0,0 +1,7 @@ +# About +This library contains extensions that are shared amongst multiple additional projects. This, +currently takes the place of a source generator that would inject these types. The problem +with a Roslyn source generator for this is that the "generated" sources have a dependency on +types that are poly filled by a diffent source generator. Source generators all see the same +input and therefore a source generator is untestable without solving the problem of +explicilty generating the sources for the poly filled types. diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/Ubiquity.NET.PollyFill.SharedSources.projitems b/src/Ubiquity.NET.PollyFill.SharedSources/Ubiquity.NET.PollyFill.SharedSources.projitems new file mode 100644 index 000000000..de344bbfa --- /dev/null +++ b/src/Ubiquity.NET.PollyFill.SharedSources/Ubiquity.NET.PollyFill.SharedSources.projitems @@ -0,0 +1,19 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + 9fb998e5-6d78-4129-8022-9bb39fea81f2 + + + Ubuiquity.NET.PollyFill.SharedSources + + + + + + + + + + \ No newline at end of file diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/Ubiquity.NET.PollyFill.SharedSources.shproj b/src/Ubiquity.NET.PollyFill.SharedSources/Ubiquity.NET.PollyFill.SharedSources.shproj new file mode 100644 index 000000000..3ce6efc70 --- /dev/null +++ b/src/Ubiquity.NET.PollyFill.SharedSources/Ubiquity.NET.PollyFill.SharedSources.shproj @@ -0,0 +1,16 @@ + + + + 9fb998e5-6d78-4129-8022-9bb39fea81f2 + 14.0 + + + + + + + + + + + diff --git a/src/Ubiquity.NET.Runtime.Utils/Ubiquity.NET.Runtime.Utils.csproj b/src/Ubiquity.NET.Runtime.Utils/Ubiquity.NET.Runtime.Utils.csproj index a1be12f68..7d41df232 100644 --- a/src/Ubiquity.NET.Runtime.Utils/Ubiquity.NET.Runtime.Utils.csproj +++ b/src/Ubiquity.NET.Runtime.Utils/Ubiquity.NET.Runtime.Utils.csproj @@ -1,6 +1,6 @@  - net8.0;net9.0 + net8.0 enable preview @@ -28,6 +28,7 @@ + diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/SourceGeneratorTest.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/SourceGeneratorTest.cs new file mode 100644 index 000000000..9a385f150 --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/CSharp/SourceGeneratorTest.cs @@ -0,0 +1,35 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Testing; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils.CSharp +{ + /// Source generator tests for C# that allows specification of the language + /// Source generator type + /// Verifier type + public class SourceGeneratorTest + : Microsoft.CodeAnalysis.CSharp.Testing.CSharpSourceGeneratorTest + where TSourceGenerator : new() + where TVerifier : IVerifier, new() + { + /// Initializes a new instance of the class. + /// Version of the language for this test + public SourceGeneratorTest(LanguageVersion ver) + { + LanguageVersion = ver; + } + + /// + /// Creates parse options + protected override ParseOptions CreateParseOptions( ) + { + // TODO: until C# 14 is formally released, this is "preview" + return ((CSharpParseOptions)base.CreateParseOptions()).WithLanguageVersion(LanguageVersion); + } + + private readonly LanguageVersion LanguageVersion; + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/EnumerableObjectComparer.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/EnumerableObjectComparer.cs new file mode 100644 index 000000000..e0fe6aa52 --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/EnumerableObjectComparer.cs @@ -0,0 +1,37 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils +{ + /// Comparer to test an array (element by element) for equality + public class EnumerableObjectComparer + : IEqualityComparer> + { + /// + [SuppressMessage( "StyleCop.CSharp.NamingRules", "SA1305:Field names should not use Hungarian notation", Justification = "xValues and yValues are not hungarioan names" )] + public bool Equals(IEnumerable? x, IEnumerable? y) + { + ArgumentNullException.ThrowIfNull(x); + ArgumentNullException.ThrowIfNull(y); + var xValues = x.ToImmutableArray(); + var yValues = x.ToImmutableArray(); + return xValues.Length == yValues.Length + && xValues.Zip(yValues, (a, b) => a.Equals(b)).All(x => x); + } + + /// + public int GetHashCode([DisallowNull] IEnumerable obj) + { + return obj.GetHashCode(); + } + + /// Default constructed comparer. + public static readonly EnumerableObjectComparer Default = new(); + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/GeneratorDriverExtensions.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/GeneratorDriverExtensions.cs new file mode 100644 index 000000000..13b4f0c83 --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/GeneratorDriverExtensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System; +using System.Collections.Immutable; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils +{ + /// Utility class for to add extensions for testing + public static class GeneratorDriverExtensions + { + /// Runs a source generator twice and validates the results + /// Driver to use for the run + /// Compilation to use for the run + /// Array of names to filter all of the internal tracking names + /// Results of first run + /// + /// This will run the generator twice. The value of results are tested for + /// any banned types and are additionally tested to ensure the expected + /// tracking names are found and only use cached results on the second run. + /// + public static GeneratorDriverRunResult RunGeneratorAndAssertResults( + this GeneratorDriver driver, + CSharpCompilation compilation, + ImmutableArray trackingNames + ) + { + ArgumentOutOfRangeException.ThrowIfLessThan(trackingNames.Length, 1, nameof(trackingNames)); + + var compilationClone = compilation.Clone(); + + // save the resulting immutable driver for use in second run. + driver = driver.RunGenerators(compilation); + GeneratorDriverRunResult runResult1 = driver.GetRunResult(); + GeneratorDriverRunResult runResult2 = driver.RunGenerators(compilationClone) + .GetRunResult(); + Assert.That.AreEqual(runResult1, runResult2, trackingNames); + Assert.That.Cached(runResult2); + return runResult1; + } + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/GeneratorDriverRunResultExtensions.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/GeneratorDriverRunResultExtensions.cs new file mode 100644 index 000000000..8f3476888 --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/GeneratorDriverRunResultExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +// based on blog code (but heavily modified): +// https://andrewlock.net/creating-a-source-generator-part-10-testing-your-incremental-generator-pipeline-outputs-are-cacheable/ + +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +using Microsoft.CodeAnalysis; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils +{ + /// Static class with extensions/helpers for testing incremental source generators + public static class GeneratorDriverRunResultExtensions + { + /// Extension method for a to Get all of the tracked steps matching a name contained in the input + /// Results to get steps from + /// Set of names to get from + /// Dictionary to map the name to any run steps for that name + [SuppressMessage("Style", "IDE0046:Convert to conditional expression", Justification = "Significantly less readable if applied")] + public static ImmutableDictionary> GetTrackedSteps( + this GeneratorDriverRunResult runResult, + ImmutableArray trackingNames + ) + { + if (trackingNames.Length == 0) + { + return ImmutableDictionary>.Empty; + } + + return runResult.Results[0] + .TrackedSteps + .Where(step => trackingNames.Contains(step.Key)) + .ToImmutableDictionary(v => v.Key, v => v.Value); + } + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/MsTestAssertExtensions.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/MsTestAssertExtensions.cs new file mode 100644 index 000000000..a16becd9b --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/MsTestAssertExtensions.cs @@ -0,0 +1,230 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Reflection; + +using Microsoft.CodeAnalysis; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils +{ + // NOTE: due to bug https://github.com/dotnet/roslyn/issues/78042 this is not convertible to + // the new 'extension' keyword. The problem is in the compiler generated lambda for the LINQ + // expressions. Fortunately, the "old" syntax still works fine... + + /// Utility class to implement extensions for + public static class MsTestAssertExtensions + { + /// Extension method for use with to validate two are equivalent + /// Unused, provides extension support + /// Results of first run + /// Results of second run + /// Names of custom tracking steps to validate + public static void AreEqual( + this Assert _, + GeneratorDriverRunResult r1, + GeneratorDriverRunResult r2, + ImmutableArray trackingNames + ) + { + var trackedSteps1 = r1.GetTrackedSteps(trackingNames); + var trackedSteps2 = r2.GetTrackedSteps(trackingNames); + + // Assert the static requirements + Assert.AreNotEqual(0, trackedSteps1.Count, "Should not be an empty set of steps matching tracked names"); + Assert.HasCount( trackedSteps1.Count, trackedSteps2, "Both runs should have same number of tracked steps"); + bool hasSameKeys = trackedSteps1.Zip(trackedSteps2, (s1, s2) => trackedSteps2.ContainsKey(s1.Key) && trackedSteps1.ContainsKey(s2.Key)) + .All(x => x); + Assert.IsTrue(hasSameKeys, "Both sets of runs should have the same keys"); + + // loop through all KVPs of name to step in result set 1 + // assert that the second run steps for the same tracking name are equal. + foreach (var (trackingName, runSteps1) in trackedSteps1) + { + var runSteps2 = trackedSteps2[trackingName]; + Assert.That.AreEqual(runSteps1, runSteps2, trackingName); + } + } + + /// + /// Extension method for use with to validate each member of a pair of + /// are equivalent. + /// + /// Unused, provides extension support + /// Array of steps to test against + /// Array of steps to assert are equal to the elements of + /// Tracking name of the step for use in diagnostic messages + /// + /// This uses the built-in extensibility point to perform asserts on + /// each member of the input arrays. Each is tested for equality and this only passes + /// if ALL members are equal. + /// + public static void AreEqual( + this Assert _, + ImmutableArray steps1, + ImmutableArray steps2, + string stepTrackingName + ) + { + Assert.HasCount( steps1.Length, steps2, "Step lengths should be equal"); + for (int i = 0; i < steps1.Length; ++i) + { + var runStep1 = steps1[i]; + var runStep2 = steps2[i]; + + IEnumerable outputs1 = runStep1.Outputs.Select(x => x.Value); + IEnumerable outputs2 = runStep2.Outputs.Select(x => x.Value); + + Assert.AreEqual(outputs1, outputs2, EnumerableObjectComparer.Default, $"{stepTrackingName} should produce cacheable outputs"); + Assert.That.OutputsCachedOrUnchanged(runStep2, stepTrackingName); + Assert.That.ObjectGraphContainsValidSymbols(runStep1, stepTrackingName); + } + } + + /// Extension method for use with to assert all of the tracked output steps are cached + /// Unused, provides extension support + /// Run results to test for cached outputs + public static void Cached(this Assert _, GeneratorDriverRunResult driverRunResult) + { + // verify the second run only generated cached source outputs + var uncachedSteps = from generatorRunResult in driverRunResult.Results + from trackedStepKvp in generatorRunResult.TrackedOutputSteps + from runStep in trackedStepKvp.Value // name is used in select if condition passes + from valueReasonTuple in runStep.Outputs // all outputs must have a cached reason. + where valueReasonTuple.Reason != IncrementalStepRunReason.Cached + select runStep.Name; + + foreach (string stepTrackingName in uncachedSteps) + { + Assert.Fail($"Step name {stepTrackingName ?? ""} contains uncached results for second run!"); + } + } + + /// Extension method for use with to validate that an object is not of a banned type + /// [ignored] syntactic sugar for extension method + /// object node to test + /// reason message for any failures + /// parameters for construction of any exceptions + public static void NotBannedType( + this Assert _, + object? node, + [StringSyntax(StringSyntaxAttribute.CompositeFormat)] string message, + params string[] parameters + ) + { + // can't validate anything for the type of a null + if (node is not null) + { + string msg = string.Format(CultureInfo.CurrentCulture, message, parameters); + + // While this is not a comprehensive list. it covers the most common mistakes directly + Assert.IsNotInstanceOfType(node, msg); + Assert.IsNotInstanceOfType(node, msg); + Assert.IsNotInstanceOfType(node, msg); + } + } + + /// + /// Extension method for use with to validate that all + /// are either or + /// + /// Unused, provides extension support + /// Step of the run to test + /// Tracking name to use in assertion messages on failures + public static void OutputsCachedOrUnchanged( + this Assert _, + IncrementalGeneratorRunStep runStep, + string stepTrackingName + ) + { + Assert.IsFalse( + runStep.Outputs.Any(x => x.Reason != IncrementalStepRunReason.Cached && x.Reason != IncrementalStepRunReason.Unchanged), + $"{stepTrackingName} should have only cached or unchanged reasons!" + ); + } + + /// Extension method to validate that the output of a doesn't use any banned types. + /// [Ignored] + /// Run step to validate + /// Name of the step to aid in diagnostics + /// + /// + /// It is debatable if this should be used in a test or an analyzer. In a test it is easy + /// to omit from the tests (or not test at all in early development cycles). + /// An analyzer can operate as you type code in the editor or when you compile the code so + /// has a greater chance of catching erroneous use. Unfortunately no such analyzer exists + /// as of yet. [It's actually hard to define the rules an analyzer should follow]. So this + /// will do the best it can for now... + /// + public static void ObjectGraphContainsValidSymbols( + this Assert _, + IncrementalGeneratorRunStep runStep, + string stepTrackingName + ) + { + // Including the stepTrackingName in error messages to make it easier to isolate issues + const string because = "Step shouldn't contain banned symbols or non-equatable types. [{0}; {1}]"; + var visited = new HashSet(); + + // Check all of the outputs - probably overkill, but why not + foreach (var (obj, _) in runStep.Outputs) + { + Visit(obj, visited, because, stepTrackingName, runStep.Name ?? ""); + } + + // Private static function to recursively validate an object is cacheable + static void Visit( + object? node, + HashSet visitedNodes, + [StringSyntax(StringSyntaxAttribute.CompositeFormat)] string message, + params string[] parameters + ) + { + // If we've already seen this object, or it's null, stop. + if (node is null || !visitedNodes.Add(node)) + { + return; + } + + Assert.That.NotBannedType(node, message, parameters); + + // Skip basic types and anything equatable, this includes + // any equatable collections such as EquatableArray as + // that implies all elements are equatable already. + // For now equatable type skipping is disabled as testing for + // that is complex... + Type type = node.GetType(); + if (type.IsBasicType() /*|| type.IsEquatable()*/) + { + return; + } + + // If the object is a collection, check each of the values + if (node is IEnumerable collection and not string) + { + foreach (object element in collection) + { + // recursively check each element in the collection + Visit(element, visitedNodes, message, parameters); + } + } + else + { + // Recursively check each field in the object + foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + object? fieldValue = field.GetValue(node); + Visit(fieldValue, visitedNodes, message, parameters); + } + } + } + } + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/ReflectionTypeExtensions.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/ReflectionTypeExtensions.cs new file mode 100644 index 000000000..48a97d8ca --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/ReflectionTypeExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System; + +namespace Ubiquity.NET.SourceGenerator.Test.Utils +{ + /// Utility extensions for runtime reflection types + public static class ReflectionTypeExtensions + { + /// Simple type test to determine if a type is a basic type + /// Type to test + /// if the type is a basic type; if not + /// + /// A basic type for the purposes of this test is one that is a primitive, enum, or string. + /// + public static bool IsBasicType(this Type t) + { + return t.IsPrimitive + || t.IsEnum + || t == typeof(string); + } + +#if USE_ISEQUATABLE + /// Tests if a type is equatable + /// Type to tests + /// if the type is equatable; if not + /// + /// The definition of equatable is not fully understood, so at present this ALWAYS returns false. + /// However, this acts as a place holder for when it is determined how to accomplish this. Detection + /// of equatable is a test optimization so does not impact the correctness of a test - only the cost + /// to run it. + /// + public static bool IsEquatable(this Type _) + { + // TODO: Figure out how to validate that node implements IEquatable where T is + // some type in the object hierarchy of 'node'. In a test this would require + // full reflection of the type of node to get the full hierarchy and then + // test that an implementation of IEquatable exists for that type. Ideally + // this should go in deepest hierarchy first ordering as it is most likely + // implemented at the lowest layer for an immediate base type. (Though + // technically it could be at any level, that's the most likely case so, for + // efficiency, test it first) + // For now, just assume it isn't and skip the optimization... + return false; + } +#endif + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/ResourceSourceTextDictionary.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/ResourceSourceTextDictionary.cs new file mode 100644 index 000000000..cdb729570 --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/ResourceSourceTextDictionary.cs @@ -0,0 +1,119 @@ +// Copyright (c) Ubiquity.NET Contributors. All rights reserved. +// Licensed under the Apache-2.0 WITH LLVM-exception license. See the LICENSE.md file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Reflection; +using System.Resources; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +#pragma warning disable SA1316 // Tuple element names should use correct casing + +namespace Ubiquity.NET.SourceGenerator.Test.Utils +{ + /// Dictionary to map a resource name to information about expected generated files + /// Type of the generator + public class ResourceSourceTextDictionary + : IReadOnlyDictionary + where TGenerator : IIncrementalGenerator + { + /// Initializes a new instance of the class. + /// Assembly that contains the resources + /// Transform function to convert the resource name into a generated file name + /// + /// If is then the default is to use a C# transformation + /// that assumes the input resource name is a file name and extracts the file name (without extension) and adds + /// a '.g.cs' to the end. This is the most common, if a test requires something different then it can provide + /// a transformation function for + /// + public ResourceSourceTextDictionary( Func? nameTransform = null) + { + GeneratorAssembly = typeof(TGenerator).Assembly; + NameTransform = nameTransform ?? CSharpNameTransform; + } + + /// Gets the containing assembly for the resources + public Assembly GeneratorAssembly { get; } + + /// Adds a named resource to the map + /// name of the resource in the assembly + /// This instance for fluent use + /// resource is missing in the resources (programming error) + public ResourceSourceTextDictionary Add(string resourceName) + { + // {GeneratorAssemblyName}\{Generator FQN}\{GeneratorNamespace}.{HintName} + // Is this the assembly name or the namespace name? (In first use case they are the same) + // It makes sense that Roslyn generators use the assembly name. + string generatorAssemblyName = GeneratorAssembly.GetName().Name ?? throw new InvalidOperationException("Internal Error: Generator assembly has no name!"); + string generatorFQN = typeof(TGenerator).FullName ?? throw new InvalidOperationException("Internal Error: Generator type has no FQN!"); + string generatorNamespace = typeof(TGenerator).Namespace ?? throw new InvalidOperationException("Internal Error: Generator type has no namespace!"); + string fullResourceName = string.Join('.', generatorNamespace, resourceName); + string fullGeneratedSource = Path.Combine(generatorAssemblyName, generatorFQN,generatorNamespace, resourceName); + using Stream stream = GeneratorAssembly.GetManifestResourceStream(fullResourceName) + ?? throw new MissingManifestResourceException($"GENERATOR BUG: Missing resource '{fullResourceName}'"); + + string expectedGeneratedName = NameTransform(fullGeneratedSource); + var srcTxt = SourceText.From(stream, throwIfBinaryDetected: true, canBeEmbedded: true); + InnerDictionary.Add(resourceName, (expectedGeneratedName, srcTxt)); + return this; + } + + /// Adds a resource file name and information + /// Name of the manifest resource + /// Generated file information for the resource + public void Add(string resourceName, (string filename, SourceText content) generatedFile) + { + InnerDictionary.Add(resourceName, generatedFile); + } + + /// + public (string filename, SourceText content) this[ string key ] => InnerDictionary[ key ]; + + /// + public IEnumerable Keys => InnerDictionary.Keys; + + /// + public IEnumerable<(string filename, SourceText content)> Values => InnerDictionary.Values; + + /// + public int Count => InnerDictionary.Count; + + /// + public bool ContainsKey( string key ) + { + return InnerDictionary.ContainsKey( key ); + } + + /// + public IEnumerator> GetEnumerator( ) + { + return InnerDictionary.GetEnumerator(); + } + + /// + public bool TryGetValue( string key, [MaybeNullWhen( false )] out (string filename, SourceText content) value ) + { + return InnerDictionary.TryGetValue( key, out value ); + } + + /// + IEnumerator IEnumerable.GetEnumerator( ) + { + return InnerDictionary.GetEnumerator(); + } + + private readonly Func NameTransform; + + private readonly Dictionary InnerDictionary = []; + + private string CSharpNameTransform(string resourceName) + { + return $"{Path.GetDirectoryName(resourceName)}.{Path.GetFileNameWithoutExtension(resourceName)}.g.cs"; + } + } +} diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/Ubiquity.NET.SourceGenerator.Test.Utils.csproj b/src/Ubiquity.NET.SourceGenerator.Test.Utils/Ubiquity.NET.SourceGenerator.Test.Utils.csproj new file mode 100644 index 000000000..24020f75a --- /dev/null +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/Ubiquity.NET.SourceGenerator.Test.Utils.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + + + + + + + + + + + + + + + + + + diff --git a/src/Ubiquity.NET.SrcGeneration/Ubiquity.NET.SrcGeneration.csproj b/src/Ubiquity.NET.SrcGeneration/Ubiquity.NET.SrcGeneration.csproj index 4f41ecf3a..79ba81404 100644 --- a/src/Ubiquity.NET.SrcGeneration/Ubiquity.NET.SrcGeneration.csproj +++ b/src/Ubiquity.NET.SrcGeneration/Ubiquity.NET.SrcGeneration.csproj @@ -7,6 +7,7 @@ preview True True + True true @@ -37,4 +38,15 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + +