Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<PortableExecutableReference> 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<ImmutableArray<Diagnostic>> Analyze(string source, DiagnosticAnalyzer analyzer, bool generate = true)
{
CSharpCompilation compilation = Create(source, generate);
return await compilation.WithAnalyzers([analyzer]).GetAnalyzerDiagnosticsAsync(Xunit.TestContext.Current.CancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -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<Diagnostic> 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<Diagnostic> 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<Diagnostic> result = await Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzerExtensions.WithAnalyzers(compilation,
[new DimensionMismatchAnalyzer()], options: null).GetAnalyzerDiagnosticsAsync(Xunit.TestContext.Current.CancellationToken);
Assert.Empty(result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|--------------------
DQ0001 | DimensionalQuantities | Error | DimensionMismatchAnalyzer
DQ0002 | DimensionalQuantities | Error | DimensionMismatchAnalyzer
Original file line number Diff line number Diff line change
@@ -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)));
}
}
}
Original file line number Diff line number Diff line change
@@ -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)));
}
}
Original file line number Diff line number Diff line change
@@ -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<DiagnosticDescriptor> 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);
});
}
}
13 changes: 13 additions & 0 deletions src/DimensionalQuantities.Roslyn/Analyzers/DimensionNames.cs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading