From cd23a330563f07df452d0f613965e361899c8f07 Mon Sep 17 00:00:00 2001 From: James McMahon Date: Sat, 24 Oct 2026 20:35:15 +0100 Subject: [PATCH] feat: guide quantity encodings and offer safe alias fixes --- .github/scripts/check-package.py | 1 + .../PackagedGeneratorTests.cs | 2 + ...ensionalQuantities.Roslyn.CodeFixes.csproj | 12 ++ .../QuantityAliasCodeFixProvider.cs | 73 ++++++++++ .../QuantityEncodingAnalyzerTests.cs | 72 ++++++++++ .../QuantityAliasCodeFixProviderTests.cs | 94 ++++++++++++ .../DimensionalQuantities.Roslyn.Tests.csproj | 2 + .../Discovery/DiscoverySeedCollectorTests.cs | 33 +++++ .../AnalyzerReleases.Unshipped.md | 3 + .../Analyzers/CanonicalTypeEncoding.cs | 41 ++++++ .../Analyzers/QuantityEncodingAnalyzer.cs | 134 ++++++++++++++++++ .../Discovery/DiscoverySeedCollector.cs | 2 +- .../Discovery/TypeSystemNumberReader.cs | 15 +- src/DimensionalQuantities.slnx | 1 + .../DimensionalQuantities.csproj | 2 + 15 files changed, 484 insertions(+), 3 deletions(-) create mode 100644 src/DimensionalQuantities.Roslyn.CodeFixes/DimensionalQuantities.Roslyn.CodeFixes.csproj create mode 100644 src/DimensionalQuantities.Roslyn.CodeFixes/QuantityAliasCodeFixProvider.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Analyzers/QuantityEncodingAnalyzerTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/CodeFixes/QuantityAliasCodeFixProviderTests.cs create mode 100644 src/DimensionalQuantities.Roslyn.Tests/Discovery/DiscoverySeedCollectorTests.cs create mode 100644 src/DimensionalQuantities.Roslyn/Analyzers/CanonicalTypeEncoding.cs create mode 100644 src/DimensionalQuantities.Roslyn/Analyzers/QuantityEncodingAnalyzer.cs diff --git a/.github/scripts/check-package.py b/.github/scripts/check-package.py index 8098e27..a4c2d14 100644 --- a/.github/scripts/check-package.py +++ b/.github/scripts/check-package.py @@ -10,6 +10,7 @@ names = package.namelist() assert "lib/netstandard2.0/DimensionalQuantities.dll" in names assert "analyzers/dotnet/cs/DimensionalQuantities.Roslyn.dll" in names + assert "analyzers/dotnet/cs/DimensionalQuantities.Roslyn.CodeFixes.dll" in names assert not any("Microsoft.CodeAnalysis" in name for name in names) specification = ET.fromstring(package.read("DimensionalQuantities.nuspec")) assert specification.find(".//{*}version").text == version diff --git a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs index db31c7c..b3b1cee 100644 --- a/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs +++ b/src/DimensionalQuantities.IntegrationTests/GeneratedQuantityTests/PackagedGeneratorTests.cs @@ -51,6 +51,7 @@ public static double MultiplyVoltageCurrent(double volts, double amps) """); ProjectCreator creator = ProjectCreator.Templates.SdkCsproj(path: consumerProject, targetFramework: "netstandard2.0") .Property("LangVersion", "preview") + .Property("WarningsAsErrors", "CS8032;CS8785;AD0001") .ItemPackageReference(package) .Save(); @@ -175,6 +176,7 @@ private ProjectCreator CreateProject(string name, string source, params Package[ File.WriteAllText(Path.Combine(directory, name + ".cs"), source); ProjectCreator creator = ProjectCreator.Templates.SdkCsproj(path: Path.Combine(directory, name + ".csproj"), targetFramework: "netstandard2.0") .Property("LangVersion", "preview") + .Property("WarningsAsErrors", "CS8032;CS8785;AD0001") .Property("Version", "1.0.0") .Property("PackageOutputPath", Path.Combine(directory, "packages")) .Property("CopyLocalLockFileAssemblies", "true"); diff --git a/src/DimensionalQuantities.Roslyn.CodeFixes/DimensionalQuantities.Roslyn.CodeFixes.csproj b/src/DimensionalQuantities.Roslyn.CodeFixes/DimensionalQuantities.Roslyn.CodeFixes.csproj new file mode 100644 index 0000000..7df797a --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.CodeFixes/DimensionalQuantities.Roslyn.CodeFixes.csproj @@ -0,0 +1,12 @@ + + + netstandard2.0 + true + false + + + + + + + diff --git a/src/DimensionalQuantities.Roslyn.CodeFixes/QuantityAliasCodeFixProvider.cs b/src/DimensionalQuantities.Roslyn.CodeFixes/QuantityAliasCodeFixProvider.cs new file mode 100644 index 0000000..cfe1c29 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.CodeFixes/QuantityAliasCodeFixProvider.cs @@ -0,0 +1,73 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +namespace DimensionalQuantities.Roslyn.CodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(QuantityAliasCodeFixProvider))] +[Shared] +public sealed class QuantityAliasCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => ["DQ0003"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + Diagnostic diagnostic = context.Diagnostics[0]; + if (!diagnostic.Properties.TryGetValue("AliasName", out string? alias) || alias is null or "") + { + return; + } + + SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + SemanticModel? model = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null || model is null + || root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf() is not GenericNameSyntax generic) + { + return; + } + + NameSyntax name = GetOutermostName(generic); + if (name.Ancestors().OfType() + .Any(invocation => model.GetOperation(invocation, context.CancellationToken) is INameOfOperation)) + { + return; + } + + ITypeSymbol? originalType = model.GetTypeInfo(name, context.CancellationToken).Type; + TypeSyntax replacement = SyntaxFactory.ParseTypeName(alias); + ITypeSymbol? replacementType = model.GetSpeculativeTypeInfo(name.SpanStart, replacement, + SpeculativeBindingOption.BindAsTypeOrNamespace).Type; + if (originalType is null || !SymbolEqualityComparer.Default.Equals(originalType, replacementType)) + { + return; + } + + context.RegisterCodeFix(CodeAction.Create($"Use {alias}", cancellationToken => ReplaceAsync(context.Document, name, replacement, cancellationToken), + $"UseAlias:{alias}"), diagnostic); + } + + private static NameSyntax GetOutermostName(NameSyntax name) + { + while (name.Parent is NameSyntax parent) + { + name = parent; + } + + return name; + } + + private static async Task ReplaceAsync(Document document, NameSyntax original, + TypeSyntax replacement, CancellationToken cancellationToken) + { + SyntaxNode root = (await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false))!; + SyntaxNode updated = root.ReplaceNode(original, replacement.WithTriviaFrom(original)); + return document.WithSyntaxRoot(updated); + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Analyzers/QuantityEncodingAnalyzerTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Analyzers/QuantityEncodingAnalyzerTests.cs new file mode 100644 index 0000000..36010d3 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Analyzers/QuantityEncodingAnalyzerTests.cs @@ -0,0 +1,72 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Roslyn.Analyzers; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Analyzers; + +public sealed class QuantityEncodingAnalyzerTests +{ + private const string LengthType = "Quantity>"; + + [Fact] + public async Task RawQuantitySuggestsReadableAlias() + { + string source = $"using DimensionalQuantities.TypeSystem; public class Consumer {{ public {LengthType} Value {{ get; }} }}"; + Diagnostic result = Assert.Single(await AnalyzerTestCompilation.Analyze(source, new QuantityEncodingAnalyzer())); + Assert.Multiple( + () => Assert.Equal("DQ0003", result.Id), + () => Assert.Contains("Length", result.GetMessage()), + () => Assert.Equal(LengthType, source.Substring(result.Location.SourceSpan.Start, result.Location.SourceSpan.Length))); + } + + [Theory] + [InlineData("global::DimensionalQuantities.TypeSystem.Fraction")] + [InlineData("global::DimensionalQuantities.TypeSystem.Fraction")] + public async Task NonCanonicalEncodingExplainsDistinctTypeIdentity(string exponent) + { + string type = $"global::DimensionalQuantities.TypeSystem.Quantity>"; + string source = $"using MyLength = {type}; public class Consumer {{ }}"; + ImmutableArray results = await AnalyzerTestCompilation.Analyze(source, new QuantityEncodingAnalyzer()); + Diagnostic result = Assert.Single(results); + Assert.Multiple( + () => Assert.Equal("DQ0004", result.Id), + () => Assert.Equal(DiagnosticSeverity.Warning, result.Severity)); + } + + [Theory] + [InlineData("Quantity")] + [InlineData("Quantity, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0>>")] + [InlineData("Quantity, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0>>")] + public async Task InvalidClosedEncodingIsDiagnosedWithoutAnalyzerCrash(string type) + { + string source = $"using DimensionalQuantities.TypeSystem; public class Consumer {{ public {type} Value {{ get; }} }}"; + Diagnostic result = Assert.Single(await AnalyzerTestCompilation.Analyze(source, new QuantityEncodingAnalyzer(), generate: false)); + Assert.Multiple( + () => Assert.Equal("DQ0005", result.Id), + () => Assert.Equal(DiagnosticSeverity.Error, result.Severity)); + } + + [Theory] + [InlineData("Quantity")] + [InlineData("Power")] + public async Task UnresolvedArgumentsDoNotProduceEncodingDiagnostics(string type) + { + string source = $"using DimensionalQuantities.TypeSystem; public class Consumer {{ public {type} Value {{ get; }} }}"; + ImmutableArray result = await AnalyzerTestCompilation.Analyze(source, new QuantityEncodingAnalyzer(), generate: false); + Assert.Empty(result); + } + + [Theory] + [InlineData("using MyLength = DimensionalQuantities.TypeSystem.Quantity>; public class Consumer { public MyLength Value { get; } }")] + [InlineData("public class Consumer { public Length Value { get; } }")] + [InlineData("using DimensionalQuantities.TypeSystem; public class Consumer { public Quantity Value { get; } }")] + [InlineData("public class Quantity { } public class Consumer { public Quantity Value { get; } }")] + public async Task DeliberateAliasesOpenGenericsAndUnrelatedTypesAreAllowed(string source) + { + ImmutableArray result = await AnalyzerTestCompilation.Analyze(source, new QuantityEncodingAnalyzer()); + Assert.Empty(result); + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/CodeFixes/QuantityAliasCodeFixProviderTests.cs b/src/DimensionalQuantities.Roslyn.Tests/CodeFixes/QuantityAliasCodeFixProviderTests.cs new file mode 100644 index 0000000..e2e6779 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/CodeFixes/QuantityAliasCodeFixProviderTests.cs @@ -0,0 +1,94 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Roslyn.Analyzers; +using DimensionalQuantities.Roslyn.CodeFixes; +using DimensionalQuantities.Roslyn.Tests.Analyzers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.CodeFixes; + +public sealed class QuantityAliasCodeFixProviderTests +{ + private const string LengthType = "global::DimensionalQuantities.TypeSystem.Quantity>"; + + private const string LengthCurrentType = "global::DimensionalQuantities.TypeSystem.Quantity>"; + + [Theory] + [InlineData("public class Consumer { public TYPE Value { get; } }", "public class Consumer { public Length Value { get; } }")] + [InlineData("public class Consumer { public TYPE? Value { get; } }", "public class Consumer { public Length? Value { get; } }")] + [InlineData("public class Consumer { public /* measurement */ TYPE Value { get; } }", "public class Consumer { public /* measurement */ Length Value { get; } }")] + [InlineData("public class Consumer { public TYPE Value { get; } }", "public class Consumer { public LengthCurrent Value { get; } }", LengthCurrentType)] + public async Task FixUsesAliasAndPreservesSurroundingSource(string template, string expected, string? rawType = null) + { + string source = template.Replace("TYPE", rawType ?? LengthType); + using AdhocWorkspace workspace = new(); + Document document = CreateDocument(workspace, source); + Compilation compilation = (await document.Project.GetCompilationAsync(TestContext.Current.CancellationToken))!; + Diagnostic diagnostic = Assert.Single(await compilation.WithAnalyzers([new QuantityEncodingAnalyzer()]).GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken)); + List actions = []; + QuantityAliasCodeFixProvider provider = new(); + await provider.RegisterCodeFixesAsync(new(document, diagnostic, (action, _) => actions.Add(action), TestContext.Current.CancellationToken)); + CodeAction action = Assert.Single(actions); + ImmutableArray operations = await action.GetOperationsAsync(TestContext.Current.CancellationToken); + ApplyChangesOperation changes = Assert.Single(operations.OfType()); + Document changed = changes.ChangedSolution.GetDocument(document.Id)!; + string result = (await changed.GetTextAsync(TestContext.Current.CancellationToken)).ToString(); + CSharpCompilation finalCompilation = AnalyzerTestCompilation.Create(result); + Assert.Multiple( + () => Assert.Equal(expected, result), + () => Assert.DoesNotContain(finalCompilation.GetDiagnostics(TestContext.Current.CancellationToken), diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)); + } + + [Fact] + public async Task NameOfDoesNotOfferFixThatChangesTheReturnedName() + { + string source = $"public class Consumer {{ public string Value() => nameof({LengthType}); }}"; + using AdhocWorkspace workspace = new(); + Document document = CreateDocument(workspace, source); + Compilation compilation = (await document.Project.GetCompilationAsync(TestContext.Current.CancellationToken))!; + Diagnostic diagnostic = Assert.Single(await compilation.WithAnalyzers([new QuantityEncodingAnalyzer()]).GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken)); + List actions = []; + QuantityAliasCodeFixProvider provider = new(); + await provider.RegisterCodeFixesAsync(new(document, diagnostic, (action, _) => actions.Add(action), TestContext.Current.CancellationToken)); + Assert.Empty(actions); + } + + [Fact] + public async Task ShadowedAliasDoesNotOfferUnsafeFix() + { + string source = $"namespace ConsumerNamespace {{ public class Length {{ }} public class Consumer {{ public {LengthType} Value {{ get; }} }} }}"; + using AdhocWorkspace workspace = new(); + Document document = CreateDocument(workspace, source); + Compilation compilation = (await document.Project.GetCompilationAsync(TestContext.Current.CancellationToken))!; + ImmutableArray diagnostics = await compilation.WithAnalyzers([new QuantityEncodingAnalyzer()]).GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken); + List actions = []; + QuantityAliasCodeFixProvider provider = new(); + foreach (Diagnostic diagnostic in diagnostics.Where(diagnostic => diagnostic.Id == "DQ0003")) + { + await provider.RegisterCodeFixesAsync(new(document, diagnostic, (action, _) => actions.Add(action), TestContext.Current.CancellationToken)); + } + Assert.Empty(actions); + } + + private static Document CreateDocument(AdhocWorkspace workspace, string source) + { + CSharpCompilation compilation = AnalyzerTestCompilation.Create(source); + Project project = workspace.AddProject(ProjectInfo.Create(ProjectId.CreateNewId(), VersionStamp.Create(), "Consumer", "Consumer", LanguageNames.CSharp, + compilationOptions: compilation.Options, parseOptions: new CSharpParseOptions(LanguageVersion.Preview), metadataReferences: compilation.References)); + Document userDocument = workspace.AddDocument(project.Id, "Consumer.cs", SourceText.From(source)); + foreach (SyntaxTree tree in compilation.SyntaxTrees.Where(tree => tree.FilePath != "Consumer.cs")) + { + workspace.AddDocument(project.Id, Path.GetFileName(tree.FilePath), tree.GetText()); + } + return workspace.CurrentSolution.GetDocument(userDocument.Id)!; + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj b/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj index bf1de2b..dfd83d3 100644 --- a/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj +++ b/src/DimensionalQuantities.Roslyn.Tests/DimensionalQuantities.Roslyn.Tests.csproj @@ -13,8 +13,10 @@ + + diff --git a/src/DimensionalQuantities.Roslyn.Tests/Discovery/DiscoverySeedCollectorTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Discovery/DiscoverySeedCollectorTests.cs new file mode 100644 index 0000000..2c50818 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Discovery/DiscoverySeedCollectorTests.cs @@ -0,0 +1,33 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Discovery; + +public sealed class DiscoverySeedCollectorTests +{ + [Fact] + public void RawQuantityDeclarationRequestsItsDimension() + { + const string source = """ + using DimensionalQuantities.TypeSystem; + public class Consumer + { + public Quantity> Value { get; } + } + """; + SyntaxTree tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Preview), cancellationToken: TestContext.Current.CancellationToken); + string[] assemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + IEnumerable references = assemblies.Select(path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(Quantity<>).Assembly.Location)); + CSharpCompilation compilation = CSharpCompilation.Create("Consumer", [tree], references, new(OutputKind.DynamicallyLinkedLibrary)); + ImmutableArray result = DiscoverySeedCollector.FindRequestedDimensions(compilation, [tree], TestContext.Current.CancellationToken); + Dimension expected = new(Length: 1, ElectricCurrent: 1); + + Assert.Equal(expected, Assert.Single(result)); + } +} diff --git a/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md b/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md index 3f208ce..3a1a39c 100644 --- a/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md +++ b/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md @@ -4,3 +4,6 @@ Rule ID | Category | Severity | Notes --------|----------|----------|-------------------- DQ0001 | DimensionalQuantities | Error | DimensionMismatchAnalyzer DQ0002 | DimensionalQuantities | Error | DimensionMismatchAnalyzer +DQ0003 | DimensionalQuantities | Info | QuantityEncodingAnalyzer +DQ0004 | DimensionalQuantities | Warning | QuantityEncodingAnalyzer +DQ0005 | DimensionalQuantities | Error | QuantityEncodingAnalyzer diff --git a/src/DimensionalQuantities.Roslyn/Analyzers/CanonicalTypeEncoding.cs b/src/DimensionalQuantities.Roslyn/Analyzers/CanonicalTypeEncoding.cs new file mode 100644 index 0000000..a9c2af1 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Analyzers/CanonicalTypeEncoding.cs @@ -0,0 +1,41 @@ +using DimensionalQuantities.Numerics; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; + +namespace DimensionalQuantities.Roslyn.Analyzers; + +internal static class CanonicalTypeEncoding +{ + public static bool TryInspect(ITypeSymbol type, TypeSystemNumberReader reader, Compilation compilation, + out bool canonical, CancellationToken cancellationToken) + { + canonical = false; + if (!reader.TryRead(type, out Rational value, cancellationToken)) + { + return false; + } + + Type expectedType = TypeSystemNumberCodec.CreateType(value); + ITypeSymbol? expected = Resolve(expectedType, compilation); + canonical = expected is not null && SymbolEqualityComparer.Default.Equals(type, expected); + return true; + } + + private static ITypeSymbol? Resolve(Type runtimeType, Compilation compilation) + { + if (!runtimeType.IsConstructedGenericType) + { + return compilation.GetTypeByMetadataName(runtimeType.FullName!); + } + + INamedTypeSymbol? definition = compilation.GetTypeByMetadataName(runtimeType.GetGenericTypeDefinition().FullName!); + if (definition is null) + { + return null; + } + + ITypeSymbol?[] arguments = runtimeType.GetGenericArguments().Select(argument => Resolve(argument, compilation)).ToArray(); + return arguments.Any(argument => argument is null) ? null : definition.Construct(arguments!); + } +} diff --git a/src/DimensionalQuantities.Roslyn/Analyzers/QuantityEncodingAnalyzer.cs b/src/DimensionalQuantities.Roslyn/Analyzers/QuantityEncodingAnalyzer.cs new file mode 100644 index 0000000..d302638 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Analyzers/QuantityEncodingAnalyzer.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DimensionalQuantities.Roslyn.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class QuantityEncodingAnalyzer : DiagnosticAnalyzer +{ + internal const string AliasNameProperty = "AliasName"; + + internal static readonly DiagnosticDescriptor ReadableAlias = new( + "DQ0003", "Use a readable quantity alias", "Use the readable alias '{0}' for this quantity", + "DimensionalQuantities", DiagnosticSeverity.Info, isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor NonCanonical = new( + "DQ0004", "Noncanonical type-system number", "This encoding is noncanonical; equivalent values have distinct type identity", + "DimensionalQuantities", DiagnosticSeverity.Warning, isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor Invalid = new( + "DQ0005", "Invalid type-system encoding", "This closed type-system encoding is invalid", + "DimensionalQuantities", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [ReadableAlias, NonCanonical, Invalid]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(start => + { + INamedTypeSymbol? quantity = start.Compilation.GetTypeByMetadataName(typeof(Quantity<>).FullName!); + INamedTypeSymbol? dimension = start.Compilation.GetTypeByMetadataName(typeof(TypeSystem.Dimension<,,,,,,>).FullName!); + INamedTypeSymbol? power = start.Compilation.GetTypeByMetadataName(typeof(Power<>).FullName!); + if (quantity is null || dimension is null || power is null) + { + return; + } + + TypeSystemNumberReader reader = new(start.Compilation); + start.RegisterSyntaxNodeAction(node => AnalyzeGeneric(node, reader, quantity, dimension, power), SyntaxKind.GenericName); + }); + } + + private static void AnalyzeGeneric(SyntaxNodeAnalysisContext context, TypeSystemNumberReader reader, + INamedTypeSymbol quantity, INamedTypeSymbol dimension, INamedTypeSymbol power) + { + GenericNameSyntax syntax = (GenericNameSyntax)context.Node; + if (context.SemanticModel.GetTypeInfo(syntax, context.CancellationToken).Type is not INamedTypeSymbol type + || IsIncomplete(type)) + { + return; + } + + if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, quantity)) + { + AnalyzeQuantity(context, reader, syntax, type, dimension); + } + else if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, power)) + { + AnalyzePower(context, reader, syntax, type); + } + } + + private static void AnalyzeQuantity(SyntaxNodeAnalysisContext context, TypeSystemNumberReader reader, + GenericNameSyntax syntax, INamedTypeSymbol quantity, INamedTypeSymbol dimensionType) + { + if (quantity.TypeArguments[0] is not INamedTypeSymbol encodedDimension + || !SymbolEqualityComparer.Default.Equals(encodedDimension.OriginalDefinition, dimensionType)) + { + Report(context, Invalid, syntax); + return; + } + + bool canonical = true; + foreach (ITypeSymbol exponent in encodedDimension.TypeArguments) + { + if (!CanonicalTypeEncoding.TryInspect(exponent, reader, context.SemanticModel.Compilation, + out bool exponentCanonical, context.CancellationToken)) + { + Report(context, Invalid, syntax); + return; + } + + canonical &= exponentCanonical; + } + + if (!canonical) + { + Report(context, NonCanonical, syntax); + return; + } + + if (syntax.Ancestors().OfType().Any()) + { + return; + } + + QuantityTypeReader quantityReader = new(context.SemanticModel.Compilation); + if (!quantityReader.TryRead(quantity, out Dimension dimension, context.CancellationToken) || dimension == default) + { + return; + } + + string alias = DimensionNames.Get(dimension); + ImmutableDictionary properties = ImmutableDictionary.Empty.Add(AliasNameProperty, alias); + context.ReportDiagnostic(Diagnostic.Create(ReadableAlias, syntax.GetLocation(), properties, alias)); + } + + private static void AnalyzePower(SyntaxNodeAnalysisContext context, TypeSystemNumberReader reader, + GenericNameSyntax syntax, INamedTypeSymbol power) + { + if (!CanonicalTypeEncoding.TryInspect(power.TypeArguments[0], reader, context.SemanticModel.Compilation, + out bool canonical, context.CancellationToken)) + { + Report(context, Invalid, syntax); + } + else if (!canonical) + { + Report(context, NonCanonical, syntax); + } + } + + private static bool IsIncomplete(ITypeSymbol type) => type is ITypeParameterSymbol or IErrorTypeSymbol + || type is INamedTypeSymbol named && (named.IsUnboundGenericType || named.TypeArguments.Any(IsIncomplete)); + + private static void Report(SyntaxNodeAnalysisContext context, DiagnosticDescriptor descriptor, GenericNameSyntax syntax) => + context.ReportDiagnostic(Diagnostic.Create(descriptor, syntax.GetLocation())); +} diff --git a/src/DimensionalQuantities.Roslyn/Discovery/DiscoverySeedCollector.cs b/src/DimensionalQuantities.Roslyn/Discovery/DiscoverySeedCollector.cs index 793444f..85507fb 100644 --- a/src/DimensionalQuantities.Roslyn/Discovery/DiscoverySeedCollector.cs +++ b/src/DimensionalQuantities.Roslyn/Discovery/DiscoverySeedCollector.cs @@ -69,7 +69,7 @@ private static ImmutableArray FindMetadataDimensions(SyntaxNode root, foreach (ExpressionSyntax expression in root.DescendantNodes().OfType()) { cancellationToken.ThrowIfCancellationRequested(); - if (expression is InvocationExpressionSyntax or MemberAccessExpressionSyntax or ElementAccessExpressionSyntax + if (expression is InvocationExpressionSyntax or MemberAccessExpressionSyntax or ElementAccessExpressionSyntax or GenericNameSyntax || expression is IdentifierNameSyntax name && !SyntaxFacts.IsInTypeOnlyContext(name)) { dimensions.AddRange(metadata.Discover(expression, cancellationToken)); diff --git a/src/DimensionalQuantities.Roslyn/Discovery/TypeSystemNumberReader.cs b/src/DimensionalQuantities.Roslyn/Discovery/TypeSystemNumberReader.cs index 7425b01..073b73a 100644 --- a/src/DimensionalQuantities.Roslyn/Discovery/TypeSystemNumberReader.cs +++ b/src/DimensionalQuantities.Roslyn/Discovery/TypeSystemNumberReader.cs @@ -32,8 +32,19 @@ public bool TryRead(ITypeSymbol? symbol, out Rational value, CancellationToken c return false; } - value = TypeSystemNumberCodec.Parse(type!); - return true; + try + { + value = TypeSystemNumberCodec.Parse(type!); + return true; + } + catch (DivideByZeroException) + { + return false; + } + catch (ArgumentException) + { + return false; + } } private bool TryGetRuntimeType(ITypeSymbol? symbol, out Type? type, CancellationToken cancellationToken) diff --git a/src/DimensionalQuantities.slnx b/src/DimensionalQuantities.slnx index 420d2ce..ddef88c 100644 --- a/src/DimensionalQuantities.slnx +++ b/src/DimensionalQuantities.slnx @@ -4,6 +4,7 @@ + diff --git a/src/DimensionalQuantities/DimensionalQuantities.csproj b/src/DimensionalQuantities/DimensionalQuantities.csproj index 547ee04..bb64c9f 100644 --- a/src/DimensionalQuantities/DimensionalQuantities.csproj +++ b/src/DimensionalQuantities/DimensionalQuantities.csproj @@ -13,6 +13,8 @@ +