From cea187700fd03e383d2fa9664e9ce8a490568ab6 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 20 Jun 2026 01:17:54 +0200 Subject: [PATCH] Package Cratis.Architecture.CodeAnalysis as a Roslyn analyzer Configure the project to pack as a proper analyzer so the publish workflow produces a usable NuGet package: target netstandard2.0, set IncludeBuildOutput=false and DevelopmentDependency, and pack the assembly into analyzers/dotnet/cs instead of lib/. Targeting netstandard2.0 requires a few compatibility fixes: add minimal System.Index/System.Range polyfills for the range operators and replace char overloads of string.Split/Join/Replace/StartsWith not available on netstandard2.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Cratis.Architecture.CodeAnalysis.csproj | 18 +- Source/CodeAnalysis/RangeAndIndexPolyfill.cs | 178 ++++++++++++++++++ .../NamespaceMustAlignWithFolderPathRule.cs | 6 +- .../Rules/UnusedInterfacesRule.cs | 2 +- .../UseStringInterpolationCodeFix.cs | 4 +- 5 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 Source/CodeAnalysis/RangeAndIndexPolyfill.cs diff --git a/Source/CodeAnalysis/Cratis.Architecture.CodeAnalysis.csproj b/Source/CodeAnalysis/Cratis.Architecture.CodeAnalysis.csproj index c4863ad..e855097 100644 --- a/Source/CodeAnalysis/Cratis.Architecture.CodeAnalysis.csproj +++ b/Source/CodeAnalysis/Cratis.Architecture.CodeAnalysis.csproj @@ -4,10 +4,18 @@ Cratis.Architecture.CodeAnalysis Roslyn analyzers enforcing Cratis architecture principles. true - net10.0 - true - $(NoWarn);RS1038;RS1041;RCS1093 + true + false + false + false + true + true + + netstandard2.0 + + latest + $(NoWarn);RS1038;RS1041;RCS1093;NU5128;NU1507 false false @@ -24,4 +32,8 @@ + + + + diff --git a/Source/CodeAnalysis/RangeAndIndexPolyfill.cs b/Source/CodeAnalysis/RangeAndIndexPolyfill.cs new file mode 100644 index 0000000..15a31ff --- /dev/null +++ b/Source/CodeAnalysis/RangeAndIndexPolyfill.cs @@ -0,0 +1,178 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// The analyzer targets netstandard2.0 so it loads in every compiler host. netstandard2.0 does not +// define System.Index and System.Range, which the C# range and index operators require. These +// minimal polyfills enable the language feature without changing behavior. +#pragma warning disable + +namespace System +{ + /// Represent a type can be used to index a collection either from the start or the end. + internal readonly struct Index : IEquatable + { + readonly int _value; + + /// Initializes a new instance of the struct. + /// The index value. It has to be zero or positive number. + /// Indicating if the index is from the start or from the end. + public Index(int value, bool fromEnd = false) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); + } + + _value = fromEnd ? ~value : value; + } + + Index(int value) => _value = value; + + /// Gets an pointing at first element. + public static Index Start => new(0); + + /// Gets an pointing at beyond last element. + public static Index End => new(~0); + + /// Gets the index value. + public int Value => _value < 0 ? ~_value : _value; + + /// Gets a value indicating whether the index is from the end. + public bool IsFromEnd => _value < 0; + + /// Create an from the start at the position indicated by the value. + /// The index value from the start. + /// The created . + public static Index FromStart(int value) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); + } + + return new Index(value); + } + + /// Create an from the end at the position indicated by the value. + /// The index value from the end. + /// The created . + public static Index FromEnd(int value) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), "value must be non-negative"); + } + + return new Index(~value); + } + + /// Calculate the offset from the start using the given collection length. + /// The length of the collection that the will be used with. + /// The offset. + public int GetOffset(int length) + { + var offset = _value; + if (IsFromEnd) + { + offset += length + 1; + } + + return offset; + } + + /// Converts integer number to an . + /// The index value from the start. + public static implicit operator Index(int value) => FromStart(value); + + /// + public override bool Equals(object? value) => value is Index index && _value == index._value; + + /// + public bool Equals(Index other) => _value == other._value; + + /// + public override int GetHashCode() => _value; + } + + /// Represent a range that has start and end indexes. + internal readonly struct Range : IEquatable + { + /// Initializes a new instance of the struct. + /// The index representing the start of the range. + /// The index representing the end of the range. + public Range(Index start, Index end) + { + Start = start; + End = end; + } + + /// Gets the index representing the inclusive start of the range. + public Index Start { get; } + + /// Gets the index representing the exclusive end of the range. + public Index End { get; } + + /// Gets a that covers the whole collection. + public static Range All => new(Index.Start, Index.End); + + /// Create a from the given start index to the end of the collection. + /// The index representing the start of the range. + /// The created . + public static Range StartAt(Index start) => new(start, Index.End); + + /// Create a from the start of the collection to the given end index. + /// The index representing the end of the range. + /// The created . + public static Range EndAt(Index end) => new(Index.Start, end); + + /// Calculate the start offset and length of the range using the given collection length. + /// The length of the collection that the range will be used with. + /// The offset and length of the range. + public (int Offset, int Length) GetOffsetAndLength(int length) + { + var start = Start.IsFromEnd ? length - Start.Value : Start.Value; + var end = End.IsFromEnd ? length - End.Value : End.Value; + + if ((uint)end > (uint)length || (uint)start > (uint)end) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + return (start, end - start); + } + + /// + public override bool Equals(object? value) => value is Range range && range.Start.Equals(Start) && range.End.Equals(End); + + /// + public bool Equals(Range other) => other.Start.Equals(Start) && other.End.Equals(End); + + /// + public override int GetHashCode() => (Start.GetHashCode() * 31) + End.GetHashCode(); + } +} + +namespace System.Runtime.CompilerServices +{ + /// Provides runtime helpers required by the range operator on netstandard2.0. + internal static class RuntimeHelpers + { + /// Slices the specified array using the specified range. + /// The type of the array elements. + /// The array to slice. + /// The range to slice with. + /// The sliced array. + public static T[] GetSubArray(T[] array, Range range) + { + var (offset, length) = range.GetOffsetAndLength(array.Length); + if (length == 0) + { + return []; + } + + var dest = new T[length]; + Array.Copy(array, offset, dest, 0, length); + return dest; + } + } +} diff --git a/Source/CodeAnalysis/Rules/NamespaceMustAlignWithFolderPathRule.cs b/Source/CodeAnalysis/Rules/NamespaceMustAlignWithFolderPathRule.cs index 9ff09b4..e9a93fc 100644 --- a/Source/CodeAnalysis/Rules/NamespaceMustAlignWithFolderPathRule.cs +++ b/Source/CodeAnalysis/Rules/NamespaceMustAlignWithFolderPathRule.cs @@ -45,7 +45,7 @@ static string GetLogicalFolderPath(string? filePath) return string.Empty; } - var normalized = filePath.Replace('\\', '/'); + var normalized = filePath!.Replace('\\', '/'); var sourceIndex = normalized.IndexOf("/Source/", StringComparison.Ordinal); if (sourceIndex < 0) { @@ -66,7 +66,7 @@ static string GetLogicalFolderPath(string? filePath) return string.Empty; } - var folders = withinProject[..fileNameIndex].Split('/', StringSplitOptions.RemoveEmptyEntries); - return string.Join('.', folders); + var folders = withinProject[..fileNameIndex].Split(['/'], StringSplitOptions.RemoveEmptyEntries); + return string.Join(".", folders); } } diff --git a/Source/CodeAnalysis/Rules/UnusedInterfacesRule.cs b/Source/CodeAnalysis/Rules/UnusedInterfacesRule.cs index ea65852..f374de6 100644 --- a/Source/CodeAnalysis/Rules/UnusedInterfacesRule.cs +++ b/Source/CodeAnalysis/Rules/UnusedInterfacesRule.cs @@ -59,7 +59,7 @@ public static void Register(CompilationStartAnalysisContext startContext) { foreach (var @interface in interfaceSymbols) { - if (implementedInterfaces.Contains(@interface) || !@interface.Name.StartsWith('I')) + if (implementedInterfaces.Contains(@interface) || !@interface.Name.StartsWith("I", StringComparison.Ordinal)) { continue; } diff --git a/Source/CodeAnalysis/UseStringInterpolationCodeFix.cs b/Source/CodeAnalysis/UseStringInterpolationCodeFix.cs index 918eedf..e4d8a48 100644 --- a/Source/CodeAnalysis/UseStringInterpolationCodeFix.cs +++ b/Source/CodeAnalysis/UseStringInterpolationCodeFix.cs @@ -66,10 +66,10 @@ static async Task ConvertStringFormatToInterpolationAsync(Document doc var interpolated = format; for (var i = 0; i < arguments.Length; i++) { - interpolated = interpolated.Replace("{" + i + "}", "{" + arguments[i] + "}", StringComparison.Ordinal); + interpolated = interpolated.Replace("{" + i + "}", "{" + arguments[i] + "}"); } - var replacement = SyntaxFactory.ParseExpression("$\"" + interpolated.Replace("\"", "\\\"", StringComparison.Ordinal) + "\"") + var replacement = SyntaxFactory.ParseExpression("$\"" + interpolated.Replace("\"", "\\\"") + "\"") .WithTriviaFrom(invocation); var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);