diff --git a/src/DimensionalQuantities.Roslyn.Tests/Analyzers/AnalyzerTestCompilation.cs b/src/DimensionalQuantities.Roslyn.Tests/Analyzers/AnalyzerTestCompilation.cs new file mode 100644 index 0000000..3a584f5 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Analyzers/AnalyzerTestCompilation.cs @@ -0,0 +1,35 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Roslyn.Generators; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DimensionalQuantities.Roslyn.Tests.Analyzers; + +internal static class AnalyzerTestCompilation +{ + public static CSharpCompilation Create(string source, bool generate = true) + { + CSharpParseOptions options = new(LanguageVersion.Preview); + 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("AnalyzerConsumer", + [CSharpSyntaxTree.ParseText(source, options, "Consumer.cs")], references, new(OutputKind.DynamicallyLinkedLibrary)); + if (!generate) + { + return compilation; + } + + GeneratorDriver driver = CSharpGeneratorDriver.Create([new QuantityGenerator().AsSourceGenerator()], parseOptions: options); + driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation generated, out _); + return (CSharpCompilation)generated; + } + + public static async Task> Analyze(string source, DiagnosticAnalyzer analyzer, bool generate = true) + { + CSharpCompilation compilation = Create(source, generate); + return await compilation.WithAnalyzers([analyzer]).GetAnalyzerDiagnosticsAsync(Xunit.TestContext.Current.CancellationToken); + } +} diff --git a/src/DimensionalQuantities.Roslyn.Tests/Analyzers/DimensionMismatchAnalyzerTests.cs b/src/DimensionalQuantities.Roslyn.Tests/Analyzers/DimensionMismatchAnalyzerTests.cs new file mode 100644 index 0000000..c7368cb --- /dev/null +++ b/src/DimensionalQuantities.Roslyn.Tests/Analyzers/DimensionMismatchAnalyzerTests.cs @@ -0,0 +1,120 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Roslyn.Analyzers; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace DimensionalQuantities.Roslyn.Tests.Analyzers; + +public sealed class DimensionMismatchAnalyzerTests +{ + [Theory] + [InlineData("+")] + [InlineData("-")] + [InlineData("<")] + [InlineData(">")] + [InlineData("<=")] + [InlineData(">=")] + [InlineData("==")] + [InlineData("!=")] + public async Task IncompatibleOperandsExplainTheirDimensions(string operation) + { + string source = $$""" + public class Consumer + { + public object Calculate(Length length, Current current) => length {{operation}} current; + } + """; + Diagnostic result = Assert.Single(await AnalyzerTestCompilation.Analyze(source, new DimensionMismatchAnalyzer())); + Assert.Multiple( + () => Assert.Equal("DQ0001", result.Id), + () => Assert.Equal(DiagnosticSeverity.Error, result.Severity), + () => Assert.Contains("Length", result.GetMessage()), + () => Assert.Contains("Current", result.GetMessage()), + () => Assert.Equal($"length {operation} current", source.Substring(result.Location.SourceSpan.Start, result.Location.SourceSpan.Length))); + } + + [Theory] + [InlineData("public void Calculate(Length length) { Current current = length; }")] + [InlineData("public void Calculate(Length length, Current current) { current = length; }")] + [InlineData("public Current Calculate(Length length) => length;")] + [InlineData("public Current Calculate(Length length) { return length; }")] + [InlineData("public void Calculate(Length length) { Accept(length); } private void Accept(Current current) { }")] + [InlineData("public void Calculate(Length length) { Accept(current: length); } private void Accept(Current current) { }")] + [InlineData("public object Calculate(Length length, Current current) => Current.Min(current, length);")] + [InlineData("public object Calculate(Length length, Current current) => Current.Clamp(current, current, length);")] + [InlineData("public Current Value { get; } = Length.FromMetres(1);")] + [InlineData("public Current Value => Length.FromMetres(1);")] + [InlineData("public Current this[int index] => Length.FromMetres(1);")] + [InlineData("public Consumer(Current current) { } public object Calculate(Length length) => new Consumer(length);")] + public async Task WrongDestinationExplainsExpectedAndActualDimensions(string member) + { + string source = $"public class Consumer {{ {member} }}"; + Diagnostic result = Assert.Single(await AnalyzerTestCompilation.Analyze(source, new DimensionMismatchAnalyzer())); + Assert.Multiple( + () => Assert.Equal("DQ0002", result.Id), + () => Assert.Equal(DiagnosticSeverity.Error, result.Severity), + () => Assert.Contains("Expected Current", result.GetMessage()), + () => Assert.Contains("Length", result.GetMessage()), + () => Assert.Equal("Consumer.cs", result.Location.SourceTree!.FilePath)); + } + + [Theory] + [InlineData("public object Calculate(Length length, Length other) => length + other;")] + [InlineData("public object Calculate(Length length, Current current) => length * current;")] + [InlineData("public object Calculate(Length length, Current current) => length / current;")] + [InlineData("public object Calculate(Energy energy, Torque torque) => energy + torque;")] + [InlineData("public Current? Calculate() => null;")] + [InlineData("public Current Calculate() => default;")] + [InlineData("public object Calculate(Length length) => length;")] + [InlineData("public object Calculate(Length length) => length + missing;")] + [InlineData("public void Calculate(Length length) { Accept(length); } private void Accept(Length value) { } private void Accept(Current value) { }")] + public async Task ValidOrUnrelatedCodeDoesNotProduceDiagnostics(string member) + { + ImmutableArray result = await AnalyzerTestCompilation.Analyze($"public class Consumer {{ {member} }}", new DimensionMismatchAnalyzer()); + Assert.Empty(result); + } + + [Theory] + [InlineData("+=")] + [InlineData("-=")] + public async Task CompoundAssignmentsExplainIncompatibleDimensions(string operation) + { + string source = $"public class Consumer {{ public void Calculate(Length length, Current current) {{ length {operation} current; }} }}"; + Diagnostic result = Assert.Single(await AnalyzerTestCompilation.Analyze(source, new DimensionMismatchAnalyzer())); + Assert.Equal("DQ0001", result.Id); + } + + [Fact] + public async Task NullableOperandsStillExplainDimensions() + { + const string source = "public class Consumer { public object Calculate(Length? length, Current? current) => length + current; }"; + Diagnostic result = Assert.Single(await AnalyzerTestCompilation.Analyze(source, new DimensionMismatchAnalyzer())); + Assert.Equal("DQ0001", result.Id); + } + + [Fact] + public async Task ExistingUserOperatorIsRespected() + { + const string source = """ + public static class Custom + { + extension(Length) + { + public static double operator +(Length length, Current current) => 1; + } + } + public class Consumer { public double Calculate(Length length, Current current) => length + current; } + """; + ImmutableArray result = await AnalyzerTestCompilation.Analyze(source, new DimensionMismatchAnalyzer()); + Assert.Empty(result); + } + + [Fact] + public async Task UnrelatedCompilationWithoutLibraryDoesNotCrash() + { + Microsoft.CodeAnalysis.CSharp.CSharpCompilation compilation = Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create("Empty"); + ImmutableArray result = await Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzerExtensions.WithAnalyzers(compilation, + [new DimensionMismatchAnalyzer()], options: null).GetAnalyzerDiagnosticsAsync(Xunit.TestContext.Current.CancellationToken); + Assert.Empty(result); + } +} diff --git a/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Shipped.md b/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Shipped.md @@ -0,0 +1 @@ + diff --git a/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md b/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..3f208ce --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/AnalyzerReleases.Unshipped.md @@ -0,0 +1,6 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +DQ0001 | DimensionalQuantities | Error | DimensionMismatchAnalyzer +DQ0002 | DimensionalQuantities | Error | DimensionMismatchAnalyzer diff --git a/src/DimensionalQuantities.Roslyn/Analyzers/BinaryDimensionAnalysis.cs b/src/DimensionalQuantities.Roslyn/Analyzers/BinaryDimensionAnalysis.cs new file mode 100644 index 0000000..a6b6741 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Analyzers/BinaryDimensionAnalysis.cs @@ -0,0 +1,48 @@ +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DimensionalQuantities.Roslyn.Analyzers; + +internal static class BinaryDimensionAnalysis +{ + public static void AnalyzeCompoundAssignment(SyntaxNodeAnalysisContext context, QuantityTypeReader reader) + { + AssignmentExpressionSyntax assignment = (AssignmentExpressionSyntax)context.Node; + if (context.SemanticModel.GetSymbolInfo(assignment, context.CancellationToken).Symbol is IMethodSymbol) + { + return; + } + + ITypeSymbol? leftType = context.SemanticModel.GetTypeInfo(assignment.Left, context.CancellationToken).Type; + ITypeSymbol? rightType = context.SemanticModel.GetTypeInfo(assignment.Right, context.CancellationToken).Type; + if (reader.TryReadIncludingNullable(leftType, out Dimension left, context.CancellationToken) + && reader.TryReadIncludingNullable(rightType, out Dimension right, context.CancellationToken) + && left != right) + { + context.ReportDiagnostic(Diagnostic.Create(DimensionMismatchAnalyzer.IncompatibleOperands, assignment.GetLocation(), + assignment.OperatorToken.Text, DimensionNames.Get(left), DimensionNames.Get(right))); + } + } + + public static void Analyze(SyntaxNodeAnalysisContext context, QuantityTypeReader reader) + { + BinaryExpressionSyntax binary = (BinaryExpressionSyntax)context.Node; + if (context.SemanticModel.GetSymbolInfo(binary, context.CancellationToken).Symbol is IMethodSymbol) + { + return; + } + + ITypeSymbol? leftType = context.SemanticModel.GetTypeInfo(binary.Left, context.CancellationToken).Type; + ITypeSymbol? rightType = context.SemanticModel.GetTypeInfo(binary.Right, context.CancellationToken).Type; + if (reader.TryReadIncludingNullable(leftType, out Dimension left, context.CancellationToken) + && reader.TryReadIncludingNullable(rightType, out Dimension right, context.CancellationToken) + && left != right) + { + context.ReportDiagnostic(Diagnostic.Create(DimensionMismatchAnalyzer.IncompatibleOperands, binary.GetLocation(), + binary.OperatorToken.Text, DimensionNames.Get(left), DimensionNames.Get(right))); + } + } +} diff --git a/src/DimensionalQuantities.Roslyn/Analyzers/DestinationDimensionAnalysis.cs b/src/DimensionalQuantities.Roslyn/Analyzers/DestinationDimensionAnalysis.cs new file mode 100644 index 0000000..12de4de --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Analyzers/DestinationDimensionAnalysis.cs @@ -0,0 +1,118 @@ +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Roslyn.Discovery; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DimensionalQuantities.Roslyn.Analyzers; + +internal static class DestinationDimensionAnalysis +{ + public static void Register(CompilationStartAnalysisContext context, QuantityTypeReader reader) + { + context.RegisterSyntaxNodeAction(node => AnalyzeAssignment(node, reader), SyntaxKind.SimpleAssignmentExpression); + context.RegisterSyntaxNodeAction(node => AnalyzeInitializer(node, reader), SyntaxKind.EqualsValueClause); + context.RegisterSyntaxNodeAction(node => AnalyzeReturn(node, reader), SyntaxKind.ReturnStatement, SyntaxKind.ArrowExpressionClause); + context.RegisterSyntaxNodeAction(node => AnalyzeArgument(node, reader), SyntaxKind.Argument); + } + + private static void AnalyzeAssignment(SyntaxNodeAnalysisContext context, QuantityTypeReader reader) + { + AssignmentExpressionSyntax assignment = (AssignmentExpressionSyntax)context.Node; + ITypeSymbol? target = context.SemanticModel.GetTypeInfo(assignment.Left, context.CancellationToken).Type; + ReportIfDifferent(context, reader, target, assignment.Right); + } + + private static void AnalyzeInitializer(SyntaxNodeAnalysisContext context, QuantityTypeReader reader) + { + EqualsValueClauseSyntax initializer = (EqualsValueClauseSyntax)context.Node; + ITypeSymbol? target = initializer.Parent switch + { + VariableDeclaratorSyntax variable => (context.SemanticModel.GetDeclaredSymbol(variable, context.CancellationToken) as ILocalSymbol)?.Type + ?? (context.SemanticModel.GetDeclaredSymbol(variable, context.CancellationToken) as IFieldSymbol)?.Type, + PropertyDeclarationSyntax property => (context.SemanticModel.GetDeclaredSymbol(property, context.CancellationToken) as IPropertySymbol)?.Type, + _ => null + }; + ReportIfDifferent(context, reader, target, initializer.Value); + } + + private static void AnalyzeReturn(SyntaxNodeAnalysisContext context, QuantityTypeReader reader) + { + ExpressionSyntax? expression = context.Node switch + { + ReturnStatementSyntax statement => statement.Expression, + ArrowExpressionClauseSyntax arrow => arrow.Expression, + _ => null + }; + if (expression is null) + { + return; + } + + ITypeSymbol? target = context.Node switch + { + ArrowExpressionClauseSyntax { Parent: PropertyDeclarationSyntax property } => + (context.SemanticModel.GetDeclaredSymbol(property, context.CancellationToken) as IPropertySymbol)?.Type, + ArrowExpressionClauseSyntax { Parent: IndexerDeclarationSyntax indexer } => + (context.SemanticModel.GetDeclaredSymbol(indexer, context.CancellationToken) as IPropertySymbol)?.Type, + _ => (context.SemanticModel.GetEnclosingSymbol(context.Node.SpanStart, context.CancellationToken) as IMethodSymbol)?.ReturnType + }; + ReportIfDifferent(context, reader, target, expression); + } + + private static void AnalyzeArgument(SyntaxNodeAnalysisContext context, QuantityTypeReader reader) + { + ArgumentSyntax argument = (ArgumentSyntax)context.Node; + SyntaxNode? call = argument.Parent?.Parent; + if (call is not InvocationExpressionSyntax && call is not BaseObjectCreationExpressionSyntax) + { + return; + } + + SymbolInfo binding = context.SemanticModel.GetSymbolInfo(call, context.CancellationToken); + IMethodSymbol? method = binding.Symbol as IMethodSymbol; + if (method is null && binding.CandidateSymbols.Length == 1) + { + method = binding.CandidateSymbols[0] as IMethodSymbol; + } + + if (method is null) + { + return; + } + + BaseArgumentListSyntax? arguments = call switch + { + InvocationExpressionSyntax invocation => invocation.ArgumentList, + BaseObjectCreationExpressionSyntax creation => creation.ArgumentList, + _ => null + }; + IParameterSymbol? parameter = argument.NameColon is not null + ? method.Parameters.FirstOrDefault(candidate => candidate.Name == argument.NameColon.Name.Identifier.ValueText) + : method.Parameters.ElementAtOrDefault(arguments?.Arguments.IndexOf(argument) ?? -1); + ReportIfDifferent(context, reader, parameter?.Type, argument.Expression); + } + + private static void ReportIfDifferent(SyntaxNodeAnalysisContext context, QuantityTypeReader reader, + ITypeSymbol? target, ExpressionSyntax expression) + { + ITypeSymbol? source = context.SemanticModel.GetTypeInfo(expression, context.CancellationToken).Type; + if (!reader.TryReadIncludingNullable(target, out Dimension expected, context.CancellationToken) + || !reader.TryReadIncludingNullable(source, out Dimension actual, context.CancellationToken) + || expected == actual || target is null || source is null) + { + return; + } + + Microsoft.CodeAnalysis.CSharp.Conversion conversion = context.SemanticModel.Compilation is CSharpCompilation compilation + ? compilation.ClassifyConversion(source, target) : default; + if (conversion.IsImplicit) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create(DimensionMismatchAnalyzer.WrongDestination, expression.GetLocation(), + DimensionNames.Get(expected), DimensionNames.Get(actual))); + } +} diff --git a/src/DimensionalQuantities.Roslyn/Analyzers/DimensionMismatchAnalyzer.cs b/src/DimensionalQuantities.Roslyn/Analyzers/DimensionMismatchAnalyzer.cs new file mode 100644 index 0000000..5488254 --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Analyzers/DimensionMismatchAnalyzer.cs @@ -0,0 +1,45 @@ +using System.Collections.Immutable; +using DimensionalQuantities.Roslyn.Discovery; +using DimensionalQuantities.TypeSystem; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DimensionalQuantities.Roslyn.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DimensionMismatchAnalyzer : DiagnosticAnalyzer +{ + internal static readonly DiagnosticDescriptor IncompatibleOperands = new( + "DQ0001", "Incompatible quantity dimensions", "Cannot apply '{0}' to {1} and {2}", + "DimensionalQuantities", DiagnosticSeverity.Error, isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor WrongDestination = new( + "DQ0002", "Wrong quantity dimension", "Expected {0}, but expression has {1}", + "DimensionalQuantities", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public override ImmutableArray SupportedDiagnostics => [IncompatibleOperands, WrongDestination]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(start => + { + if (start.Compilation.GetTypeByMetadataName(typeof(Quantity<>).FullName!) is null) + { + return; + } + + QuantityTypeReader reader = new(start.Compilation); + start.RegisterSyntaxNodeAction(node => BinaryDimensionAnalysis.Analyze(node, reader), + SyntaxKind.AddExpression, SyntaxKind.SubtractExpression, + SyntaxKind.LessThanExpression, SyntaxKind.GreaterThanExpression, + SyntaxKind.LessThanOrEqualExpression, SyntaxKind.GreaterThanOrEqualExpression, + SyntaxKind.EqualsExpression, SyntaxKind.NotEqualsExpression); + start.RegisterSyntaxNodeAction(node => BinaryDimensionAnalysis.AnalyzeCompoundAssignment(node, reader), + SyntaxKind.AddAssignmentExpression, SyntaxKind.SubtractAssignmentExpression); + DestinationDimensionAnalysis.Register(start, reader); + }); + } +} diff --git a/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs b/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs new file mode 100644 index 0000000..009190d --- /dev/null +++ b/src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs @@ -0,0 +1,13 @@ +using DimensionalQuantities.Dimensions; +using DimensionalQuantities.Naming; +using DimensionalQuantities.Units; + +namespace DimensionalQuantities.Roslyn.Analyzers; + +internal static class DimensionNames +{ + public static string Get(Dimension dimension) => + EngineeringCatalogue.Entries.TryGetValue(dimension, out UnitDefinition[]? units) + ? units[0].QuantityName + : dimension == default ? "Dimensionless" : DimensionNameGenerator.GetQuantityName(dimension); +}