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
1 change: 1 addition & 0 deletions .github/scripts/check-package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.9.0" PrivateAssets="all" />
<PackageReference Include="System.Composition.AttributedModel" Version="10.0.1" PrivateAssets="all" />
<PackageReference Include="PolySharp" Version="1.15.0" PrivateAssets="all" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -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<string> 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<GenericNameSyntax>() is not GenericNameSyntax generic)
{
return;
}

NameSyntax name = GetOutermostName(generic);
if (name.Ancestors().OfType<InvocationExpressionSyntax>()
.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<Document> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Dimension<TerminalHex1, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0>>";

[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<global::DimensionalQuantities.TypeSystem.TerminalHex1, global::DimensionalQuantities.TypeSystem.TerminalHex1>")]
[InlineData("global::DimensionalQuantities.TypeSystem.Fraction<global::DimensionalQuantities.TypeSystem.TerminalHex2, global::DimensionalQuantities.TypeSystem.TerminalHex2>")]
public async Task NonCanonicalEncodingExplainsDistinctTypeIdentity(string exponent)
{
string type = $"global::DimensionalQuantities.TypeSystem.Quantity<global::DimensionalQuantities.TypeSystem.Dimension<{exponent}, "
+ "global::DimensionalQuantities.TypeSystem.TerminalHex0, global::DimensionalQuantities.TypeSystem.TerminalHex0, global::DimensionalQuantities.TypeSystem.TerminalHex0, "
+ "global::DimensionalQuantities.TypeSystem.TerminalHex0, global::DimensionalQuantities.TypeSystem.TerminalHex0, global::DimensionalQuantities.TypeSystem.TerminalHex0>>";
string source = $"using MyLength = {type}; public class Consumer {{ }}";
ImmutableArray<Diagnostic> 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<string>")]
[InlineData("Quantity<Dimension<Hex<TerminalHex1, TerminalHex0>, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0, TerminalHex0>>")]
[InlineData("Quantity<Dimension<Fraction<TerminalHex1, TerminalHex0>, 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<Missing>")]
[InlineData("Power<Missing>")]
public async Task UnresolvedArgumentsDoNotProduceEncodingDiagnostics(string type)
{
string source = $"using DimensionalQuantities.TypeSystem; public class Consumer {{ public {type} Value {{ get; }} }}";
ImmutableArray<Diagnostic> result = await AnalyzerTestCompilation.Analyze(source, new QuantityEncodingAnalyzer(), generate: false);
Assert.Empty(result);
}

[Theory]
[InlineData("using MyLength = DimensionalQuantities.TypeSystem.Quantity<DimensionalQuantities.TypeSystem.Dimension<DimensionalQuantities.TypeSystem.TerminalHex1, DimensionalQuantities.TypeSystem.TerminalHex0, DimensionalQuantities.TypeSystem.TerminalHex0, DimensionalQuantities.TypeSystem.TerminalHex0, DimensionalQuantities.TypeSystem.TerminalHex0, DimensionalQuantities.TypeSystem.TerminalHex0, DimensionalQuantities.TypeSystem.TerminalHex0>>; public class Consumer { public MyLength Value { get; } }")]
[InlineData("public class Consumer { public Length Value { get; } }")]
[InlineData("using DimensionalQuantities.TypeSystem; public class Consumer<T> { public Quantity<T> Value { get; } }")]
[InlineData("public class Quantity<T> { } public class Consumer { public Quantity<string> Value { get; } }")]
public async Task DeliberateAliasesOpenGenericsAndUnrelatedTypesAreAllowed(string source)
{
ImmutableArray<Diagnostic> result = await AnalyzerTestCompilation.Analyze(source, new QuantityEncodingAnalyzer());
Assert.Empty(result);
}
}
Original file line number Diff line number Diff line change
@@ -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<global::DimensionalQuantities.TypeSystem.Dimension<global::DimensionalQuantities.TypeSystem.TerminalHex1, global::DimensionalQuantities.TypeSystem.TerminalHex0, "
+ "global::DimensionalQuantities.TypeSystem.TerminalHex0, global::DimensionalQuantities.TypeSystem.TerminalHex0, global::DimensionalQuantities.TypeSystem.TerminalHex0, "
+ "global::DimensionalQuantities.TypeSystem.TerminalHex0, global::DimensionalQuantities.TypeSystem.TerminalHex0>>";

private const string LengthCurrentType = "global::DimensionalQuantities.TypeSystem.Quantity<global::DimensionalQuantities.TypeSystem.Dimension<global::DimensionalQuantities.TypeSystem.TerminalHex1, global::DimensionalQuantities.TypeSystem.TerminalHex0, "
+ "global::DimensionalQuantities.TypeSystem.TerminalHex0, global::DimensionalQuantities.TypeSystem.TerminalHex1, global::DimensionalQuantities.TypeSystem.TerminalHex0, "
+ "global::DimensionalQuantities.TypeSystem.TerminalHex0, global::DimensionalQuantities.TypeSystem.TerminalHex0>>";

[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<CodeAction> actions = [];
QuantityAliasCodeFixProvider provider = new();
await provider.RegisterCodeFixesAsync(new(document, diagnostic, (action, _) => actions.Add(action), TestContext.Current.CancellationToken));
CodeAction action = Assert.Single(actions);
ImmutableArray<CodeActionOperation> operations = await action.GetOperationsAsync(TestContext.Current.CancellationToken);
ApplyChangesOperation changes = Assert.Single(operations.OfType<ApplyChangesOperation>());
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<CodeAction> 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<Diagnostic> diagnostics = await compilation.WithAnalyzers([new QuantityEncodingAnalyzer()]).GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken);
List<CodeAction> 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)!;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
<ItemGroup>
<ProjectReference Include="../DimensionalQuantities/DimensionalQuantities.csproj" />
<ProjectReference Include="../DimensionalQuantities.Roslyn/DimensionalQuantities.Roslyn.csproj" />
<ProjectReference Include="../DimensionalQuantities.Roslyn.CodeFixes/DimensionalQuantities.Roslyn.CodeFixes.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.9.0" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -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<Dimension<TerminalHex1, TerminalHex0, TerminalHex0, TerminalHex1, TerminalHex0, TerminalHex0, TerminalHex0>> 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<PortableExecutableReference> 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<Dimension> result = DiscoverySeedCollector.FindRequestedDimensions(compilation, [tree], TestContext.Current.CancellationToken);
Dimension expected = new(Length: 1, ElectricCurrent: 1);

Assert.Equal(expected, Assert.Single(result));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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!);
}
}
Loading
Loading