From 3c13166a952d052f576c1e253dc76838af4540fa Mon Sep 17 00:00:00 2001 From: Steven Maillet Date: Fri, 9 Jan 2026 12:51:33 -0800 Subject: [PATCH 1/4] # Updates in prep for code gen >[!IMPORTANT] > Some of these changes are breaking changes that require a major version bump. ## General repository updates * Moved editorconfig and global.json to same folder as the solution. - Despite what is implied in the docs, these are only recognized when they exist at the same level of the directory structure as the solution it applies to. * Added `GeneratePathProperty` to all packagereferences so that is automatic now. - It appears that it is the default whether that is set or not. The default behavior in the project system might handle explicitly setting it to `false`. This makes it consistently on even if the default is normally off. * Updated `NuGet.config` to leverage source mapping and force all generated packages to come from the build output location and never from nuget.org or any other source. ## Ubiquity.NET.CodeAnalysis.Utils * [Potentially Breaking Change] Added reference from `Ubiquity.NET.CodeAnalysis.Utils` to `Ubiquity.NET.Extensions` * Added `AttributeDataExtensions` to provide common usage extensions for `AttributeData` * Cleaned up implementation of `BaseTypeDeclarationSyntaxExtensions.GetNestedClassName` * Added DebugAssert extension to validate the size of a struct is within the recommended limits. - If a struct is too large there's a very good chance that it has more overhead than if it was a class. * [Breaking change] Updated `DiagnosticInfo` to use an ImmutableArray as the member and apply structural equality on it. - This ensures that two instances that have the same contents are considered equal. Previously it was only a shallow compare so tow diagnostics with the same contents for `Params` ended up comparing as not equal even though they were equal. * [Breaking Change] Added Structural equality behavior to EquatableArray. - Also clarified docs that use of this type is rather limited. It's primary goal is to add structural equatability as the behavior behind `IEquatable` which is normally just a shallow equality or reference equality. When an array is captured as a member of something else, than an `ImmutableArray` should be used directly. The primary reason this isn't obsolete is for a top level array that is a direct result of a parse/analysis. Capturing it into `EquatableArray` ensures correct caching behavior. * Added EquatableAttributeData to capture attribute data in the analysis phase. * Added `EquatableAttributeDataCollection` * This is essentially a `KeyedCollection` that uses the `EquatableAttributeData.Name` as the key AND supports structural equality. * Added `EquatableDictionary` that is a struct to wrap a `Dictionary` with structural equality. This is a simple struct that consume no size beyond that of the managed ref to the wrapped dictionary. It is only providing alternate behavior. * Added `NamespaceQualifiedNameFormatter` as a general type to handle custom formatting of `NamespaceWualifiedName`. - This type is generalized to allow language specific implementations with only the global prefix and alias maps as unique per language. - Added static CSharp, using singleton pattern for the language as only one actual instance is needed. * [Breaking Change] Added Structural Equality support to `NestedClassName` * [Breaking Change] Changed type of `Result.Diagnostics` to `ImmutableArray` * Added `StructurallyEquatableTypedConstant` - This is a simple wrapper around `TypedConstant` to provide structural equality for array constants. These are most likely used in the constructor or named arguments of an Attribute. * Added `StructuralTypedConstantComparer` to perform structural comparisons of `TypedConstant` values. * Added `SymbolExtensions` utility class to host extension methods for an `ISymbol`. Particularly around finding/capturing attribute data attached to a symbol. * Added `TypedConstantExtensions` utility class to host extension methods for `TypedConstant`. - Specifically to test if a given constant is a non-null array. * Added `TypeSymbolExtensions` utility class to host extensions for a `ITypeSymbol`. - Specifically to gather or determine the name elements. - Reverse walk of the hierarchy is NOT performed until the sequence of namespace names is enumerated. This helps reduce the cost until it is needed. ## Ubiquity.NET.CommandLine * [Breaking Change] changed the name of `CmdLineSettings` to `CommandLineSettings` for consistency. * [Breaking Change] - Split `ICommandLineOptions` into two distinct interfaces. - `ICommandBinder` provides binding support of the results of a parse - `IRootCommandBuilder` provides support for building the application root command. - This paves the way for generators and, in particular, that every sub-command is NOT a root command. That is, a sub-command, when supported, does not implement IRooCommandBuilder as it isn't a root command. * Obsoleted the `ArgsParsing.TryParse` as the use of this with it's odd return code behavior was sub-optimal leading to confusion and incorrect use that was difficult to understand. Callers should use the `RootCommandExtensions.ParseAndInvokeResult` overloads instead. ## Ubiquity.NET.Extensions * Added `ICustomFormatter` interface to the extensions support. - This provides an explicit type formatter that custom formatting can query of an `IFormatter` instance to know that a particular type format is available * Added `ImmutableArrayExtensions` utility class to host extension to `ImmutableArray` ## Ubiquity.NET.SrcGeneration * Added AsLiteral methods to the `CSharpLanguage` type to allow formatting a string or bool as a literal appropriate for the language. --- Directory.Build.props | 5 + Directory.Packages.props | 6 + IgnoredWords.dic | 5 + NuGet.Config | 22 +- README.md | 6 +- .editorconfig => src/.editorconfig | 15 +- .../AttributeDataExtensions.cs | 31 ++ .../BaseTypeDeclarationSyntaxExtensions.cs | 35 ++- .../DebugAssert.cs | 30 ++ .../DiagnosticInfo.cs | 43 ++- .../EquatableArray.cs | 134 +++++---- .../EquatableAttributeData.cs | 148 ++++++++++ .../EquatableAttributeDataCollection.cs | 144 +++++++++ .../EquatableDictionary.cs | 223 ++++++++++++++ .../GlobalNamespaceImports.cs | 2 + .../NamespaceQualifiedName.cs | 177 +++++++++++ .../NamespaceQualifiedNameFormatter.cs | 138 +++++++++ .../NestedClassName.cs | 41 +-- src/Ubiquity.NET.CodeAnalysis.Utils/Result.cs | 2 +- .../StructuralTypedConstantComparer.cs | 82 +++++ .../StructurallyEquatableTypedConstant.cs | 101 +++++++ .../SymbolExtensions.cs | 92 ++++++ .../TypeSymbolExtensions.cs | 70 +++++ .../TypedConstantExtensions.cs | 18 ++ .../Ubiquity.NET.CodeAnalysis.Utils.csproj | 20 +- .../ArgsParsingTests.cs | 10 +- .../CommandLineTests.cs | 52 ++-- .../TestOptions.g.cs | 7 +- .../AppControlledDefaultsRootCommand.cs | 49 ++- src/Ubiquity.NET.CommandLine/ArgsParsing.cs | 67 +++-- .../CommandLineOptions.cs | 168 ----------- ...LineSettings.cs => CommandLineSettings.cs} | 10 +- .../DiagnosticMessage.cs | 64 ++++ .../ICommandBinder.cs | 27 ++ .../ICommandLineOptions.cs | 47 --- .../IRootCommandBuilder.cs | 65 ++++ src/Ubiquity.NET.CommandLine/PackageReadMe.md | 3 +- .../ParseResultExtensions.cs | 2 +- .../RootCommandBuilder.cs | 279 ++++++++++++++++++ .../RootCommandExtensions.cs | 8 +- .../Ubiquity.NET.CommandLine.csproj | 3 +- .../ICustomFormatter.cs | 18 ++ .../ImmutableArrayExtensions.cs | 25 ++ src/Ubiquity.NET.Extensions/ProcessInfo.cs | 36 +++ src/Ubiquity.NET.Extensions/Readme.md | 6 +- .../PolyFillExceptionValidators.cs | 5 +- .../PolyFillStringExtensions.cs | 1 + .../MsTestAssertExtensions.cs | 37 ++- .../CSharp/CSharpLanguage.cs | 20 ++ .../CSharp/IndentedTextWriterExtensions.cs | 8 +- src/Ubiquity.NET.SrcGeneration/ReadMe.md | 2 +- src/Ubiquity.NET.Utils.slnx | 2 +- global.json => src/global.json | 0 stylecop.json | 2 +- 54 files changed, 2195 insertions(+), 418 deletions(-) rename .editorconfig => src/.editorconfig (99%) create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/AttributeDataExtensions.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/DebugAssert.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/EquatableAttributeData.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/EquatableAttributeDataCollection.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/EquatableDictionary.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/NamespaceQualifiedName.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/NamespaceQualifiedNameFormatter.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/StructuralTypedConstantComparer.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/StructurallyEquatableTypedConstant.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/SymbolExtensions.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/TypeSymbolExtensions.cs create mode 100644 src/Ubiquity.NET.CodeAnalysis.Utils/TypedConstantExtensions.cs delete mode 100644 src/Ubiquity.NET.CommandLine/CommandLineOptions.cs rename src/Ubiquity.NET.CommandLine/{CmdLineSettings.cs => CommandLineSettings.cs} (95%) create mode 100644 src/Ubiquity.NET.CommandLine/ICommandBinder.cs delete mode 100644 src/Ubiquity.NET.CommandLine/ICommandLineOptions.cs create mode 100644 src/Ubiquity.NET.CommandLine/IRootCommandBuilder.cs create mode 100644 src/Ubiquity.NET.CommandLine/RootCommandBuilder.cs create mode 100644 src/Ubiquity.NET.Extensions/ICustomFormatter.cs create mode 100644 src/Ubiquity.NET.Extensions/ImmutableArrayExtensions.cs rename global.json => src/global.json (100%) diff --git a/Directory.Build.props b/Directory.Build.props index e65d6bd..15f915a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -91,6 +91,11 @@ + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index f3e83bf..b54992b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,6 +22,7 @@ + @@ -32,7 +33,12 @@ + + + + + diff --git a/IgnoredWords.dic b/IgnoredWords.dic index 86f8cae..957a873 100644 --- a/IgnoredWords.dic +++ b/IgnoredWords.dic @@ -47,6 +47,7 @@ Cmp Comdat Comdats Committers +comparand compat Concat Config @@ -67,9 +68,11 @@ downcasts endian endianess endif +endregion enum Enums env +equatability exe facepalm fallback @@ -170,6 +173,7 @@ stdcall struct structs Subrange +suppressions Sym Tag telliam @@ -205,6 +209,7 @@ Users usings utils validator +validators varargs variadic vcxproj diff --git a/NuGet.Config b/NuGet.Config index 7572faf..46c7ca2 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -1,9 +1,19 @@  - - - - - - + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 0f81ffa..068c82b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,6 @@ of multiple small libraries that didn't warrant a distinct repository. >[!IMPORTANT] > When editing code in this repository make certain that any extensions or tooling that -> automatically removes trailing whitespace is disabled. It is fine to highlight such cases -> and most of the time remove any. However, there are some tests where a trailing whitespace -> is required and a critical part of the tests. +> ***automatically*** removes trailing whitespace is disabled. It is fine to highlight such +> cases and most of the time remove any. However, there are some tests where a trailing +> whitespace is required and a critical part of the tests. diff --git a/.editorconfig b/src/.editorconfig similarity index 99% rename from .editorconfig rename to src/.editorconfig index b90504f..80a7700 100644 --- a/.editorconfig +++ b/src/.editorconfig @@ -7,14 +7,14 @@ indent_size = 4 insert_final_newline = true tab_width = 4 end_of_line = crlf + +# VSSPELL: Spell checker settings for all files +vsspell_section_id = bc80fe46ff7a40189dee3f3476198102 # until [VSSpellChecker bug 277](https://github.com/EWSoftware/VSSpellChecker/issues/277) # is fixed, disable the analyzers. It's more of a PITA than a help # VSSPELL: Disable the analyzers until Bug 277 is fixed. vsspell_code_analyzers_enabled = false - -# VSSPELL: Spell checker settings for all files -vsspell_section_id = 1c7003ec377c4bd9bb3c509d29770210 -vsspell_ignored_words_1c7003ec377c4bd9bb3c509d29770210 = File:.\IgnoredWords.dic|bar +vsspell_ignored_words_bc80fe46ff7a40189dee3f3476198102 = File:.\IgnoredWords.dic # match VS generated formatting for MSBuild project files [*.*proj,*.props,*.targets] @@ -105,6 +105,10 @@ csharp_style_prefer_local_over_anonymous_function = true:error csharp_style_prefer_index_operator = true:error csharp_style_prefer_range_operator = true:error +# CS0618: Type or member is obsolete +dotnet_diagnostic.CS0618.severity = error +csharp_style_prefer_simple_property_accessors = true:suggestion + # Analysis and refactoring rules for Ubiquity.NET # Description: Code analysis rules for Ubiquity.NET projects @@ -983,7 +987,8 @@ dotnet_diagnostic.CA1303.severity = silent dotnet_diagnostic.CA1304.severity = warning -dotnet_diagnostic.CA1305.severity = warning +// CA1305: Specify IFormatProvider +dotnet_diagnostic.CA1305.severity = silent dotnet_diagnostic.CA1306.severity = warning diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/AttributeDataExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/AttributeDataExtensions.cs new file mode 100644 index 0000000..ab3681e --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/AttributeDataExtensions.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 to provide extensions to + public static class AttributeDataExtensions + { + /// Tests if the full namespace qualified name of the name matches this type + /// AttributeData to test + /// Sequence of parts for the name to test with outer most namespace first (Including the simple name) + /// true if the name matches and false otherwise + public static bool IsFullNameMatch( this AttributeData self, NamespaceQualifiedName fullName ) + { + return self.AttributeClass is not null + && self.AttributeClass.Name == fullName.SimpleName + && self.AttributeClass.GetNamespaceNames() + .SequenceEqual( fullName.NamespaceNames ); + } + + /// Gets the for the attribute + /// self + /// for the attribute + public static NamespaceQualifiedName GetNamespaceQualifiedName( this AttributeData self ) + { + return self.AttributeClass is null + ? new( [], string.Empty ) + : self.AttributeClass.GetNamespaceQualifiedName(); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/BaseTypeDeclarationSyntaxExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/BaseTypeDeclarationSyntaxExtensions.cs index 6ca2942..77bfc7d 100644 --- a/src/Ubiquity.NET.CodeAnalysis.Utils/BaseTypeDeclarationSyntaxExtensions.cs +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/BaseTypeDeclarationSyntaxExtensions.cs @@ -60,27 +60,26 @@ public static string GetDeclaredNamespace(this BaseTypeDeclarationSyntax syntax) /// Syntax to get the name for /// Flag to indicate if the type itself is included in the name [Default: /// of the syntax or + /// + /// + /// The return type is never null if is true AND it is a structural type (reference or value) + /// as the name of the type itself is included. + /// + /// 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; + 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) + if(!IsAllowedKind( parentSyntax )) { break; } @@ -90,7 +89,8 @@ bool isAllowedKind 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) + children: parentClassInfo is null ? [] : [ parentClassInfo ] + ); // set the child link (null initially) // Move to the next outer type parentSyntax = parentSyntax.Parent as TypeDeclarationSyntax; @@ -98,6 +98,15 @@ bool isAllowedKind // return a link to the outermost parent type return parentClassInfo; + + // local static function to test for allowed kinds + static bool IsAllowedKind( TypeDeclarationSyntax parentSyntax ) + { + var rawKind = parentSyntax.Kind(); + return rawKind == SyntaxKind.ClassDeclaration + || rawKind == SyntaxKind.StructDeclaration + || rawKind == SyntaxKind.RecordDeclaration; + } } } } diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/DebugAssert.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/DebugAssert.cs new file mode 100644 index 0000000..437316c --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/DebugAssert.cs @@ -0,0 +1,30 @@ +// 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.Diagnostics; + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Utility class to support Debug asserts + public static class DebugAssert + { + /// Tests if a structure size is < 16 bytes and generates a debug assertion if not + /// Type of the struct to test + /// + /// This uses a runtime debug assert as it isn't possible to know the size at compile time of a managed struct. + /// The `sizeof` doesn't apply for anything with a managed reference or a native pointer sized member + /// as such sizes depend on the actual runtime used. + /// + /// This function ONLY operates in a debug build. That is, this is the compiler will elide calls to this method + /// at the call site unless the "DEBUG" symbol is defined as it has a attached to it. + /// + /// + /// + [Conditional( "DEBUG" )] + public static void StructSizeOK( ) + where T : struct + { + Debug.Assert( Unsafe.SizeOf() <= 16, $"{nameof( T )} size is > 16 bytes; Make it a class" ); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/DiagnosticInfo.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/DiagnosticInfo.cs index 67ffa21..f7b4d3b 100644 --- a/src/Ubiquity.NET.CodeAnalysis.Utils/DiagnosticInfo.cs +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/DiagnosticInfo.cs @@ -11,7 +11,8 @@ namespace Ubiquity.NET.CodeAnalysis.Utils /// 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 + public sealed class DiagnosticInfo + : IEquatable { #if !NET9_0_OR_GREATER /// Initializes a new instance of the class. @@ -38,17 +39,17 @@ public DiagnosticInfo(DiagnosticDescriptor descriptor, Location? location, param { Descriptor = descriptor; Location = location; - Params = msgArgs.ToImmutableArray(); + Params = [ .. msgArgs ]; } /// Gets the parameters for this diagnostic - public EquatableArray Params { get; } + public ImmutableArray 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. + // Microsoft.CodeAnalysis.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; } @@ -59,5 +60,37 @@ public Diagnostic CreateDiagnostic() { return Diagnostic.Create(Descriptor, Location, Params.ToArray()); } + + /// + public bool Equals( DiagnosticInfo other ) + { + return other is not null + && StructuralComparisons.StructuralEqualityComparer.Equals(Params, other.Params) + && Descriptor.Equals(other.Descriptor) + && ( ReferenceEquals(Location, other.Location) + || (Location is not null && Location.Equals(other.Location)) + ); + } + + /// + public override bool Equals( object obj ) + { + return obj is DiagnosticInfo other + && Equals( other ); + } + + /// + public override int GetHashCode( ) + { + // sadly this will re-hash the hashcode computed for the structure, but there is no way + // to combine the result of a hash with other things. (The overload of Add(int) is private) + // The generic Add() will call the type's GetHashCode() and ignores the implementation of + // IStructuralEquatable. + return HashCode.Combine( + StructuralComparisons.StructuralEqualityComparer.GetHashCode( Params ), + Descriptor, + Location + ); + } } } diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableArray.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableArray.cs index 29a5c8e..91964ca 100644 --- a/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableArray.cs +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableArray.cs @@ -14,6 +14,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +// Modified heavily to support IStructuralEquatable + namespace Ubiquity.NET.CodeAnalysis.Utils { /// Extensions for . @@ -23,11 +25,22 @@ public static class EquatableArray /// The type of items in the input array. /// The input instance. /// An instance from a given . - public static EquatableArray AsEquatableArray(this ImmutableArray array) + public static EquatableArray AsEquatableArray( this ImmutableArray array ) + where T : IEquatable + { + return array.IsDefault ? throw new ArgumentNullException( nameof( array ) ) + : new( array ); + } + + /// Creates an instance from a given . + /// The type of items in the input array. + /// The input builder instance. + /// An instance from a given . + [Obsolete( "Use ImmutableArry instead" )] + public static EquatableArray AsEquatableArray( this ImmutableArray.Builder self ) where T : IEquatable { - return array.IsDefault ? throw new ArgumentNullException(nameof(array)) - : new(array); + return AsEquatableArray( self.ToImmutable() ); } } @@ -35,23 +48,31 @@ public static EquatableArray AsEquatableArray(this ImmutableArray array /// An immutable, equatable array. This is equivalent to but with value equality of members support. /// /// The type of values in the array. + /// + /// Use of this type should be limited to cases where it is the result of analysis itself. That is, when the array is a + /// member of some other equatable type, then it should use instead. The container should + /// use the support to implement it's . This type will enforce + /// structural comparison as it's implementation of equality checks. All forms of retrieving a hash code resolve to the + /// structural form. This ensures that the behavior is consistent and the array is cacheable. + /// public readonly struct EquatableArray : IEquatable> , IEnumerable + , IStructuralEquatable where T : IEquatable { /// /// The underlying array. /// - private readonly T[]? array; + private readonly T[]? InnerArray; /// /// Creates a new instance. /// /// The input to wrap. - public EquatableArray(ImmutableArray array) + public EquatableArray( ImmutableArray array ) { - this.array = Unsafe.As, T[]?>(ref array); + InnerArray = Unsafe.As, T[]?>( ref array ); } /// @@ -59,10 +80,10 @@ public EquatableArray(ImmutableArray 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] + public ref readonly T this[ int index ] { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => ref AsImmutableArray().ItemRef(index); + [MethodImpl( MethodImplOptions.AggressiveInlining )] + get => ref AsImmutableArray().ItemRef( index ); } /// @@ -70,51 +91,40 @@ public ref readonly T this[int index] /// public bool IsEmpty { - [MethodImpl(MethodImplOptions.AggressiveInlining)] + [MethodImpl( MethodImplOptions.AggressiveInlining )] get => AsImmutableArray().IsEmpty; } /// Gets the length of the array - public int Length => array?.Length ?? 0; + public int Length => InnerArray?.Length ?? 0; - /// - public bool Equals(EquatableArray array) + /// + public bool Equals( EquatableArray array ) { - return AsSpan().SequenceEqual(array.AsSpan()); + return StructuralComparisons.StructuralEqualityComparer.Equals( InnerArray ); } - /// - public override bool Equals([NotNullWhen(true)] object? obj) + /// + public override bool Equals( [NotNullWhen( true )] object? obj ) { - return obj is EquatableArray array && Equals(this, array); + return obj is EquatableArray other + && Equals( other ); } - /// - public override int GetHashCode() + /// + 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(); + return StructuralComparisons.StructuralEqualityComparer.GetHashCode( InnerArray ); } /// /// Gets an instance from the current . /// /// The from the current . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public ImmutableArray AsImmutableArray() + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public ImmutableArray AsImmutableArray( ) { - return Unsafe.As>(ref Unsafe.AsRef(in array)); + return Unsafe.As>( ref Unsafe.AsRef( in InnerArray ) ); } /// @@ -122,16 +132,16 @@ public ImmutableArray AsImmutableArray() /// /// The input instance. /// An instance from a given . - public static EquatableArray FromImmutableArray(ImmutableArray array) + public static EquatableArray FromImmutableArray( ImmutableArray array ) { - return new(array); + return new( array ); } /// /// Returns a wrapping the current items. /// /// A wrapping the current items. - public ReadOnlySpan AsSpan() + public ReadOnlySpan AsSpan( ) { return AsImmutableArray().AsSpan(); } @@ -140,46 +150,66 @@ public ReadOnlySpan AsSpan() /// Copies the contents of this instance to a mutable array. /// /// The newly instantiated array. - public T[] ToArray() + public T[] ToArray( ) { - return [.. AsImmutableArray()]; + 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() + public ImmutableArray.Enumerator GetEnumerator( ) { return AsImmutableArray().GetEnumerator(); } - /// - IEnumerator IEnumerable.GetEnumerator() + /// + IEnumerator IEnumerable.GetEnumerator( ) { return ((IEnumerable)AsImmutableArray()).GetEnumerator(); } - /// - IEnumerator IEnumerable.GetEnumerator() + /// + IEnumerator IEnumerable.GetEnumerator( ) { return ((IEnumerable)AsImmutableArray()).GetEnumerator(); } + /// + [SuppressMessage( "Style", "IDE0046:Convert to conditional expression", Justification = "Nested conditionals are not simpler" )] + bool IStructuralEquatable.Equals( object other, IEqualityComparer comparer ) + { + if(other is not EquatableArray otherArray) + { + return false; + } + + return InnerArray is null + ? ReferenceEquals(InnerArray, otherArray.InnerArray) + : ((IStructuralEquatable)InnerArray).Equals(other, comparer); + } + + /// + int IStructuralEquatable.GetHashCode( IEqualityComparer comparer ) + { + return StructuralComparisons.StructuralEqualityComparer.GetHashCode(InnerArray); + } + /// /// Implicitly converts an to . /// /// An instance from a given . - public static implicit operator EquatableArray(ImmutableArray array) + public static implicit operator EquatableArray( ImmutableArray array ) { - return FromImmutableArray(array); + return FromImmutableArray( array ); } /// /// Implicitly converts an to . /// /// An instance from a given . - public static implicit operator ImmutableArray(EquatableArray array) + public static implicit operator ImmutableArray( EquatableArray array ) { return array.AsImmutableArray(); } @@ -190,9 +220,9 @@ public static implicit operator ImmutableArray(EquatableArray array) /// The first value. /// The second value. /// Whether and are equal. - public static bool operator ==(EquatableArray left, EquatableArray right) + public static bool operator ==( EquatableArray left, EquatableArray right ) { - return left.Equals(right); + return left.Equals( right ); } /// @@ -201,9 +231,9 @@ public static implicit operator ImmutableArray(EquatableArray array) /// The first value. /// The second value. /// Whether and are not equal. - public static bool operator !=(EquatableArray left, EquatableArray right) + public static bool operator !=( EquatableArray left, EquatableArray right ) { - return !left.Equals(right); + return !left.Equals( right ); } } } diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableAttributeData.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableAttributeData.cs new file mode 100644 index 0000000..deb0514 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableAttributeData.cs @@ -0,0 +1,148 @@ +// 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.Diagnostics; + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Equatable form of + /// + /// + /// This CAPTURES only the portion of the attributes data that is relevant to source generators. + /// Specifically, it captures the full for the unnamed + /// constructor arguments and a dictionary for the named arguments. All of which is captured + /// in a manner that supports equality checks. These form the semantic set of properties + /// necessary to capture for an attribute. + /// + /// + /// + /// C# language specification §23.2.4 Attribute parameter types + /// + public class EquatableAttributeData + : IEquatable + { + /// Initializes a new instance of the class. + /// The to capture equatable information from + public EquatableAttributeData( AttributeData data ) + { + PolyFillExceptionValidators.ThrowIfNull( data ); + + Name = data.GetNamespaceQualifiedName(); + ConstructorArguments = [ .. data.ConstructorArguments.Select(e=>(StructurallyEquatableTypedConstant)e) ]; + var namedArgs = data.NamedArguments.Select(kvp => new KeyValuePair(kvp.Key, (StructurallyEquatableTypedConstant)kvp.Value)); + NamedArguments = namedArgs.ToImmutableDictionary(); + } + + /// Gets the full namespace qualified name for this attribute + public NamespaceQualifiedName Name { get; } + + /// Gets the unnamed constructor arguments for this attribute + public ImmutableArray ConstructorArguments { get; } + + /// Gets dictionary for the named arguments + public EquatableDictionary NamedArguments { get; } = []; + + /// Gets the constant for a named argument + /// Name of the argument to fetch + /// Optional value for the named argument ( is false if isn't provided) + public Optional GetNamedArgValue( string argName ) + { + return NamedArguments.TryGetValue( argName, out StructurallyEquatableTypedConstant typedConst ) + ? new(typedConst) + : default; + } + + /// Gets the named argument constant as a specified type + /// Type of the value to retrieve if present + /// Name of the attribute argument + /// for the value + /// + /// The name may not be specified in which case the result + /// will have not value ( is false). It is also + /// possible that it was specified AND that the value is null (if T is a nullable type + /// or a default instance if it is not.) Thus it is important to examine the return + /// to know if a value was specified that happens to be the default value for a type. + /// + public Optional GetNamedArgValue( string name ) + { + var argInfo = GetNamedArgValue(name); + return !argInfo.HasValue + ? default + : new((T)argInfo.Value.Value!); + } + + /// Gets the named argument constant as an of elements of specified type + /// Type of the value to retrieve if present + /// Name of the attribute argument + /// for the array of values + /// + /// The name may not be specified in which case the result + /// will have not value ( is false). It is also + /// possible that it was specified AND that the value is null (if T is a nullable type + /// or a default instance if it is not.) Thus it is important to examine the return + /// to know if a value was specified that happens to be the default value for a type. + /// + /// As nullability is NOT part of the type this method + /// assumes it is allowed. Therefore, the resulting array may contain null values if + /// that is what was provided to the attribute in the original source. + /// + /// + public Optional> GetNamedArgValueArray( string argName ) + { + var elementType = typeof( TElement ); + if(elementType.IsArray) + { + throw new InvalidOperationException("Arrays of arrays not supported. TElement must be a scalar."); + } + + if(!NamedArguments.TryGetValue( argName, out StructurallyEquatableTypedConstant typedConst ) + || typedConst.IsNull + || typedConst.Kind != TypedConstantKind.Array + ) + { + Debug.WriteLineIf( !typedConst.IsNull && typedConst.Kind != TypedConstantKind.Array, $"Non array named attribute; retrieved as array! '{argName}'" ); + return default; + } + + // Nullability of TElement is not known, if the typed constant indicates it is a null value + // then that is what it is. This does not skip such a thing nor attempt to validate nullability. +#pragma warning disable CS8601 // Possible null reference assignment. +#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. + return new( [ .. typedConst.Values.Select( static tc => (TElement)tc.Value ) ] ); +#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type. +#pragma warning restore CS8601 // Possible null reference assignment. + } + + /// + public bool Equals( EquatableAttributeData other ) + { + return other is not null + && Name == other.Name + && StructuralComparisons.StructuralEqualityComparer.Equals(ConstructorArguments, other.ConstructorArguments) + && StructuralComparisons.StructuralEqualityComparer.Equals(NamedArguments, other.NamedArguments); + } + + /// + public override int GetHashCode( ) + { + return HashCode.Combine( + Name, + StructuralComparisons.StructuralEqualityComparer.GetHashCode( ConstructorArguments ), + StructuralComparisons.StructuralEqualityComparer.GetHashCode( NamedArguments ) + ); + } + + /// + public override bool Equals( object obj ) + { + return obj is EquatableAttributeData other + && Equals( other ); + } + + [SuppressMessage( "Usage", "CA2225:Operator overloads have named alternates", Justification = "Implicit cast for public constructor" )] + public static implicit operator EquatableAttributeData( AttributeData data ) + { + return new( data ); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableAttributeDataCollection.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableAttributeDataCollection.cs new file mode 100644 index 0000000..908038e --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableAttributeDataCollection.cs @@ -0,0 +1,144 @@ +// 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 +{ + /// Keyed collection of keyed by + public class EquatableAttributeDataCollection + : IEnumerable + , IReadOnlyCollection + , IEquatable + , IStructuralEquatable + { + /// Initializes a new instance of the class. + /// Attribute information for this collection + /// + /// This will iterate over the collection to get the + /// as a key for the item in the collection. + /// + public EquatableAttributeDataCollection( IEnumerable attributes ) + : this( ToDictionary( attributes ) ) + { + } + + /// Initializes a new instance of the class. + /// Dictionary of attributes for the collection + public EquatableAttributeDataCollection( EquatableDictionary attributes ) + { + InnerDictionary = attributes; + } + + /// Gets the value for a key + /// Name of the attribute data + /// Attribute data for the key + /// is null + /// does not match any entry in this collection + [SuppressMessage( "Design", "CA1043:Use Integral Or String Argument For Indexers", Justification = "Neither string nor integer is the key type" )] + public EquatableAttributeData this[ NamespaceQualifiedName key ] => InnerDictionary[key]; + + /// + public int Count => InnerDictionary.Count; + + /// + public IEnumerator GetEnumerator( ) + { + return Values.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator( ) + { + return GetEnumerator(); + } + + #region Equatable implementation + + /// + public bool Equals( EquatableAttributeDataCollection other ) + { + return StructuralComparisons.StructuralEqualityComparer.Equals(this, other); + } + + /// + public override bool Equals( object obj ) + { + return obj is not null + && obj is EquatableAttributeDataCollection collection + && Equals( collection ); + } + + /// + public override int GetHashCode( ) + { + // CONSIDER: Optimize this to cache the hash code. + HashCode retVal = default; + foreach(var item in this) + { + retVal.Add( item ); + } + + return retVal.ToHashCode(); + } + #endregion + + /// Gets a sequence of the names of all values. + /// + /// These names are keys for the values used in and + /// . + /// + public IEnumerable Keys => InnerDictionary.Keys; + + /// Gets a sequence of all the values in this collection + /// + /// This is no different that using the implemented by this instance + /// directly. It is here to support common dictionary functionality. + /// + public IEnumerable Values => InnerDictionary.Values; + + /// Tries to get the value for a given + /// The type name for the attribute + /// Resulting attribute if found (default constructed if not) + /// true if the attribute is found in this collection + public bool TryGetValue( NamespaceQualifiedName key, [MaybeNullWhen( false )] out EquatableAttributeData item ) + { + return InnerDictionary.TryGetValue( key, out item ); + } + + [SuppressMessage( "Usage", "CA2225:Operator overloads have named alternates", Justification = "Simple wrapper over public constructor" )] + public static implicit operator EquatableAttributeDataCollection( ImmutableArray attributes ) + { + return new EquatableAttributeDataCollection( attributes ); + } + + private readonly EquatableDictionary InnerDictionary; + + private static EquatableDictionary ToDictionary( IEnumerable attributes ) + { + var bldr = ImmutableDictionary.CreateBuilder(); + if(attributes is not null) + { + foreach(var attr in attributes) + { + bldr.Add( attr.Name, attr ); + } + } + + return bldr.ToImmutable(); + } + + /// + bool IStructuralEquatable.Equals( object other, IEqualityComparer comparer ) + { + bool retVal = other is EquatableAttributeDataCollection otherCollection + && ((IStructuralEquatable)InnerDictionary).Equals( otherCollection.InnerDictionary, comparer ); + + return retVal; + } + + /// + int IStructuralEquatable.GetHashCode( IEqualityComparer comparer ) + { + return ((IStructuralEquatable)InnerDictionary).GetHashCode( comparer ); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableDictionary.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableDictionary.cs new file mode 100644 index 0000000..c0ef644 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/EquatableDictionary.cs @@ -0,0 +1,223 @@ +// 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 +{ + /// An equatable, immutable, dictionary + /// Type of keys in the dictionary (Must be for TKey + /// Type of keys in the dictionary (Must be for TValue + /// + /// This is a struct that has no additional size beyond that of the wrapped reference to the underlying dictionary. It + /// merely adds equatability checks to the dictionary for use in Roslyn source generator caching. + /// + public readonly struct EquatableDictionary + : IImmutableDictionary + , IEquatable> + , IStructuralEquatable + where TKey : IEquatable + where TValue : IEquatable + { + /// Initializes a new instance of the struct. + /// Dictionary to wrap + public EquatableDictionary( IImmutableDictionary dictionaryToWrap ) + { + InnerDictionary = dictionaryToWrap; + } + + #region Equatability + + /// + public override int GetHashCode( ) + { + return InnerDictionary.GetHashCode(); + } + + /// + public override bool Equals( object obj ) + { + return obj is EquatableDictionary dictionary + && Equals( dictionary ); + } + + /// + public bool Equals( EquatableDictionary other ) + { + return ((IStructuralEquatable)this).Equals( other, EqualityComparer>.Default); + } + + public static bool operator ==( EquatableDictionary left, EquatableDictionary right ) + { + return left.Equals( right ); + } + + public static bool operator !=( EquatableDictionary left, EquatableDictionary right ) + { + return !(left == right); + } + + /// + bool IStructuralEquatable.Equals( object other, IEqualityComparer comparer ) + { + if(other is not EquatableDictionary otherItem) + { + return false; + } + + using var thisIterator = GetEnumerator(); + using var otherIterator = otherItem.GetEnumerator(); + + bool mismatchFound = false; + bool lhsValid = thisIterator.MoveNext(); // move to first entry; if any + bool rhsValid = otherIterator.MoveNext(); + while(lhsValid && rhsValid) + { + // NOTE: KeyValuePair is NOT equatable, it's just a struct + // if one or both of the pair is a managed reference then it + // is compared with what amount to reference equality. Which is + // NOT what is desired here. + if( !comparer.Equals( thisIterator.Current.Key, otherIterator.Current.Key ) + || !comparer.Equals( thisIterator.Current.Value, otherIterator.Current.Value ) + ) + { + mismatchFound = true; + break; // stop loop as soon as mismatch is found + } + + lhsValid = thisIterator.MoveNext(); + rhsValid = otherIterator.MoveNext(); + } + + // Only equal if no mismatch found AND both sequences are the same length + // (that is, if one sequence is a super set of the other, it is not equal! + return !mismatchFound && (lhsValid == rhsValid); + } + + /// + int IStructuralEquatable.GetHashCode( IEqualityComparer comparer ) + { + HashCode hashCode = default; + foreach( var kvp in this) + { + hashCode.Add(kvp, comparer as IEqualityComparer> ); + } + + return hashCode.ToHashCode(); + } + #endregion + + #region IImmutableDictionary interface implementations through wrapped dictionary + + /// + public TValue this[ TKey key ] => ((IReadOnlyDictionary)InnerDictionary)[ key ]; + + /// + public IEnumerable Keys => InnerDictionary.Keys; + + /// + public IEnumerable Values => InnerDictionary.Values; + + /// + public int Count => InnerDictionary.Count; + + /// + public IImmutableDictionary Add( TKey key, TValue value ) + { + return new EquatableDictionary(InnerDictionary.Add( key, value )); + } + + /// + public IImmutableDictionary AddRange( IEnumerable> pairs ) + { + return new EquatableDictionary( InnerDictionary.AddRange( pairs ) ); + } + + /// + public IImmutableDictionary Clear( ) + { + return new EquatableDictionary( InnerDictionary.Clear() ); + } + + /// + public bool Contains( KeyValuePair pair ) + { + return InnerDictionary.Contains( pair ); + } + + /// + public bool ContainsKey( TKey key ) + { + return InnerDictionary.ContainsKey( key ); + } + + /// + public IEnumerator> GetEnumerator( ) + { + return InnerDictionary.GetEnumerator(); + } + + /// + public IImmutableDictionary Remove( TKey key ) + { + return InnerDictionary.Remove( key ); + } + + /// + public IImmutableDictionary RemoveRange( IEnumerable keys ) + { + return InnerDictionary.RemoveRange( keys ); + } + + /// + public IImmutableDictionary SetItem( TKey key, TValue value ) + { + return InnerDictionary.SetItem( key, value ); + } + + /// + public IImmutableDictionary SetItems( IEnumerable> items ) + { + return InnerDictionary.SetItems( items ); + } + + /// + public bool TryGetKey( TKey equalKey, out TKey actualKey ) + { + return InnerDictionary.TryGetKey( equalKey, out actualKey ); + } + + /// + IEnumerator IEnumerable.GetEnumerator( ) + { + return ((IEnumerable)InnerDictionary).GetEnumerator(); + } + + /// + public bool TryGetValue( TKey key, out TValue value ) + { + value = default!; + if(!InnerDictionary.TryGetValue( key, out TValue? foundValue )) + { + return false; + } + + value = foundValue; + return true; + } + + #endregion + + [SuppressMessage( "Usage", "CA2225:Operator overloads have named alternates", Justification = "Implicit cast for public constructor" )] + public static implicit operator EquatableDictionary( ImmutableDictionary dictionaryToWrap ) + { + return new(dictionaryToWrap); + } + + [SuppressMessage( "Usage", "CA2225:Operator overloads have named alternates", Justification = "Implicit cast for public constructor" )] + public static implicit operator EquatableDictionary( ImmutableSortedDictionary dictionaryToWrap ) + { + return new( dictionaryToWrap ); + } + + private readonly IImmutableDictionary InnerDictionary; + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/GlobalNamespaceImports.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/GlobalNamespaceImports.cs index 746eb94..9587eb7 100644 --- a/src/Ubiquity.NET.CodeAnalysis.Utils/GlobalNamespaceImports.cs +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/GlobalNamespaceImports.cs @@ -34,3 +34,5 @@ set of namespaces that is NOT consistent or controlled by the developer. THAT is global using Microsoft.CodeAnalysis.CSharp.Syntax; global using Microsoft.CodeAnalysis.Diagnostics; global using Microsoft.CodeAnalysis.Text; + +global using Ubiquity.NET.Extensions; diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/NamespaceQualifiedName.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/NamespaceQualifiedName.cs new file mode 100644 index 0000000..83e4f75 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/NamespaceQualifiedName.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. + +namespace Ubiquity.NET.CodeAnalysis.Utils +{ + /// Simple record to represent a namespace qualified name as a sequence of strings + /// + /// This is useful for matching names of attributes where comparison on the simple name is performed first. This filters + /// out anything without a correct simple name before taking on the cost of establishing the full namespace hierarchical + /// name. + /// + /// This does NOT support nested names. The syntax, and even support, of that is language dependent. + /// Additionally, there is no provision to represent the transition to a nested type as the elements of the sequence + /// are just strings. + /// + /// + public class NamespaceQualifiedName + : IEquatable + , IEquatable + , IFormattable + { + /// Initializes a new instance of the class. + /// sequence of namespace names (outermost to innermost) + /// Unqualified name of the symbol + public NamespaceQualifiedName(IEnumerable namespaceNames, string simpleName ) + { + PolyFillExceptionValidators.ThrowIfNull(namespaceNames); + PolyFillExceptionValidators.ThrowIfNullOrWhiteSpace(simpleName); + + SimpleName = simpleName; + NamespaceNames = [ .. namespaceNames.Select( s => ValidateNamespacePart(s) ) ]; + + static string ValidateNamespacePart( string s, [CallerArgumentExpression(nameof(s))] string? exp = null ) + { + PolyFillExceptionValidators.ThrowIfNullOrWhiteSpace( s, exp ); + return s; + } + } + + /// Gets the sequence of the namespace names starting with the outermost namespace moving inwards + public ImmutableArray NamespaceNames { get; } + + /// Gets the simple (unqualified) name of the symbol + public string SimpleName { get; } + + /// Gets a string for the namespaces instance + /// + /// This essentially returns an enumerable sequence that is the joining of + /// with "." as the delimiter. + /// + public string Namespace => string.Join( ".", NamespaceNames ); + + /// Gets the full sequence of the names for this instance + /// + /// This essentially returns an enumerable sequence that is the concatenation of + /// with the to form a sequence + /// that contains the full name. + /// + public IEnumerable FullNameParts + { + get + { + foreach(string name in NamespaceNames) + { + yield return name; + } + + yield return SimpleName; + } + } + + /// + public bool Equals( NamespaceQualifiedName other ) + { + return FullNameParts.SequenceEqual( other.FullNameParts ); + } + + /// + public override bool Equals( object obj ) + { + return obj is NamespaceQualifiedName other + && Equals( other ); + } + + /// + public override int GetHashCode( ) + { + // CONSIDER: Lazy create this so that overhead is paid only once. + + HashCode hashCode = default; + + foreach(string item in NamespaceNames) + { + hashCode.Add( item ); + } + + hashCode.Add( SimpleName ); + + return hashCode.ToHashCode(); + } + + /// Equality operator to test two names for equality + /// left name to test + /// Right name to test + /// true if the names are equal + public static bool operator ==( NamespaceQualifiedName left, NamespaceQualifiedName right ) + { + return left.Equals( right ); + } + + /// Equality operator to test two names for inequality + /// left name to test + /// Right name to test + /// true if the names are NOT equal; false otherwise + public static bool operator !=( NamespaceQualifiedName left, NamespaceQualifiedName right ) + { + return !(left == right); + } + + /// Compares this name with that of a + /// The type to compare the name to + /// true if the name of matches this name + public bool Equals( Type other ) + { + return ToString() == other.FullName; + } + + /// Gets the string representation of the full namespace using '.' as the delimiter + /// Full namespace qualified name as a string (with a global prefix or an alias if available) + /// + /// This is just a tail call to with "AG" as the format. + /// That is, the default formatting is to provide an alias if possible and if not available use a global prefixed + /// name. If any other behavior is desired then is available + /// to allow formatting as needed. (NOTE: That version is called when formatting a string with a specifier + /// such as var s = $"{MyNamespaceQualifiedName:R}"; + /// + public override string ToString( ) + { + return ToString("R", null); + } + + /// Formats this instance according to the args + /// Format string for this instance (see remarks) + /// [ignored] + /// Formatted string representation of this instance + /// + /// The supported values for are: + /// + /// ValueDescription + /// AFormat as a language specific alias if possible + /// GFormat with a language specific global prefix. + /// AGFormat with a language specific alias if possible, otherwise include a global prefix. + /// RThe raw full name without any qualifications + /// + /// + /// is not supported + [SuppressMessage( "Style", "IDE0046:Convert to conditional expression", Justification = "Result is anything but simpler" )] + public string ToString( string format, IFormatProvider? formatProvider ) + { + // default to the C# formatter unless specified. + formatProvider ??= NamespaceQualifiedNameFormatter.CSharp; + + // if no custom formatter is available, then just produce the raw name. + var customFormatter = (ICustomFormatter)formatProvider.GetFormat(typeof(ICustomFormatter)); + return customFormatter is null + ? string.Join(".", FullNameParts) + : customFormatter.Format(format, this, formatProvider); + } + + /// Implicit conversion to a string (Shorthand for calling + /// Name to convert + public static implicit operator string( NamespaceQualifiedName self ) + { + return self.ToString(); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/NamespaceQualifiedNameFormatter.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/NamespaceQualifiedNameFormatter.cs new file mode 100644 index 0000000..e70c77b --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/NamespaceQualifiedNameFormatter.cs @@ -0,0 +1,138 @@ +// 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 +{ + /// Custom formatter for + public class NamespaceQualifiedNameFormatter + : IFormatProvider + , ICustomFormatter + { + /// Initializes a new instance of the class. + /// Global prefix for the language this formatter will produce + /// Map of aliases for the language (Key is the full namespace name of a type, the values are the alias) + public NamespaceQualifiedNameFormatter( string globalPrefix, IReadOnlyDictionary aliasMap ) + { + GlobalPrefix = globalPrefix ?? string.Empty; + AliasMap = aliasMap ?? throw new ArgumentNullException(nameof(aliasMap)); + } + + /// Gets the global prefix for the language formatting (example: "global::") + public string GlobalPrefix { get; } + + /// Gets the map of type aliases this formatter uses to format an alias + /// + /// The keys for this map are a fully qualified type names and the values are the aliases + /// used in place of the full name. + /// + public IReadOnlyDictionary AliasMap { get; } + + /// + public string Format( string format, object arg, IFormatProvider? formatProvider ) + { + return arg is not NamespaceQualifiedName self + ? string.Empty + : Format(format, self, formatProvider); + } + + /// Formats this instance according to the args + /// Format string for this instance (see remarks) + /// The value to format + /// [ignored] + /// Formatted string representation of this instance + /// + /// The supported values for are: + /// + /// ValueDescription + /// AFormat as a language specific alias if possible + /// GFormat with a language specific global prefix. + /// AGFormat with a language specific alias if possible, otherwise include a global prefix. + /// RThe raw full name without any qualifications + /// + /// + /// is not supported + public string Format( string format, NamespaceQualifiedName arg, IFormatProvider? formatProvider ) + { + // default to global prefix or alias if not specified. + format ??= "R"; + + string rawName = string.Join( ".", arg.FullNameParts ); + if(format == "R") + { + return rawName; + } + + // Try an alias first as that might be the return value + if((format == "A" || format == "AG") && TryFormatLanguageAlias( rawName, out string? alias )) + { + return alias; + } + + // not an alias so, try global prefix if requested. + // The only reason that won't add a prefix is if the GlobalPrefix is null, empty or all whitespace. + // In that case (or if the prefix isn't requested), the full name is returned. + return (format == "G" || format == "AG") && TryPrefixGlobal( rawName, out string? prefixedName ) + ? prefixedName + : rawName; + } + + /// + public object? GetFormat( Type formatType ) + { + return formatType == typeof( ICustomFormatter ) + ? this + : null; + } + + private bool TryFormatLanguageAlias( string fullName, [MaybeNullWhen( false )] out string alias ) + { + return AliasMap.TryGetValue( fullName, out alias ); + } + + private bool TryPrefixGlobal( string fullName, [MaybeNullWhen( false )] out string prefixedName ) + { + prefixedName = null; + if(string.IsNullOrWhiteSpace( GlobalPrefix )) + { + return false; + } + + prefixedName = $"{GlobalPrefix}{fullName}"; + return true; + } + + /// Gets a formatter for the C# language + public static NamespaceQualifiedNameFormatter CSharp { get; } + = new NamespaceQualifiedNameFormatter( + "global::", + /* see: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/built-in-types */ + new Dictionary() + { + [ "System.Boolean" ] = "bool", + [ "System.Byte" ] = "byte", + [ "System.SByte" ] = "sbyte", + [ "System.Char" ] = "char", + [ "System.Decimal" ] = "decimal", + [ "System.Double" ] = "double", + [ "System.Single" ] = "float", + [ "System.Int32" ] = "int", + [ "System.UInt32" ] = "uint", + + // These aren't quite an alias in C# 9 & 10, but C# 11 made them FULL aliases as used here + [ "System.IntPtr" ] = "nint", + [ "System.UIntPtr" ] = "nuint", + + [ "System.Int64" ] = "long", + [ "System.UInt64" ] = "ulong", + [ "System.Int16" ] = "short", + [ "System.UInt16" ] = "ushort", + + [ "System.Object" ] = "object", + [ "System.String" ] = "string", + [ "System.Delegate" ] = "delegate", + + // ["????"] = "dynamic", // No way to map in this direction + } + ); + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/NestedClassName.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/NestedClassName.cs index 114c201..b371cf4 100644 --- a/src/Ubiquity.NET.CodeAnalysis.Utils/NestedClassName.cs +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/NestedClassName.cs @@ -20,8 +20,8 @@ public sealed class NestedClassName /// /// is normally one of ("class", "struct", "interface", "record [class|struct]?"). /// - public NestedClassName(string keyword, string name, string constraints, params NestedClassName[] children) - : this( keyword, name, constraints, (IEnumerable)children) + public NestedClassName( string keyword, string name, string constraints, params NestedClassName[] children ) + : this( keyword, name, constraints, (IEnumerable)children ) { } @@ -33,7 +33,7 @@ public NestedClassName(string keyword, string name, string constraints, params N /// /// is normally one of ("class", "struct", "interface", "record [class|struct]?"). /// - public NestedClassName(string keyword, string name, string constraints, IEnumerable children) + public NestedClassName( string keyword, string name, string constraints, IEnumerable children ) #else /// Initializes a new instance of the class. /// Keyword for this declaration @@ -49,11 +49,11 @@ public NestedClassName(string keyword, string name, string constraints, params I Keyword = keyword; Name = name; Constraints = constraints; - Children = children.ToImmutableArray().AsEquatableArray(); + Children = [ .. children ]; } /// Gets child nested types - public EquatableArray Children { get; } + public ImmutableArray Children { get; } /// Gets the keyword for this type /// @@ -68,7 +68,7 @@ public NestedClassName(string keyword, string name, string constraints, params I public string Constraints { get; } /// Gets a value indicating whether this name contains constraints - public bool HasConstraints => !string.IsNullOrWhiteSpace(Constraints); + public bool HasConstraints => !string.IsNullOrWhiteSpace( Constraints ); /// Compares this instance with another /// Value to compare this instance with @@ -78,34 +78,41 @@ public NestedClassName(string keyword, string name, string constraints, params I /// 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) + [SuppressMessage( "Style", "IDE0046:Convert to conditional expression", Justification = "NOT simpler" )] + public bool Equals( NestedClassName other ) { - if (other == null) + if(other is null) { return false; } - if (ReferenceEquals(this, other)) + 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 ); + return Keyword == other.Keyword + && Name == other.Name + && Constraints == other.Constraints + && StructuralComparisons.StructuralEqualityComparer.Equals( Children, other.Children ); } /// - public override bool Equals(object obj) + public override bool Equals( object obj ) { - return obj is NestedClassName parentClass && Equals(parentClass); + return obj is NestedClassName other + && Equals( other ); } /// - public override int GetHashCode() + public override int GetHashCode( ) { - return HashCode.Combine(Children, Keyword, Name, Constraints); + return HashCode.Combine( + Keyword, + Name, + Constraints, + StructuralComparisons.StructuralEqualityComparer.GetHashCode( Children ) + ); } } } diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/Result.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/Result.cs index 48258b4..020d7b9 100644 --- a/src/Ubiquity.NET.CodeAnalysis.Utils/Result.cs +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/Result.cs @@ -78,7 +78,7 @@ public Result(T? value, ImmutableArray diagnostics) /// 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; + public ImmutableArray Diagnostics { get; init; } = []; /// Gets a value indicating whether this result contains any diagnostics /// This is a shorthand for testing the length of the property diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/StructuralTypedConstantComparer.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/StructuralTypedConstantComparer.cs new file mode 100644 index 0000000..65ee204 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/StructuralTypedConstantComparer.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 +{ + /// Performs structural equality on a + /// + /// While implements it fails to + /// account for structural equatability of the value, that is, it is ONLY a shallow equality + /// check and that isn't valid for a source Roslyn component that is collecting data that + /// requires caching. + /// + public class StructuralTypedConstantComparer + : IEqualityComparer + , IEqualityComparer + { + /// Performs structural equality of two values + /// First comparand + /// Second comparand + /// true if and are structurally equal + [SuppressMessage( "Style", "IDE0046:Convert to conditional expression", Justification = "Nested conditional is not simpler" )] + public bool Equals( TypedConstant x, TypedConstant y ) + { + // handle fast checks first + if( (x.IsNull != y.IsNull) || (x.Kind != y.Kind) ) + { + return false; + } + + if(!SymbolEqualityComparer.Default.Equals( x.Type, y.Type )) + { + return false; + } + + // Due to how the properties are not simple accessors for the fields + // this has to use distinct computations of equality for arrays vs. scalars + bool retVal = x.Kind == TypedConstantKind.Array + ? StructuralComparisons.StructuralEqualityComparer.Equals( x.Values, y.Values ) + : StructuralComparisons.StructuralEqualityComparer.Equals( x.Value, y.Value ); + + return retVal; + } + + /// + public int GetHashCode( TypedConstant obj ) + { + return obj.Kind == TypedConstantKind.Array + ? HashCode.Combine( + obj.Kind, + SymbolEqualityComparer.IncludeNullability.GetHashCode( obj.Type ), + StructuralComparisons.StructuralEqualityComparer.GetHashCode( obj.Values ) + ) + : HashCode.Combine( + obj.Kind, + SymbolEqualityComparer.IncludeNullability.GetHashCode( obj.Type ), + StructuralComparisons.StructuralEqualityComparer.GetHashCode( obj.Value ) + ); + } + + /// Determines if and are both and structurally equal + /// First comparand + /// Second comparand + /// true if if and are both and structurally equal; false if not + public new bool Equals( object x, object y ) + { + return x is TypedConstant lhs + && y is TypedConstant rhs + && Equals( lhs, rhs ); + } + + /// + public int GetHashCode( object obj ) + { + return obj is TypedConstant value + ? GetHashCode( value ) + : obj?.GetHashCode() ?? 0; + } + + /// Gets the default instance of this comparer + public static StructuralTypedConstantComparer Default { get; } = new(); + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/StructurallyEquatableTypedConstant.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/StructurallyEquatableTypedConstant.cs new file mode 100644 index 0000000..5af2580 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/StructurallyEquatableTypedConstant.cs @@ -0,0 +1,101 @@ +// 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 +{ + /// Wrapper for that implements structural (deep) equality + /// + /// While implements it does so with a shallow + /// depth if indicates an array. Use of this, type expresses intent + /// to use structural equality (deep) semantics. It is intentionally designed where the size is the + /// same as it is just functionality that is different. + /// + public readonly struct StructurallyEquatableTypedConstant + : IEquatable + { + /// Initializes a new instance of the struct. + /// to wrap + public StructurallyEquatableTypedConstant(TypedConstant other) + { + InnerConst = other; + } + + /// + public TypedConstantKind Kind => InnerConst.Kind; + + /// + public ITypeSymbol? Type => InnerConst.Type; + + /// + public bool IsNull => InnerConst.IsNull; + + /// + public object? Value => InnerConst.Value; + + /// Gets the value for a array. + /// + /// ImmutableArray if was passed as the array value; + /// can be used to check for this. + /// + public ImmutableArray Values => [ .. InnerConst.Values.Select(e=> new StructurallyEquatableTypedConstant(e)) ]; + + /// Test if this instance is structurally equal to + /// Comparand + /// true if instance is structurally equal to ; false if not + public bool Equals( StructurallyEquatableTypedConstant other ) + { + return StructuralTypedConstantComparer.Default.Equals(InnerConst, other.InnerConst); + } + + /// Test if this instance is structurally equal to + /// Comparand + /// + /// true if obj is and this instance is structurally equal to ; false if not. + /// + public override bool Equals( object obj ) + { + return obj is StructurallyEquatableTypedConstant other + && Equals( other ); + } + + /// + public override int GetHashCode( ) + { + return StructuralTypedConstantComparer.Default.GetHashCode(InnerConst); + } + + public static bool operator ==( StructurallyEquatableTypedConstant left, StructurallyEquatableTypedConstant right ) + { + return left.Equals(right); + } + + public static bool operator !=( StructurallyEquatableTypedConstant left, StructurallyEquatableTypedConstant right ) + { + return !(left == right); + } + + /// Gets the wrapped + /// The inner + /// + /// This is useful for getting access to extension methods for + /// that are otherwise unrelated to equality. + /// + public TypedConstant ToTypedConstant() + { + return InnerConst; + } + + private readonly TypedConstant InnerConst; + + [SuppressMessage( "Usage", "CA2225:Operator overloads have named alternates", Justification = "Simple alternate for existing constructor" )] + public static implicit operator StructurallyEquatableTypedConstant( TypedConstant other ) + { + return new( other ); + } + + public static explicit operator TypedConstant( StructurallyEquatableTypedConstant other ) + { + return other.ToTypedConstant(); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/SymbolExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/SymbolExtensions.cs new file mode 100644 index 0000000..c2c980a --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/SymbolExtensions.cs @@ -0,0 +1,92 @@ +// 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 host extensions for + public static class SymbolExtensions + { + /// Tries to find the first attribute with a matching name for an + /// Symbol to test + /// Sequence of valid names of attributes to match + /// for the attribute + /// is null + /// + /// As an optimization, this does not test the full namespace of the attribute until a match + /// of the simple name is found. + /// + public static AttributeData? FindFirstMatchingAttribute( this ISymbol self, IEnumerable names ) + { + foreach(var attribute in MatchingAttributes( self, names )) + { + return attribute; + } + + return null; + } + + /// Tries to find the attributes with a matching name for an + /// Symbol to test + /// Sequence of valid names of attributes to match + /// for the attribute + /// is null + /// + /// As an optimization, this does not test the full namespace of the attribute until a match + /// of the simple name is found. + /// + public static ImmutableArray MatchingAttributes( this ISymbol self, IEnumerable names ) + { + if(self is null) + { + throw new ArgumentNullException( nameof( self ) ); + } + + var existingAttributes = self.GetAttributes(); + var retVal = ImmutableArray.CreateBuilder(existingAttributes.Length); + foreach(var attr in existingAttributes) + { + foreach(NamespaceQualifiedName name in names) + { + if(attr.IsFullNameMatch( name )) + { + retVal.Add(attr); + } + } + } + + return retVal.ToImmutable(); + } + + /// Captures the attributes with a matching name for an + /// Symbol to test + /// Sequence of valid names of attributes to match + /// for the attribute + /// is null + /// + /// As an optimization, this does not test the full namespace of the attribute until a match + /// of the simple name is found. + /// + public static ImmutableArray CaptureMatchingAttributes( this ISymbol self, IEnumerable names ) + { + if(self is null) + { + throw new ArgumentNullException( nameof( self ) ); + } + + var existingAttributes = self.GetAttributes(); + var retVal = ImmutableArray.CreateBuilder(existingAttributes.Length); + foreach(var attr in existingAttributes) + { + foreach(NamespaceQualifiedName name in names) + { + if(attr.IsFullNameMatch( name )) + { + retVal.Add( attr ); + } + } + } + + return retVal.ToImmutable(); + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/TypeSymbolExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/TypeSymbolExtensions.cs new file mode 100644 index 0000000..17907d6 --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/TypeSymbolExtensions.cs @@ -0,0 +1,70 @@ +// 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 TypeSymbolExtensions + { + /// Gets the full name of the symbol (Including namespace names) + /// Symbol to get the name from + /// Enumerable collection of the name parts with outer most namespace first + public static IEnumerable GetFullName( this ITypeSymbol self ) + { + foreach(string namespacePart in GetNamespaceNames( self )) + { + yield return namespacePart; + } + + yield return self.Name; + } + + /// Gets the sequence of namespaces names of the symbol (outermost to innermost) + /// Symbol to get the name from + /// Enumerable collection of the name parts with outer most namespace first + public static IEnumerable GetNamespaceNames( this ITypeSymbol self ) + { + return new NamespacePartReverseIterator(self); + } + + /// Gets the for a symbol + /// Symbol to get the name from + /// for the symbol + public static NamespaceQualifiedName GetNamespaceQualifiedName( this ITypeSymbol self ) + { + return new( GetNamespaceNames( self ), self.Name ); + } + + // private iterator to defer the perf hit for reverse walk until the names + // are iterated. The call to GetEnumerator() will take the hit to reverse walk + // the names. + private class NamespacePartReverseIterator + : IEnumerable + { + public NamespacePartReverseIterator( ITypeSymbol symbol ) + { + Symbol = symbol; + } + + public IEnumerator GetEnumerator( ) + { + // reverse the hierarchy using a stack as the first name isn't available + // until all are traversed. + var nameStack = new Stack( 64 ); + for(ISymbol current = Symbol.ContainingNamespace; current is not null && !string.IsNullOrEmpty( current.Name ); current = current.ContainingNamespace) + { + nameStack.Push( current.Name ); + } + + return nameStack.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator( ) + { + return GetEnumerator(); + } + + private readonly ITypeSymbol Symbol; + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/TypedConstantExtensions.cs b/src/Ubiquity.NET.CodeAnalysis.Utils/TypedConstantExtensions.cs new file mode 100644 index 0000000..e07e97c --- /dev/null +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/TypedConstantExtensions.cs @@ -0,0 +1,18 @@ +// 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 TypedConstantExtensions + { + /// Tests if a is a non null array + /// The constant to test + /// true if is not null and an array + public static bool IsNonNullArray( this TypedConstant self) + { + return !self.IsNull + && self.Kind == TypedConstantKind.Array; + } + } +} diff --git a/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj b/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj index 5f11094..d025fac 100644 --- a/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj +++ b/src/Ubiquity.NET.CodeAnalysis.Utils/Ubiquity.NET.CodeAnalysis.Utils.csproj @@ -6,11 +6,8 @@ 12 @@ -28,6 +25,7 @@ Apache-2.0 WITH LLVM-exception true snupkg + True @@ -37,9 +35,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -49,4 +47,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/src/Ubiquity.NET.CommandLine.UT/ArgsParsingTests.cs b/src/Ubiquity.NET.CommandLine.UT/ArgsParsingTests.cs index ab5fb3e..f5eea7c 100644 --- a/src/Ubiquity.NET.CommandLine.UT/ArgsParsingTests.cs +++ b/src/Ubiquity.NET.CommandLine.UT/ArgsParsingTests.cs @@ -15,13 +15,14 @@ public class ArgsParsingTests [TestMethod] public void ArgsParsing_Methods_handle_args_correctly( ) { - var settings = new CmdLineSettings(); + var settings = new CommandLineSettings(); var reporter = new TestReporter(); ParseResult parseResult = new RootCommand().Parse([]); var nullArgEx = Assert.ThrowsExactly(()=> _ = ArgsParsing.Parse( null, null ) ); Assert.AreEqual( "args", nullArgEx.ParamName ); +#pragma warning disable CS0618 // Type or member is obsolete // Overload 1 nullArgEx = Assert.ThrowsExactly( ( ) => _ = ArgsParsing.TryParse( null, out var _, out int _ ) ); Assert.AreEqual( "args", nullArgEx.ParamName ); @@ -31,7 +32,7 @@ public void ArgsParsing_Methods_handle_args_correctly( ) Assert.AreEqual( "args", nullArgEx.ParamName ); // Overload 2 [param 2] - nullArgEx = Assert.ThrowsExactly( ( ) => _ = ArgsParsing.TryParse( [], (CmdLineSettings?)null, out var _, out int _ ) ); + nullArgEx = Assert.ThrowsExactly( ( ) => _ = ArgsParsing.TryParse( [], (CommandLineSettings?)null, out var _, out int _ ) ); Assert.AreEqual( "settings", nullArgEx.ParamName ); // Overload 3 @@ -49,6 +50,7 @@ public void ArgsParsing_Methods_handle_args_correctly( ) // Overload 4 [param 3] (param 2 [settings] is nullable) nullArgEx = Assert.ThrowsExactly( ( ) => _ = ArgsParsing.TryParse( [], null, null, out var _, out int _ ) ); Assert.AreEqual( "diagnosticReporter", nullArgEx.ParamName ); +#pragma warning restore CS0618 // Type or member is obsolete } #pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type. @@ -66,10 +68,11 @@ public void TryParse_return_uses_corret_semantics( ) { // semantics of the return is "should exit" to disambiguate from // parsed correctly AND invoked default option (like help). - var settings = new CmdLineSettings(); + var settings = new CommandLineSettings(); var reporter = new TestReporter(); string[] args = ["--option1", "value"]; +#pragma warning disable CS0618 // Type or member is obsolete bool shouldExit = ArgsParsing.TryParse( args, out TestOptions? options, out int exitCode ); Assert.IsFalse( shouldExit ); Assert.IsNotNull( options ); @@ -115,6 +118,7 @@ public void TryParse_return_uses_corret_semantics( ) Assert.IsTrue( shouldExit ); Assert.IsNull( options ); Assert.AreEqual( 0, exitCode ); +#pragma warning restore CS0618 // Type or member is obsolete } } } diff --git a/src/Ubiquity.NET.CommandLine.UT/CommandLineTests.cs b/src/Ubiquity.NET.CommandLine.UT/CommandLineTests.cs index 8b79ace..78fdb9d 100644 --- a/src/Ubiquity.NET.CommandLine.UT/CommandLineTests.cs +++ b/src/Ubiquity.NET.CommandLine.UT/CommandLineTests.cs @@ -83,7 +83,7 @@ public void Extension_BuildRootCommand_succeeds( ) #if NET10_0_OR_GREATER // With C# 14 extensions the type of options is known, so more inference is possible - var cmd = TestOptions.BuildRootCommand( settings, o => { } ); + var cmd = TestOptions.BuildRootCommand( settings, ( o ) => { } ); Assert.IsNotNull( cmd ); cmd = TestOptions.BuildRootCommand( settings, ( o ) => 1 ); @@ -95,16 +95,16 @@ public void Extension_BuildRootCommand_succeeds( ) cmd = TestOptions.BuildRootCommand( settings, ( o, ct ) => Task.FromResult(1) ); Assert.IsNotNull( cmd ); #else - var cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o ) => { } ); + var cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o ) => { } ); Assert.IsNotNull( cmd ); - cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o ) => 1 ); + cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o ) => 1 ); Assert.IsNotNull( cmd ); - cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => Task.CompletedTask ); + cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => Task.CompletedTask ); Assert.IsNotNull( cmd ); - cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => Task.FromResult( 1 ) ); + cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => Task.FromResult( 1 ) ); Assert.IsNotNull( cmd ); #endif } @@ -147,31 +147,31 @@ public void Extentions_BuildRootCommand_throws_if_null( ) #else // overload 1 - var ex = Assert.ThrowsExactly( ( ) =>_ = CommandLineOptions.BuildRootCommand( null, ( TestOptions o ) => { })); + var ex = Assert.ThrowsExactly( ( ) =>_ = RootCommandBuilder.BuildRootCommand( null, ( TestOptions o ) => { })); Assert.AreEqual( "settings", ex.ParamName ); - ex = Assert.ThrowsExactly( ( ) => _ = CommandLineOptions.BuildRootCommand( settings, (Action?)null ) ); + ex = Assert.ThrowsExactly( ( ) => _ = RootCommandBuilder.BuildRootCommand( settings, (Action?)null ) ); Assert.AreEqual( "action", ex.ParamName ); // overload 2 - ex = Assert.ThrowsExactly( ( ) => _ = CommandLineOptions.BuildRootCommand( null, ( TestOptions o ) => 1 ) ); + ex = Assert.ThrowsExactly( ( ) => _ = RootCommandBuilder.BuildRootCommand( null, ( TestOptions o ) => 1 ) ); Assert.AreEqual( "settings", ex.ParamName ); - ex = Assert.ThrowsExactly( ( ) => _ = CommandLineOptions.BuildRootCommand( settings, (Func?)null ) ); + ex = Assert.ThrowsExactly( ( ) => _ = RootCommandBuilder.BuildRootCommand( settings, (Func?)null ) ); Assert.AreEqual( "action", ex.ParamName ); // overload 3 - ex = Assert.ThrowsExactly( ( ) => _ = CommandLineOptions.BuildRootCommand( null, ( TestOptions o, CancellationToken ct ) => Task.CompletedTask ) ); + ex = Assert.ThrowsExactly( ( ) => _ = RootCommandBuilder.BuildRootCommand( null, ( TestOptions o, CancellationToken ct ) => Task.CompletedTask ) ); Assert.AreEqual( "settings", ex.ParamName ); - ex = Assert.ThrowsExactly( ( ) => _ = CommandLineOptions.BuildRootCommand( settings, (Func?)null ) ); + ex = Assert.ThrowsExactly( ( ) => _ = RootCommandBuilder.BuildRootCommand( settings, (Func?)null ) ); Assert.AreEqual( "action", ex.ParamName ); // overload 4 - ex = Assert.ThrowsExactly( ( ) => _ = CommandLineOptions.BuildRootCommand( null, ( TestOptions o, CancellationToken ct ) => Task.FromResult( 1 ) ) ); + ex = Assert.ThrowsExactly( ( ) => _ = RootCommandBuilder.BuildRootCommand( null, ( TestOptions o, CancellationToken ct ) => Task.FromResult( 1 ) ) ); Assert.AreEqual( "settings", ex.ParamName ); - ex = Assert.ThrowsExactly( ( ) => _ = CommandLineOptions.BuildRootCommand( settings, (Func>?)null ) ); + ex = Assert.ThrowsExactly( ( ) => _ = RootCommandBuilder.BuildRootCommand( settings, (Func>?)null ) ); Assert.AreEqual( "action", ex.ParamName ); #endif } @@ -192,7 +192,7 @@ public void Extensions_ParseAndInvokeResult_invokes_provided_action( ) var cmd = TestOptions.BuildRootCommand( settings, ( o ) => actionCalled = true); Assert.IsNotNull( cmd ); int exitCode = cmd.ParseAndInvokeResult( reporter, settings, testArgs ); - Assert.IsTrue( actionCalled ); + Assert.IsTrue( actionCalled, "action should be called" ); Assert.AreEqual( 0, exitCode ); // Overload 2 @@ -205,7 +205,7 @@ public void Extensions_ParseAndInvokeResult_invokes_provided_action( ) actionCalled = false; exitCode = cmd.ParseAndInvokeResult( reporter, settings, testArgs ); - Assert.IsTrue( actionCalled ); + Assert.IsTrue( actionCalled, "action should be called" ); Assert.AreEqual( 1, exitCode ); // Overload 3 @@ -218,7 +218,7 @@ public void Extensions_ParseAndInvokeResult_invokes_provided_action( ) actionCalled = false; exitCode = cmd.ParseAndInvokeResult( reporter, settings, testArgs ); - Assert.IsTrue( actionCalled ); + Assert.IsTrue( actionCalled, "action should be called" ); Assert.AreEqual( 0, exitCode ); // Overload 4 @@ -235,14 +235,14 @@ public void Extensions_ParseAndInvokeResult_invokes_provided_action( ) Assert.AreEqual( 1, exitCode ); #else // Overload 1 - var cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o ) => actionCalled = true); + var cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o ) => actionCalled = true); Assert.IsNotNull( cmd ); int exitCode = cmd.ParseAndInvokeResult( reporter, settings, testArgs ); Assert.IsTrue( actionCalled ); Assert.AreEqual( 0, exitCode ); // Overload 2 - cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o ) => + cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o ) => { actionCalled = true; return 1; @@ -255,7 +255,7 @@ public void Extensions_ParseAndInvokeResult_invokes_provided_action( ) Assert.AreEqual( 1, exitCode ); // Overload 3 - cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => + cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => { actionCalled = true; return Task.CompletedTask; @@ -268,7 +268,7 @@ public void Extensions_ParseAndInvokeResult_invokes_provided_action( ) Assert.AreEqual( 0, exitCode ); // Overload 4 - cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => + cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => { actionCalled = true; return Task.FromResult( 1 ); @@ -340,14 +340,14 @@ public async Task Extensions_ParseAndInvokeResultAsync_invokes_provided_action( Assert.AreEqual( 1, exitCode ); #else // Overload 1 - var cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o ) => actionCalled = true); + var cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o ) => actionCalled = true); Assert.IsNotNull( cmd ); int exitCode = await cmd.ParseAndInvokeResultAsync( reporter, settings, TestContext.CancellationToken, testArgs ); Assert.IsTrue( actionCalled ); Assert.AreEqual( 0, exitCode ); // Overload 2 - cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o ) => + cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o ) => { actionCalled = true; return 1; @@ -360,7 +360,7 @@ public async Task Extensions_ParseAndInvokeResultAsync_invokes_provided_action( Assert.AreEqual( 1, exitCode ); // Overload 3 - cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => + cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => { actionCalled = true; return Task.CompletedTask; @@ -373,7 +373,7 @@ public async Task Extensions_ParseAndInvokeResultAsync_invokes_provided_action( Assert.AreEqual( 0, exitCode ); // Overload 4 - cmd = CommandLineOptions.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => + cmd = RootCommandBuilder.BuildRootCommand( settings, ( TestOptions o, CancellationToken ct ) => { actionCalled = true; return Task.FromResult( 1 ); @@ -387,9 +387,9 @@ public async Task Extensions_ParseAndInvokeResultAsync_invokes_provided_action( #endif } - internal static CmdLineSettings CreateTestSettings( DefaultOption defaultOptions = DefaultOption.Help | DefaultOption.Version ) + internal static CommandLineSettings CreateTestSettings( DefaultOption defaultOptions = DefaultOption.Help | DefaultOption.Version ) { - return new CmdLineSettings() + return new CommandLineSettings() { DefaultOptions = defaultOptions, }; diff --git a/src/Ubiquity.NET.CommandLine.UT/TestOptions.g.cs b/src/Ubiquity.NET.CommandLine.UT/TestOptions.g.cs index 531e436..20f9ad9 100644 --- a/src/Ubiquity.NET.CommandLine.UT/TestOptions.g.cs +++ b/src/Ubiquity.NET.CommandLine.UT/TestOptions.g.cs @@ -7,9 +7,10 @@ namespace Ubiquity.NET.CommandLine.UT { - // FUTURE: Generate this with source generator from attributes in other partial declaration + // FUTURE: TEST Generation of this with source generator from attributes in other partial declaration internal partial class TestOptions - : ICommandLineOptions + : IRootCommandBuilder + , ICommandBinder { public static TestOptions Bind( ParseResult parseResult ) { @@ -19,7 +20,7 @@ public static TestOptions Bind( ParseResult parseResult ) }; } - public static AppControlledDefaultsRootCommand BuildRootCommand( CmdLineSettings settings ) + public static AppControlledDefaultsRootCommand Build( CommandLineSettings settings ) { return new( settings, "Test option root command") { diff --git a/src/Ubiquity.NET.CommandLine/AppControlledDefaultsRootCommand.cs b/src/Ubiquity.NET.CommandLine/AppControlledDefaultsRootCommand.cs index a785734..bd51d97 100644 --- a/src/Ubiquity.NET.CommandLine/AppControlledDefaultsRootCommand.cs +++ b/src/Ubiquity.NET.CommandLine/AppControlledDefaultsRootCommand.cs @@ -5,8 +5,9 @@ namespace Ubiquity.NET.CommandLine { /// Extension of that allows app control of defaults that are otherwise forced /// - /// This type is derived from and offers no additional behavior beyond the construction. - /// The constructor will adapt the command based on the provided. This moves the + /// This type is derived from and offers little additional behavior beyond the construction. + /// (Captures the settings for use with invocation, and the overloads to parse and invoke a result using the captured settings) + /// The constructor will adapt the command based on the provided. This moves the /// hard coded defaults into an app controlled domain. The default constructed settings matches the behavior of /// so there's no distinction. This allows an application to explicitly decide the behavior /// and support of various defaults that could otherwise surprise the author/user. This is especially important when @@ -21,10 +22,11 @@ public class AppControlledDefaultsRootCommand /// Initializes a new instance of the class. /// Description of this root command /// Settings to apply for the command parsing - public AppControlledDefaultsRootCommand( CmdLineSettings settings, string description = "" ) + public AppControlledDefaultsRootCommand( CommandLineSettings settings, string description = "" ) : base( description ) { ArgumentNullException.ThrowIfNull(settings); + Settings = settings; // RootCommand constructor already adds HelpOption and VersionOption so remove them // unless specified by caller. @@ -58,5 +60,46 @@ public AppControlledDefaultsRootCommand( CmdLineSettings settings, string descri Add( new EnvironmentVariablesDirective() ); } } + + /// Gets the settings used for creation and subsequent invocation + public CommandLineSettings Settings { get; } + + /// Parses a root command and invokes the results + /// Diagnostic reporter to use for any errors or information in parsing + /// Command line arguments to parse + /// Exit code of the invocation + /// + /// If the is an asynchronous command action then this will + /// BLOCK the current thread until it completes. If that is NOT the desired behavior then + /// callers should use + /// instead for explicit async operation. + /// + public int ParseAndInvokeResult( + IDiagnosticReporter reporter, + params string[] args + ) + { + return RootCommandExtensions.ParseAndInvokeResult(this, reporter, Settings, args); + } + + /// Parses a root command and invokes the results + /// Diagnostic reporter to use for any errors or information in parsing + /// Cancellation token for the operation + /// Command line arguments to parse + /// Exit code of the invocation + /// + /// If the is an synchronous command action then this will + /// run the action asynchronously. If that is NOT the desired behavior then callers should + /// use + /// instead for an explicit synchronous operation. + /// + public Task ParseAndInvokeResultAsync( + IDiagnosticReporter reporter, + CancellationToken ct, + params string[] args + ) + { + return RootCommandExtensions.ParseAndInvokeResultAsync( this, reporter, Settings, ct, args ); + } } } diff --git a/src/Ubiquity.NET.CommandLine/ArgsParsing.cs b/src/Ubiquity.NET.CommandLine/ArgsParsing.cs index fc34b57..457d226 100644 --- a/src/Ubiquity.NET.CommandLine/ArgsParsing.cs +++ b/src/Ubiquity.NET.CommandLine/ArgsParsing.cs @@ -13,7 +13,7 @@ namespace Ubiquity.NET.CommandLine public static class ArgsParsing { /// Parses the command line - /// Type of value to bind the results to [Must implement ] + /// Type of value to bind the results to [Must implement AND ] /// args array for the command line /// Settings for the parse /// Results of the parse @@ -39,18 +39,25 @@ public static class ArgsParsing /// implementation simply removes the actions and validation to leave both stages to the calling application as it keeps /// things clearer when stages are unique /// - /// - public static ParseResult Parse( string[] args, CmdLineSettings? settings = null ) - where T : ICommandLineOptions + public static ParseResult Parse( string[] args, CommandLineSettings? settings = null ) + where T : IRootCommandBuilder { ArgumentNullException.ThrowIfNull(args); - settings ??= new CmdLineSettings(); - RootCommand rootCommand = T.BuildRootCommand(settings); + settings ??= new CommandLineSettings(); + RootCommand rootCommand = T.Build(settings); return rootCommand.Parse( args, settings ); } - // FUTURE: Move these extension methods to ParseResultExtensions (ABI breaking change, but not source level) + /// + public static ParseResult Parse( string[] args ) + where T : IRootCommandBuilderWithSettings + { + ArgumentNullException.ThrowIfNull( args ); + + RootCommand rootCommand = T.Build(); + return rootCommand.Parse( args, T.Settings ); + } /// Invokes default options ( or ) /// Result of parse @@ -60,16 +67,12 @@ public static ParseResult Parse( string[] args, CmdLineSettings? settings = n /// /// The results of invoking defaults is a tuple of a flag indicating if the app should exit - default command handled, /// and the exit code for the application. The exit code is undefined if the flag indicates the app should not exit (e.g., not - /// handled). If it is defined, then that is what the app should return. It may be 0 if the command had no errors. But if there - /// was an error with the execution of the default option. - /// - /// This is a back-compat entry point that provides behavior of original release that didn't have an overload to - /// specify the use of the default handler or the timeout. - /// + /// handled). If it is defined, then that is what the app should return. It may be 0 if the command had no errors. But won't be + /// if there was an error with the execution of the default option. /// public static DefaultHandlerInvocationResult InvokeDefaultOptions( this ParseResult parseResult, - CmdLineSettings settings, + CommandLineSettings settings, IDiagnosticReporter diagnosticReporter ) { @@ -118,10 +121,10 @@ public static bool ReportErrors( this ParseResult parseResult, IDiagnosticReport /// /// In short, this wraps the following sequence of common operations and exiting on completion of /// any operation with errors or successful invocation of default options:
- /// 1)
+ /// 1)
/// 2)
- /// 3)
- /// 4)
+ /// 3)
+ /// 4)
/// /// The is set to the exit code for the app on failures. This code indicates the /// parse errors and is the result of invoking which, as of the current release, @@ -130,8 +133,8 @@ public static bool ReportErrors( this ParseResult parseResult, IDiagnosticReport /// is documented and stable. /// /// It is recommended that applications NOT use this method (It will likely be obsoleted in the next release). - /// Instead applications should use the - /// or methods + /// Instead applications should use the + /// or methods /// instead. These include invocation in the name and the result is more consistent with expectations. /// This method has a confusing return code (and was even incorrect in some cases). Nullability of the /// is not guaranteed based on the return value semantics. (It is null if parsed correctly AND the default options were handled). @@ -139,21 +142,22 @@ public static bool ReportErrors( this ParseResult parseResult, IDiagnosticReport /// /// /// + [Obsolete( "Use RootCommandExtensions.ParseAndInvokeResult instead")] public static bool TryParse( string[] args, - CmdLineSettings? settings, + CommandLineSettings? settings, IDiagnosticReporter diagnosticReporter, out T? boundValue, out int exitCode ) - where T : ICommandLineOptions + where T : IRootCommandBuilder, ICommandBinder { ArgumentNullException.ThrowIfNull( args ); ArgumentNullException.ThrowIfNull( diagnosticReporter ); - settings ??= new CmdLineSettings(); + settings ??= new CommandLineSettings(); boundValue = default; - RootCommand rootCommand = T.BuildRootCommand(settings); + RootCommand rootCommand = T.Build(settings); ParseResult parseResult = rootCommand.Parse( args, settings ); // Special case the default options (Help/Version) before checking for reported errors @@ -179,24 +183,27 @@ out int exitCode return false; // return semantics is "should exit". } - /// - public static bool TryParse( string[] args, CmdLineSettings settings, out T? boundValue, out int exitCode ) - where T : ICommandLineOptions + /// + [Obsolete( "Use RootCommandExtensions.ParseAndInvokeResult instead" )] + public static bool TryParse( string[] args, CommandLineSettings settings, out T? boundValue, out int exitCode ) + where T : IRootCommandBuilder, ICommandBinder { ArgumentNullException.ThrowIfNull( settings ); return TryParse( args, settings, new ConsoleReporter( MsgLevel.Information ), out boundValue, out exitCode ); } - /// + /// + [Obsolete( "Use RootCommandExtensions.ParseAndInvokeResult instead" )] public static bool TryParse( string[] args, out T? boundValue, out int exitCode ) - where T : ICommandLineOptions + where T : IRootCommandBuilder, ICommandBinder { return TryParse( args, settings: null, new ConsoleReporter( MsgLevel.Information ), out boundValue, out exitCode ); } - /// + /// + [Obsolete( "Use RootCommandExtensions.ParseAndInvokeResult instead" )] public static bool TryParse( string[] args, IDiagnosticReporter diagnosticReporter, out T? boundValue, out int exitCode ) - where T : ICommandLineOptions + where T : IRootCommandBuilder, ICommandBinder { return TryParse( args, settings: null, diagnosticReporter, out boundValue, out exitCode ); } diff --git a/src/Ubiquity.NET.CommandLine/CommandLineOptions.cs b/src/Ubiquity.NET.CommandLine/CommandLineOptions.cs deleted file mode 100644 index b9d4c92..0000000 --- a/src/Ubiquity.NET.CommandLine/CommandLineOptions.cs +++ /dev/null @@ -1,168 +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. - -namespace Ubiquity.NET.CommandLine -{ - /// Utility class to host extensions to - /// - /// - /// If C# 14 and the `extension` everything feature is supported these will leverage it such that - /// the class name is not needed. Otherwise, for older targets the type name is required. - /// While default static methods on an interface is allowed by the language, use of them requires - /// explicitly using the interface name and not the type. Additionally, since the interface is generic, - /// this runs afoul of CA1000: Do not declare static members on generic types. Thus, this compromise - /// is used to at least simplify the usage as much as possible. Direct use of these as static methods - /// is valid syntax in both runtimes so is the safest approach for code that targets multiple runtimes. - /// - /// - /// - /// C# example (pre .NET 10): - /// ( settings, (MyOptions o)=>1); - /// ]]> - /// - /// - /// C# example (.NET 10 or later): - /// 1); - /// - /// // Legacy syntax is still valid - /// CommandLineOptions.BuildRootCommand( settings, (MyOptions o)=>1); - /// ]]> - /// -#if NET10_0_OR_GREATER - [SuppressMessage("Design", "CA1034: Nested types should not be visible", Justification = "Extension, compiler generates nested types, deal with it")] - [SuppressMessage( "Design", "CA1000:Do not declare static members on generic types", Justification = "Only Option for sensible default implementations" )] -#endif - public static class CommandLineOptions - { -#if NET10_0_OR_GREATER - /// C# 14 static Extensions for common methods support - /// Type to bind the options to - extension( ICommandLineOptions ) - where T : ICommandLineOptions - { - /// Build a root command with synchronous handler - /// Settings to use for the command - /// Function to handle the command and returns an exit code - /// built with as the handler. - public static AppControlledDefaultsRootCommand BuildRootCommand( CmdLineSettings settings, Func action ) - { - ArgumentNullException.ThrowIfNull( settings ); - ArgumentNullException.ThrowIfNull( action ); - - var retVal = T.BuildRootCommand(settings); - retVal.SetAction( pr => action( T.Bind( pr ) ) ); - return retVal; - } - - /// Build a root command with synchronous handler - /// Settings to use for the command - /// Action to handle the command (Exceptions are treated as an exit code of 1; otherwise 0;) - /// built with as the handler. - public static AppControlledDefaultsRootCommand BuildRootCommand( CmdLineSettings settings, Action action ) - { - ArgumentNullException.ThrowIfNull( settings ); - ArgumentNullException.ThrowIfNull( action ); - - var retVal = T.BuildRootCommand(settings); - retVal.SetAction( pr => action( T.Bind( pr ) ) ); - return retVal; - } - - /// Build a root command with asynchronous handler - /// Settings to use for the command - /// Function to handle the command and returns an exit code - /// built with as the handler. - public static AppControlledDefaultsRootCommand BuildRootCommand( CmdLineSettings settings, Func> action ) - { - ArgumentNullException.ThrowIfNull( settings ); - ArgumentNullException.ThrowIfNull( action ); - - var retVal = T.BuildRootCommand(settings); - retVal.SetAction( (pr, ct) => action( T.Bind( pr ), ct ) ); - return retVal; - } - - /// Build a root command with asynchronous handler - /// Settings to use for the command - /// Function to handle the command (Exceptions are treated as an exit code of 1; otherwise 0;) - /// built with as the handler. - public static AppControlledDefaultsRootCommand BuildRootCommand( CmdLineSettings settings, Func action ) - { - ArgumentNullException.ThrowIfNull( settings ); - ArgumentNullException.ThrowIfNull( action ); - - var retVal = T.BuildRootCommand(settings); - retVal.SetAction( (pr, ct) => action( T.Bind( pr ), ct ) ); - return retVal; - } - } -#else - /// Build a root command with a synchronous handler - /// Type to bind the options to - /// Settings to use for the command - /// Action to handle the command and returns an exit code - /// built with as the handler. - public static AppControlledDefaultsRootCommand BuildRootCommand( CmdLineSettings settings, Func action ) - where T : ICommandLineOptions - { - ArgumentNullException.ThrowIfNull(settings); - ArgumentNullException.ThrowIfNull(action); - - var retVal = T.BuildRootCommand(settings); - retVal.SetAction( pr => action( T.Bind( pr ) ) ); - return retVal; - } - - /// Build a root command with async handler that returns an application specific exit code (0 == Success/No Error) - /// Type to bind the options to - /// Settings to use for the command - /// Async action to handle the command and returns an exit code (via ) - /// built with as the handler. - public static AppControlledDefaultsRootCommand BuildRootCommand( CmdLineSettings settings, Func> action ) - where T : ICommandLineOptions - { - ArgumentNullException.ThrowIfNull(settings); - ArgumentNullException.ThrowIfNull(action); - - var retVal = T.BuildRootCommand(settings); - retVal.SetAction( (pr, ct) => action( T.Bind( pr ), ct ) ); - return retVal; - } - - /// Build a root command with async handler - /// Type to bind the options to - /// Settings to use for the command - /// Action to handle the command and returns an exit code - /// built with as the handler. - public static AppControlledDefaultsRootCommand BuildRootCommand( CmdLineSettings settings, Func action ) - where T : ICommandLineOptions - { - ArgumentNullException.ThrowIfNull(settings); - ArgumentNullException.ThrowIfNull(action); - - var retVal = T.BuildRootCommand(settings); - retVal.SetAction( (pr, ct) => action( T.Bind( pr ), ct ) ); - return retVal; - } - - /// Build a root command with a synchronous handler - /// Type to bind the options to - /// Settings to use for the command - /// Action to handle the command - /// built with as the handler. - public static AppControlledDefaultsRootCommand BuildRootCommand( CmdLineSettings settings, Action action ) - where T : ICommandLineOptions - { - ArgumentNullException.ThrowIfNull(settings); - ArgumentNullException.ThrowIfNull(action); - - var retVal = T.BuildRootCommand(settings); - retVal.SetAction( pr => action( T.Bind( pr ) ) ); - return retVal; - } -#endif - } -} diff --git a/src/Ubiquity.NET.CommandLine/CmdLineSettings.cs b/src/Ubiquity.NET.CommandLine/CommandLineSettings.cs similarity index 95% rename from src/Ubiquity.NET.CommandLine/CmdLineSettings.cs rename to src/Ubiquity.NET.CommandLine/CommandLineSettings.cs index fe1f2cd..e24f665 100644 --- a/src/Ubiquity.NET.CommandLine/CmdLineSettings.cs +++ b/src/Ubiquity.NET.CommandLine/CommandLineSettings.cs @@ -46,11 +46,11 @@ public enum DefaultDirective /// The default values follows the default behaviors of the underlying library. This ensures the /// principle of least surprise while allowing for explicit overrides /// - public class CmdLineSettings + public class CommandLineSettings { /// Gets a value indicating whether errors reported should also show the help message [Default: ] /// This has a default, which is the opposite of the default from - public bool ShowHelpOnErrors { get; init; } = true; + public bool ShowHelpOnErrors { get; init; } = false; /// Gets a value indicating whether errors reported should also show Typo corrections [Default: ] /// This has a default, which is the opposite of the default from @@ -128,9 +128,9 @@ public ParserConfiguration ToParserConfiguration( ) private bool HasCustomeResponseFileBehavior = false; - /// Implicitly constructs a new based on an instance of + /// Implicitly constructs a new based on an instance of /// The settings to build the configuration from - public static implicit operator ParserConfiguration( CmdLineSettings self ) + public static implicit operator ParserConfiguration( CommandLineSettings self ) { return self.ToParserConfiguration(); } @@ -143,7 +143,7 @@ public static implicit operator ParserConfiguration( CmdLineSettings self ) /// specify them explicitly as a custom option. Then validation is customized to handle /// behavior as desired by the app. /// - public static CmdLineSettings NoDefaults { get; } + public static CommandLineSettings NoDefaults { get; } = new() { DefaultDirectives = DefaultDirective.None, diff --git a/src/Ubiquity.NET.CommandLine/DiagnosticMessage.cs b/src/Ubiquity.NET.CommandLine/DiagnosticMessage.cs index 4d1ed75..da19cd1 100644 --- a/src/Ubiquity.NET.CommandLine/DiagnosticMessage.cs +++ b/src/Ubiquity.NET.CommandLine/DiagnosticMessage.cs @@ -43,6 +43,7 @@ public readonly record struct DiagnosticMessage /// Gets the location in source for the origin of this message public SourceRange? Location { get; init; } +#if NET10_0_OR_GREATER /// Gets the subcategory of the message public string? Subcategory { @@ -99,6 +100,69 @@ public string Text field = value; } } +#else + /// Gets the subcategory of the message + public string? Subcategory + { + get => SubcategoryBackingField; + init + { + if(value is not null && value.Any( ( c ) => char.IsWhiteSpace( c ) )) + { + throw new ArgumentException( "If provided, value must not contain whitespace", nameof( value ) ); + } + + SubcategoryBackingField = value; + } + } + + /// Gets the Level/Category of the message + public MsgLevel Level + { + get => LevelBackingField; + init + { + value.ThrowIfNotDefined(); + if(value == MsgLevel.None) + { + throw new InvalidEnumArgumentException( nameof( value ), 0, typeof( MsgLevel ) ); + } + + LevelBackingField = value; + } + } + + /// Gets the code for the message (No spaces) + public string? Code + { + get => CodeBackingField; + init + { + if(value is not null && value.Any( ( c ) => char.IsWhiteSpace( c ) )) + { + throw new ArgumentException( "If provided, value must not contain whitespace", nameof( value ) ); + } + + CodeBackingField = value; + } + } + + /// Gets the text of the message + public string Text + { + get => TextBackingField; + init + { + ArgumentException.ThrowIfNullOrWhiteSpace( value ); + TextBackingField = value; + } + } + + private readonly string? SubcategoryBackingField; + private readonly MsgLevel LevelBackingField; + private readonly string? CodeBackingField; + private readonly string TextBackingField; +#endif /// Formats this instance using the general runtime specific format /// Formatted string for the message diff --git a/src/Ubiquity.NET.CommandLine/ICommandBinder.cs b/src/Ubiquity.NET.CommandLine/ICommandBinder.cs new file mode 100644 index 0000000..4ccf1e2 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine/ICommandBinder.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.CommandLine +{ + /// Interface for binding operations for a command + /// Type of the final results of a bind operation + /// + /// This provides a static interface for types that create from + /// a (bind). + /// + public interface ICommandBinder + where T : ICommandBinder + { + /// Binds the results of the parse to a new instance of + /// Results of the parse to bind + /// Newly constructed instance of with properties bound from + /// + /// The implementation of this method allows pure source code AOT (Zero-Runtime-Reflection) binding. + /// Normally this is generated by a source generator to leverage compile time reflection. But for simple + /// applications hand generation is often used. (Until a generator is implemented that's the only + /// option actually). + /// + /// Thrown when required argument or option was not parsed or has no default value configured. + public static abstract T Bind( ParseResult parseResult ); + } +} diff --git a/src/Ubiquity.NET.CommandLine/ICommandLineOptions.cs b/src/Ubiquity.NET.CommandLine/ICommandLineOptions.cs deleted file mode 100644 index 97b290c..0000000 --- a/src/Ubiquity.NET.CommandLine/ICommandLineOptions.cs +++ /dev/null @@ -1,47 +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. - -namespace Ubiquity.NET.CommandLine -{ - /// Interface for a root command in command line parsing - /// Type of the command (CRTP) - /// - /// This only contains a static interface and thus requires the static abstract feature of the runtime. - /// (.NET 7 or later) It is used to constrain methods to those that have static methods that are specifically - /// designed for command line parsing. - /// - public interface ICommandLineOptions - where T : ICommandLineOptions - { - /// Binds the results of the parse to a new instance of - /// Results of the parse to bind - /// Newly constructed instance of with properties bound from - /// - /// The implementation of this method allows pure source code AOT (Zero-Runtime-Reflection) binding. - /// Normally this is generated by a source generator to leverage compile time reflection. But for simple - /// applications hand generation is often used. (Until a generator is implemented that's the only - /// option actually). - /// - /// Thrown when required argument or option was not parsed or has no default value configured. - public static abstract T Bind( ParseResult parseResult ); - - /// Builds a new for parsing the command line - /// Settings to use for parsing - /// New instance for - /// - /// - /// The settings determine the default Options and Directives, etc... to add or remove from the command. - /// contains hard coded defaults without any construction - /// configuration. takes care of adding or removing things - /// based on the to make the defaults app controlled. - /// - /// Normally, this just creates an instance of and initializes - /// it with all of the s for a given command line - /// The implementation of this method allows pure source code AOT (Zero-Runtime-Reflection) description. - /// Normally this is generated by a source generator to leverage compile time reflection. But for simple - /// applications hand generation is often used. (Until a generator is implemented that's the only - /// option actually). - /// - public static abstract AppControlledDefaultsRootCommand BuildRootCommand( CmdLineSettings settings ); - } -} diff --git a/src/Ubiquity.NET.CommandLine/IRootCommandBuilder.cs b/src/Ubiquity.NET.CommandLine/IRootCommandBuilder.cs new file mode 100644 index 0000000..91e1c98 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine/IRootCommandBuilder.cs @@ -0,0 +1,65 @@ +// 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.CommandLine +{ + /// Interface for a root command in command line parsing + /// + /// This only contains a static interface and thus requires the static abstract feature of the runtime. + /// (.NET 7 or later) It is used to constrain methods to those that have static methods that are specifically + /// designed for command line parsing. + /// + public interface IRootCommandBuilder + { + /// Builds a new for parsing the command line + /// Settings to use for parsing + /// New instance for the command + /// + /// + /// The settings determine the default Options and Directives, etc... to add or remove from the command. + /// contains hard coded defaults without any construction + /// configuration. takes care of adding or removing things + /// based on the to make the defaults app controlled. + /// + /// Normally, this just creates an instance of and initializes + /// it with all of the s for a given command line + /// + /// The implementation of this method allows pure source code AOT (Zero-Runtime-Reflection) description. + /// + /// + public static abstract AppControlledDefaultsRootCommand Build( CommandLineSettings settings ); + } + + /// Interface for a root command in command line parsing with settings + /// + /// This only contains a static interface and thus requires the static abstract feature of the runtime. + /// (.NET 7 or later) It is used to constrain methods to those that have static methods that are specifically + /// designed for command line parsing. + /// The implementation of this method allows pure source code AOT (Zero-Runtime-Reflection) description. + /// This is ideally provided by code generation tools from an attribute, though implementing such a thing has proven + /// more difficult than is ideal. Though it is possible to specify this manually until such a thing exists or + /// in more advanced scenarios. + /// + public interface IRootCommandBuilderWithSettings + { + /// Gets the to use for building this command + public static abstract CommandLineSettings Settings { get; } + + /// Builds a new for parsing the command line + /// New instance for the command + /// + /// + /// The settings determine the default Options and Directives, etc... to add or remove from the command. + /// contains hard coded defaults without any construction + /// configuration. takes care of adding or removing things + /// based on the to make the defaults app controlled. + /// + /// Normally, this just creates an instance of and initializes + /// it with all of the s for a given command line + /// The implementation of this method allows pure source code AOT (Zero-Runtime-Reflection) description. + /// Normally, this is generated by a source generator to leverage compile time reflection. But for simple + /// applications hand generation is often used. + /// + public static abstract AppControlledDefaultsRootCommand Build( ); + } +} diff --git a/src/Ubiquity.NET.CommandLine/PackageReadMe.md b/src/Ubiquity.NET.CommandLine/PackageReadMe.md index 6e42291..c8ad5ff 100644 --- a/src/Ubiquity.NET.CommandLine/PackageReadMe.md +++ b/src/Ubiquity.NET.CommandLine/PackageReadMe.md @@ -28,7 +28,8 @@ namespace TestSample } } ``` -Options.g.cs + +Options.g.cs: ``` C# using System; using System.Collections.Generic; diff --git a/src/Ubiquity.NET.CommandLine/ParseResultExtensions.cs b/src/Ubiquity.NET.CommandLine/ParseResultExtensions.cs index f0c770f..a2d61fe 100644 --- a/src/Ubiquity.NET.CommandLine/ParseResultExtensions.cs +++ b/src/Ubiquity.NET.CommandLine/ParseResultExtensions.cs @@ -106,7 +106,7 @@ internal static IEnumerable RecurseWhileNotNull( this T? source, Func public static DefaultHandlerInvocationResult InvokeDefaultOptions( this ParseResult parseResult, - CmdLineSettings settings, + CommandLineSettings settings, IDiagnosticReporter diagnosticReporter, bool enableDefaultHandler, TimeSpan? timeout = null diff --git a/src/Ubiquity.NET.CommandLine/RootCommandBuilder.cs b/src/Ubiquity.NET.CommandLine/RootCommandBuilder.cs new file mode 100644 index 0000000..5f7c6a0 --- /dev/null +++ b/src/Ubiquity.NET.CommandLine/RootCommandBuilder.cs @@ -0,0 +1,279 @@ +// 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.CommandLine +{ + /// Utility support for types implementing + /// + /// + /// If C# 14 and the `extension` everything feature is supported these will leverage it such that + /// the class name is not needed. Otherwise, for older targets the extension type name is + /// required. While default static methods on an interface is allowed by the language, + /// use of them requires explicitly using the interface name and not the type. Additionally, since the + /// interface is generic, such use runs afoul of CA1000: Do not declare static members on generic types. + /// Thus, this compromise is used to at least simplify the usage as much as possible. Direct use of these + /// as static methods is valid syntax in both scenarios so is the safest approach for code that targets + /// multiple runtimes. + /// + /// + /// + /// C# example (pre .NET 10): + /// ( settings, (MyOptions o)=>1); + /// ]]> + /// + /// + /// C# example (.NET 10 or later): + /// 1); + /// + /// // Legacy syntax is still valid + /// RootCommandBuilder.BuildRootCommand( settings, (MyOptions o)=>1); + /// ]]> + /// +#if NET10_0_OR_GREATER + // see: https://github.com/dotnet/sdk/issues/51681 + // and: https://github.com/dotnet/roslyn-analyzers/issues/7765 + // and: https://github.com/dotnet/sdk/pull/51773 [Fix: but still pending...] + [SuppressMessage( "Design", "CA1034: Nested types should not be visible", Justification = "Extension, compiler generates nested types, deal with it" )] + [SuppressMessage( "Design", "CA1000:Do not declare static members on generic types", Justification = "Only Option for sensible default implementations" )] +#endif + public static class RootCommandBuilder + { +#if NET10_0_OR_GREATER + extension( T ) + where T : IRootCommandBuilder, ICommandBinder + { + /// Build a root command with a synchronous handler + /// Settings to use for the command + /// Action to handle the command and returns an exit code + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( CommandLineSettings settings, Func action ) + { + ArgumentNullException.ThrowIfNull( settings ); + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(settings); + retVal.SetAction( pr => action( T.Bind( pr ) ) ); + return retVal; + } + + /// Build a root command with async handler that returns an application specific exit code (0 == Success/No Error) + /// Settings to use for the command + /// Async action to handle the command and returns an exit code (via ) + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( CommandLineSettings settings, Func> action ) + { + ArgumentNullException.ThrowIfNull( settings ); + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(settings); + retVal.SetAction( ( pr, ct ) => action( T.Bind( pr ), ct ) ); + return retVal; + } + + /// Build a root command with async handler + /// Settings to use for the command + /// Action to handle the command and returns an exit code + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( CommandLineSettings settings, Func action ) + { + ArgumentNullException.ThrowIfNull( settings ); + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(settings); + retVal.SetAction( ( pr, ct ) => action( T.Bind( pr ), ct ) ); + return retVal; + } + + /// Build a root command with a synchronous handler + /// Settings to use for the command + /// Action to handle the command + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( CommandLineSettings settings, Action action ) + { + ArgumentNullException.ThrowIfNull( settings ); + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(settings); + retVal.SetAction( pr => action( T.Bind( pr ) ) ); + return retVal; + } + } + + extension( T ) + where T : IRootCommandBuilderWithSettings, ICommandBinder + { + /// Build a root command with async handler + /// Action to handle the command and returns an exit code + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( Func action ) + { + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(); + retVal.SetAction( ( pr, ct ) => action( T.Bind( pr ), ct ) ); + return retVal; + } + + /// Build a root command with async handler that returns an application specific exit code (0 == Success/No Error) + /// Async action to handle the command and returns an exit code (via ) + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( Func> action ) + { + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(); + retVal.SetAction( ( pr, ct ) => action( T.Bind( pr ), ct ) ); + return retVal; + } + + /// Build a root command with a synchronous handler that accepts the bound results of a parse + /// Action to handle the command and returns an exit code + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( Func action ) + { + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(); + retVal.SetAction( pr => action( T.Bind( pr ) ) ); + return retVal; + } + + /// Build a root command with a synchronous handler + /// Action to handle the command + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( Action action ) + { + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(); + retVal.SetAction( pr => action( T.Bind( pr ) ) ); + return retVal; + } + } +#else + /// Build a root command with a synchronous handler + /// Type to bind the options to + /// Settings to use for the command + /// Action to handle the command and returns an exit code + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( CommandLineSettings settings, Func action ) + where T : IRootCommandBuilder, ICommandBinder + { + ArgumentNullException.ThrowIfNull( settings ); + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(settings); + retVal.SetAction( pr => action( T.Bind( pr ) ) ); + return retVal; + } + + /// Build a root command with async handler that returns an application specific exit code (0 == Success/No Error) + /// Type to bind the options to + /// Settings to use for the command + /// Async action to handle the command and returns an exit code (via ) + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( CommandLineSettings settings, Func> action ) + where T : IRootCommandBuilder, ICommandBinder + { + ArgumentNullException.ThrowIfNull( settings ); + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(settings); + retVal.SetAction( ( pr, ct ) => action( T.Bind( pr ), ct ) ); + return retVal; + } + + /// Build a root command with async handler + /// Type to bind the options to + /// Settings to use for the command + /// Action to handle the command and returns an exit code + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( CommandLineSettings settings, Func action ) + where T : IRootCommandBuilder, ICommandBinder + { + ArgumentNullException.ThrowIfNull( settings ); + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(settings); + retVal.SetAction( ( pr, ct ) => action( T.Bind( pr ), ct ) ); + return retVal; + } + + /// Build a root command with a synchronous handler + /// Type to bind the options to + /// Settings to use for the command + /// Action to handle the command + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( CommandLineSettings settings, Action action ) + where T : IRootCommandBuilder, ICommandBinder + { + ArgumentNullException.ThrowIfNull( settings ); + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(settings); + retVal.SetAction( pr => action( T.Bind( pr ) ) ); + return retVal; + } + + /// Build a root command with async handler + /// Type to bind the options to + /// Action to handle the command and returns an exit code + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( Func action ) + where T : IRootCommandBuilderWithSettings, ICommandBinder + { + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(); + retVal.SetAction( ( pr, ct ) => action( T.Bind( pr ), ct ) ); + return retVal; + } + + /// Build a root command with async handler that returns an application specific exit code (0 == Success/No Error) + /// Type to bind the options to + /// Async action to handle the command and returns an exit code (via ) + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( Func> action ) + where T : IRootCommandBuilderWithSettings, ICommandBinder + { + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(); + retVal.SetAction( ( pr, ct ) => action( T.Bind( pr ), ct ) ); + return retVal; + } + + /// Build a root command with a synchronous handler that accepts the bound results of a parse + /// Type to bind the options to + /// Action to handle the command and returns an exit code + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( Func action ) + where T : IRootCommandBuilderWithSettings, ICommandBinder + { + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(); + retVal.SetAction( pr => action( T.Bind( pr ) ) ); + return retVal; + } + + /// Build a root command with a synchronous handler + /// Type to bind the options to + /// Action to handle the command + /// built with as the handler. + public static AppControlledDefaultsRootCommand BuildRootCommand( Action action ) + where T : IRootCommandBuilderWithSettings, ICommandBinder + { + ArgumentNullException.ThrowIfNull( action ); + + var retVal = T.Build(); + retVal.SetAction( pr => action( T.Bind( pr ) ) ); + return retVal; + } +#endif + + } +} diff --git a/src/Ubiquity.NET.CommandLine/RootCommandExtensions.cs b/src/Ubiquity.NET.CommandLine/RootCommandExtensions.cs index ea78948..3402fad 100644 --- a/src/Ubiquity.NET.CommandLine/RootCommandExtensions.cs +++ b/src/Ubiquity.NET.CommandLine/RootCommandExtensions.cs @@ -19,13 +19,13 @@ public static class RootCommandExtensions /// /// If the is an asynchronous command action then this will /// BLOCK the current thread until it completes. If that is NOT the desired behavior then - /// callers should use + /// callers should use /// instead for explicitly async operation. /// public static int ParseAndInvokeResult( this RootCommand rootCommand, IDiagnosticReporter reporter, - CmdLineSettings settings, + CommandLineSettings settings, params string[] args ) { @@ -65,13 +65,13 @@ params string[] args /// /// If the is an synchronous command action then this will /// run the action asynchronously. If that is NOT the desired behavior then callers should - /// use + /// use /// instead for explicitly sync operation. /// public static async Task ParseAndInvokeResultAsync( this RootCommand rootCommand, IDiagnosticReporter reporter, - CmdLineSettings settings, + CommandLineSettings settings, CancellationToken ct, params string[] args ) diff --git a/src/Ubiquity.NET.CommandLine/Ubiquity.NET.CommandLine.csproj b/src/Ubiquity.NET.CommandLine/Ubiquity.NET.CommandLine.csproj index 0a00d09..0d9cd97 100644 --- a/src/Ubiquity.NET.CommandLine/Ubiquity.NET.CommandLine.csproj +++ b/src/Ubiquity.NET.CommandLine/Ubiquity.NET.CommandLine.csproj @@ -2,7 +2,6 @@ net10.0;net8.0 - 14 enable True @@ -29,10 +28,10 @@ + - diff --git a/src/Ubiquity.NET.Extensions/ICustomFormatter.cs b/src/Ubiquity.NET.Extensions/ICustomFormatter.cs new file mode 100644 index 0000000..0b25980 --- /dev/null +++ b/src/Ubiquity.NET.Extensions/ICustomFormatter.cs @@ -0,0 +1,18 @@ +// 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 +{ + /// Custom formatter for a specific type + /// Type the formatter supports + public interface ICustomFormatter + : ICustomFormatter + { + /// Converts the value of an instance of to an equivalent string representation + /// A format string containing formatting specifications. + /// The value to format + /// An object that supplies format information about the current instance. + /// The string representation of the value of arg, formatted as specified by format and formatProvider. + string Format( string format, T arg, IFormatProvider formatProvider ); + } +} diff --git a/src/Ubiquity.NET.Extensions/ImmutableArrayExtensions.cs b/src/Ubiquity.NET.Extensions/ImmutableArrayExtensions.cs new file mode 100644 index 0000000..77defc0 --- /dev/null +++ b/src/Ubiquity.NET.Extensions/ImmutableArrayExtensions.cs @@ -0,0 +1,25 @@ +// 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 +{ + /// Utility class to provide extensions for an + public static class ImmutableArrayExtensions + { + /// Formats an array as a string + /// Type of the elements of the array + /// array to format + /// formatted form of the array + /// + /// The method on + /// will only report the type of the array, not the contents. This will + /// display the contents of the array. If the elements don't have an overloaded + /// then it will only show the type name for each + /// element. + /// + public static string Format(this ImmutableArray self) + { + return $"[{string.Join(", ", self)}]"; + } + } +} diff --git a/src/Ubiquity.NET.Extensions/ProcessInfo.cs b/src/Ubiquity.NET.Extensions/ProcessInfo.cs index 8e51ea0..a23ce95 100644 --- a/src/Ubiquity.NET.Extensions/ProcessInfo.cs +++ b/src/Ubiquity.NET.Extensions/ProcessInfo.cs @@ -6,6 +6,7 @@ namespace Ubiquity.NET.Extensions /// Process related extensions/support public static class ProcessInfo { +#if NET10_0_OR_GREATER // "field keyword" /// Gets the active assembly as of the first use of this property /// /// The active assembly is the entry assembly which may be null if called from native @@ -13,6 +14,41 @@ public static class ProcessInfo /// public static Assembly? ActiveAssembly => field ??= Assembly.GetEntryAssembly(); +#elif NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_0_OR_GREATER // nullability annotations + /// Gets the active assembly as of the first use of this property + /// + /// The active assembly is the entry assembly which may be null if called from native + /// code as no such assembly exists for that scenario. + /// + public static Assembly? ActiveAssembly => field ??= Assembly.GetEntryAssembly(); +#else // Legacy build + /// Gets the active assembly as of the first use of this property + /// + /// The active assembly is the entry assembly which may be null if called from native + /// code as no such assembly exists for that scenario. + /// + public static Assembly ActiveAssembly + { + get + { + if(ActiveAssemblyBackingField == null) + { + ActiveAssemblyBackingField = Assembly.GetEntryAssembly(); + } + + return ActiveAssemblyBackingField; + } + } + + // Again, [Sigh!] compiler/IDE is @#$% confused, it doesn't take language version of the target runtime into account. + // They sure do make multi-targeting REALLY HARD. On the one hand they say "don't set 'LangVersion' in the project", + // on the other they do dumb S#$% like this! + [SuppressMessage( "Style", "IDE0032:Use auto property", Justification = "Can't do that in C# 7.3 analyzer isn't taking language version into account" )] +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private static Assembly ActiveAssemblyBackingField; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +#endif + /// Gets the executable path for this instance of an application /// This is a short hand for [ 0 ] public static string ExecutablePath => Environment.GetCommandLineArgs()[ 0 ]; diff --git a/src/Ubiquity.NET.Extensions/Readme.md b/src/Ubiquity.NET.Extensions/Readme.md index 41c1327..6514299 100644 --- a/src/Ubiquity.NET.Extensions/Readme.md +++ b/src/Ubiquity.NET.Extensions/Readme.md @@ -4,6 +4,10 @@ of functionality used by but not actually part of multiple other Ubiquity.NET pr core principal is that this library has NO dependencies beyond the runtime itself. That is, this library should remain at the bottom of any dependency chain. +>[!NOTE] +> The library does have dependencies on `Microsoft.Bcl.HashCode` and +> `System.Collections.Immutable` as runtime "polyfills". + ## Key support * Computing a hash code for a ReadOnlySpan of bytes using [System.IO.System.IO.Hashing.XxHash3](https://learn.microsoft.com/en-us/dotnet/api/system.io.hashing.xxhash3) @@ -13,8 +17,6 @@ this library should remain at the bottom of any dependency chain. package. * StringNormalizer extensions to support converting line endings of strings for interoperability across OS platforms and compatibility with "on disk" representations. -* A custom ValidatedNotNullAttribute to allow compiler to assume a parameter - value is validated as not null. * DictionaryBuilder to enable dictionary initializer style initialization of `ImmutableDictionary` with significantly reduced overhead. - This leverages an `ImmutableDictionary.Builder` under the hood to build diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs index b2899da..3ce29de 100644 --- a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillExceptionValidators.cs @@ -7,11 +7,9 @@ // .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 -#if !NET7_0_OR_GREATER +#pragma warning disable IDE0130 // Namespace does not match folder structure namespace System { @@ -311,4 +309,3 @@ private static void ThrowNotEqual( T value, T other, string? paramName ) } } } -#endif diff --git a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs index eefe6fa..0a4cfbe 100644 --- a/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs +++ b/src/Ubiquity.NET.PollyFill.SharedSources/PolyFillStringExtensions.cs @@ -10,6 +10,7 @@ #pragma warning disable IDE0130 // Namespace does not match folder structure using global::System.Diagnostics; +using global::System.Collections.Generic; namespace System { diff --git a/src/Ubiquity.NET.SourceGenerator.Test.Utils/MsTestAssertExtensions.cs b/src/Ubiquity.NET.SourceGenerator.Test.Utils/MsTestAssertExtensions.cs index a16becd..d433a56 100644 --- a/src/Ubiquity.NET.SourceGenerator.Test.Utils/MsTestAssertExtensions.cs +++ b/src/Ubiquity.NET.SourceGenerator.Test.Utils/MsTestAssertExtensions.cs @@ -39,8 +39,8 @@ ImmutableArray 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)) + 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"); @@ -73,7 +73,7 @@ public static void AreEqual( string stepTrackingName ) { - Assert.HasCount( steps1.Length, steps2, "Step lengths should be equal"); + Assert.HasCount(steps1.Length, steps2, "Step lengths should be equal"); for (int i = 0; i < steps1.Length; ++i) { var runStep1 = steps1[i]; @@ -91,7 +91,7 @@ string 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) + public static void Cached( this Assert _, GeneratorDriverRunResult driverRunResult ) { // verify the second run only generated cached source outputs var uncachedSteps = from generatorRunResult in driverRunResult.Results @@ -207,7 +207,7 @@ params string[] parameters } // If the object is a collection, check each of the values - if (node is IEnumerable collection and not string) + if (node is IEnumerable collection and not string && !IsDefaultImmutable(node)) { foreach (object element in collection) { @@ -226,5 +226,32 @@ params string[] parameters } } } + + // This prevents visiting an Immutable collection that is default constructed + // Sadly, that will throw an exception on enumeration instead of just completing. + // So it is NOT safe to just cast to object to IEnumerable and party on - it might throw! + private static bool IsDefaultImmutable( object? o ) + { + if( o is null ) + { + return false; + } + + // This applies to a pattern of types that implement the IsDefault + // property. The most common is ImmutableArray but there are many + // others. This will skip all the generic type cruft and try to get + // the common property - if it isn't there. Then it's not one of the + // types to care about for this check. + PropertyInfo? propInfo = o.GetType().GetProperty("IsDefault"); + if( propInfo is null) + { + return false; + } + + object? propVal = propInfo.GetValue(o); + return propVal != null + && propVal is bool propBoolVal + && propBoolVal; + } } } diff --git a/src/Ubiquity.NET.SrcGeneration/CSharp/CSharpLanguage.cs b/src/Ubiquity.NET.SrcGeneration/CSharp/CSharpLanguage.cs index b6a4ff6..ea99819 100644 --- a/src/Ubiquity.NET.SrcGeneration/CSharp/CSharpLanguage.cs +++ b/src/Ubiquity.NET.SrcGeneration/CSharp/CSharpLanguage.cs @@ -12,6 +12,26 @@ public static class CSharpLanguage /// Closing of a scope for C# public const string ScopeClose = "}"; + /// Gets a literal value for a that is specific to the C# language + /// value to get as a literal + /// literal string suitable for output to a writer for the C# language as-is + public static string AsLiteral(bool value) + { + return value ? "true" : "false"; + } + + /// Gets a literal value for a that is specific to the C# language + /// value to get as a literal + /// literal string suitable for output to a writer for the C# language as-is + /// This, basically, surrounds with quotes + public static string AsLiteral( string value ) + { + return $"\"{value}\""; + } + + // TODO: char value + // This requires either simple single quotes OR, it needs conversion to a hex representation if not printable + /// Gets the language keywords /// /// This is normally used from within diff --git a/src/Ubiquity.NET.SrcGeneration/CSharp/IndentedTextWriterExtensions.cs b/src/Ubiquity.NET.SrcGeneration/CSharp/IndentedTextWriterExtensions.cs index 1bdf90e..2654a70 100644 --- a/src/Ubiquity.NET.SrcGeneration/CSharp/IndentedTextWriterExtensions.cs +++ b/src/Ubiquity.NET.SrcGeneration/CSharp/IndentedTextWriterExtensions.cs @@ -14,7 +14,13 @@ public static class IndentedTextWriterExtensions /// Version of the tool (included in comment) /// Source of the generated file [Default: null] /// Indicates if the scope is closed with a newline - public static void WriteAutoGeneratedComment( this IndentedTextWriter self, string toolName, string toolVersion, string? source = null, bool writeClosingNewLine = false ) + public static void WriteAutoGeneratedComment( + this IndentedTextWriter self, + string toolName, + string toolVersion, + string? source = null, + bool writeClosingNewLine = false + ) { #if NET8_0_OR_GREATER ArgumentNullException.ThrowIfNull( self ); diff --git a/src/Ubiquity.NET.SrcGeneration/ReadMe.md b/src/Ubiquity.NET.SrcGeneration/ReadMe.md index 91de330..ce0a322 100644 --- a/src/Ubiquity.NET.SrcGeneration/ReadMe.md +++ b/src/Ubiquity.NET.SrcGeneration/ReadMe.md @@ -3,7 +3,7 @@ This library provides support for source generation using `System.CodeDom.Compiler.IndentedTextWriter`. While .NET does have support for T4 to generate source that is used at runtime to generate a final source this can easily get VERY terse and hard to use. (Let alone debug, especially -with respect to correct white space.) Thus, while it is useful for simpler templating when +with respect to correct white space.) Thus, while it is useful for simpler templating, when things get complicated and there are lots of "decisions" to make based on the input it can get downright unruly. diff --git a/src/Ubiquity.NET.Utils.slnx b/src/Ubiquity.NET.Utils.slnx index 00bfb6b..1f05e06 100644 --- a/src/Ubiquity.NET.Utils.slnx +++ b/src/Ubiquity.NET.Utils.slnx @@ -1,6 +1,5 @@ - @@ -18,6 +17,7 @@ + diff --git a/global.json b/src/global.json similarity index 100% rename from global.json rename to src/global.json diff --git a/stylecop.json b/stylecop.json index a4db0ed..942e977 100644 --- a/stylecop.json +++ b/stylecop.json @@ -11,7 +11,7 @@ "namingRules": { "allowCommonHungarianPrefixes": false, - "allowedHungarianPrefixes": ["h", "di", "op", "os", "ir", "is", "do", "un", "on", "to", "if", "no", "v", "in", "p", "pp", "by", "dy"] + "allowedHungarianPrefixes": ["h", "di", "op", "os", "ir", "is", "do", "un", "on", "to", "if", "no", "v", "in", "p", "pp", "by", "dy", "my"] }, "orderingRules": { From c7ecb338c8bc963bdb4640ddeb422d9e9d03547b Mon Sep 17 00:00:00 2001 From: Steven Maillet Date: Fri, 9 Jan 2026 13:01:34 -0800 Subject: [PATCH 2/4] Updated Nuget.Config source mapping to account for common versioning task. --- NuGet.Config | 1 + 1 file changed, 1 insertion(+) diff --git a/NuGet.Config b/NuGet.Config index 46c7ca2..4c68dc5 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -11,6 +11,7 @@ + From 1af40edb2c10614cc5a618ca8b9864709cb96297 Mon Sep 17 00:00:00 2001 From: Steven Maillet Date: Fri, 9 Jan 2026 13:09:50 -0800 Subject: [PATCH 3/4] * Updated solution file to new location of global.json --- src/Ubiquity.NET.Utils.slnx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ubiquity.NET.Utils.slnx b/src/Ubiquity.NET.Utils.slnx index 1f05e06..bb8700e 100644 --- a/src/Ubiquity.NET.Utils.slnx +++ b/src/Ubiquity.NET.Utils.slnx @@ -8,7 +8,6 @@ - @@ -18,6 +17,7 @@ + From 742109b83dec1e595448b6f0f80afc0fe36f577c Mon Sep 17 00:00:00 2001 From: Steven Maillet Date: Fri, 9 Jan 2026 13:55:39 -0800 Subject: [PATCH 4/4] Moved global.json up one level to support consumption by automated build using scripts. --- src/global.json => global.json | 0 src/Ubiquity.NET.Utils.slnx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/global.json => global.json (100%) diff --git a/src/global.json b/global.json similarity index 100% rename from src/global.json rename to global.json diff --git a/src/Ubiquity.NET.Utils.slnx b/src/Ubiquity.NET.Utils.slnx index bb8700e..1f05e06 100644 --- a/src/Ubiquity.NET.Utils.slnx +++ b/src/Ubiquity.NET.Utils.slnx @@ -8,6 +8,7 @@ + @@ -17,7 +18,6 @@ -