Skip to content

Commit

Permalink
Implement support for structurally detectable closed hierarchcies (Wa…
Browse files Browse the repository at this point in the history
…lkerCodeRanger#42)

Detecting closed hierarchies like discriminated unions without the need to
provide [Closed]-Attribute
  • Loading branch information
csharper2010 committed Apr 4, 2022
1 parent bf60896 commit c5361a1
Show file tree
Hide file tree
Showing 9 changed files with 279 additions and 5 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
obj/
bin/
*.nupkg
*.user
*.user
_ReSharper.Caches/
34 changes: 34 additions & 0 deletions ExhaustiveMatching.Analyzer.Tests/CodeContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,40 @@ public class Circle : Shape {{ }}
public abstract class Triangle : Shape {{ }} // abstract to show abstract leaf types are checked
public class EquilateralTriangle : Triangle {{ }}
public class IsoscelesTriangle : Triangle {{ }}
}}";
return string.Format(context, args, body);
}

public static string Result(string args, string body)
{
const string context = @"using System; // Result type
using System;
using System.Collections.Generic;
using ExhaustiveMatching;
using TestNamespace;
class TestClass
{{
void TestMethod({0})
{{{1}
}}
}}
namespace TestNamespace
{{
public abstract class Result<TSuccess, TError> {{
private Result() {{ }}
public sealed class Success : Result<TSuccess, TError> {{
public TSuccess Value {{ get; }}
public Success(TSuccess value) {{ Value = value; }}
}}
public sealed class Error : Result<TSuccess, TError> {{
public TError Value {{ get; }}
public Error(TError value) {{ Value = value; }}
}}
}}
}}";
return string.Format(context, args, body);
}
Expand Down
54 changes: 54 additions & 0 deletions ExhaustiveMatching.Analyzer.Tests/SwitchExpressionAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,60 @@ void TestMethod(IToken token)
await VerifyCSharpDiagnosticsAsync(source, expected);
}

[Fact]
public async Task SwitchOnStructurallyClosedThrowingExhaustiveMatchFailedIsNotExhaustiveReportsDiagnostic()
{
const string args = "Result<string, string> result";
const string test = @"
var x = result ◊1⟦switch⟧
{
Result<string, string>.Error error => ""Error: "" + error,
_ => throw ExhaustiveMatch.Failed(result),
};";

var source = CodeContext.Result(args, test);
var expectedSuccess = DiagnosticResult
.Error("EM0003", "Subtype not handled by switch: TestNamespace.Success")
.AddLocation(source, 1);

await VerifyCSharpDiagnosticsAsync(source, expectedSuccess);
}

[Fact]
public async Task SwitchOnStructurallyClosedThrowingExhaustiveMatchDoesNotReportsDiagnostic()
{
const string args = "Result<string, string> result";
const string test = @"
var x = result ◊1⟦switch⟧
{
Result<string, string>.Error error => ""Error: "" + error,
Result<string, string>.Success success => ""Success: "" + success,
_ => throw ExhaustiveMatch.Failed(result),
};";

var source = CodeContext.Result(args, test);

await VerifyCSharpDiagnosticsAsync(source);
}

[Fact]
public async Task SwitchOnStructurallyClosedThrowingExhaustiveMatchAllowNull()
{
const string args = "Result<string, string> result";
const string test = @"
var x = result ◊1⟦switch⟧
{
Result<string, string>.Error error => ""Error: "" + error,
Result<string, string>.Success success => ""Success: "" + success,
null => ""null"",
_ => throw ExhaustiveMatch.Failed(result),
};";

var source = CodeContext.Result(args, test);

await VerifyCSharpDiagnosticsAsync(source);
}

[Fact]
public async Task SwitchOnStruct()
{
Expand Down
69 changes: 69 additions & 0 deletions ExhaustiveMatching.Analyzer.Tests/SwitchStatementAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,75 @@ void TestMethod(IToken token)
}


[Fact]
public async Task SwitchOnStructurallyClosedThrowingExhaustiveMatchFailedIsNotExhaustiveReportsDiagnostic()
{
const string args = "Result<string, string> result";
const string test = @"
◊1⟦switch⟧ (result)
{
case Result<string, string>.Error error:
Console.WriteLine(""Error: "" + error);
break;
default:
throw ExhaustiveMatch.Failed(result);
}";

var source = CodeContext.Result(args, test);
var expectedSuccess = DiagnosticResult
.Error("EM0003", "Subtype not handled by switch: TestNamespace.Success")
.AddLocation(source, 1);

await VerifyCSharpDiagnosticsAsync(source, expectedSuccess);
}

[Fact]
public async Task SwitchOnStructurallyClosedThrowingExhaustiveMatchDoesNotReportsDiagnostic()
{
const string args = "Result<string, string> result";
const string test = @"
◊1⟦switch⟧ (result)
{
case Result<string, string>.Error error:
Console.WriteLine(""Error: "" + error);
break;
case Result<string, string>.Success success:
Console.WriteLine(""Success: "" + success);
break;
default:
throw ExhaustiveMatch.Failed(result);
}";

var source = CodeContext.Result(args, test);

await VerifyCSharpDiagnosticsAsync(source);
}

[Fact]
public async Task SwitchOnStructurallyClosedThrowingExhaustiveMatchAllowNull()
{
const string args = "Result<string, string> result";
const string test = @"
◊1⟦switch⟧ (result)
{
case Result<string, string>.Error error:
Console.WriteLine(""Error: "" + error);
break;
case Result<string, string>.Success success:
Console.WriteLine(""Success: "" + success);
break;
case null:
Console.WriteLine(""null"");
break;
default:
throw ExhaustiveMatch.Failed(result);
}";

var source = CodeContext.Result(args, test);

await VerifyCSharpDiagnosticsAsync(source);
}

[Fact]
public async Task SwitchOnStruct()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
Expand All @@ -14,6 +15,12 @@ namespace ExhaustiveMatching.Analyzer.Tests.Verifiers
/// </summary>
public abstract partial class DiagnosticVerifier
{
static DiagnosticVerifier()
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
}

#region To be implemented by Test classes
/// <summary>
/// Get the CSharp analyzer being tested - to be implemented in non-abstract class
Expand Down
12 changes: 10 additions & 2 deletions ExhaustiveMatching.Analyzer/SwitchExpressionAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ private static void AnalyzeSwitchOnClosed(
var isClosed = type.HasAttribute(closedAttributeType);

var allCases = type.GetClosedTypeCases(closedAttributeType);
var allConcreteTypes = allCases
.Where(t => t.IsConcreteOrLeaf(closedAttributeType));

if (!isClosed && type.TryGetStructurallyClosedTypeCases(context, out allCases))
{
isClosed = true;
allConcreteTypes = allCases
.Where(t => t.IsConcrete());
}

var typesUsed = patterns
.Select(pattern => pattern.GetMatchedTypeSymbol(context, type, allCases, isClosed))
Expand All @@ -107,8 +116,7 @@ private static void AnalyzeSwitchOnClosed(
return; // No point in trying to check for uncovered types, this isn't closed
}

var uncoveredTypes = allCases
.Where(t => t.IsConcreteOrLeaf(closedAttributeType))
var uncoveredTypes = allConcreteTypes
.Where(t => !typesUsed.Any(t.IsSubtypeOf))
.ToArray();

Expand Down
12 changes: 10 additions & 2 deletions ExhaustiveMatching.Analyzer/SwitchStatementAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ private static void AnalyzeSwitchOnClosed(
var isClosed = type.HasAttribute(closedAttributeType);

var allCases = type.GetClosedTypeCases(closedAttributeType);
var allConcreteTypes = allCases
.Where(t => t.IsConcreteOrLeaf(closedAttributeType));

if (!isClosed && type.TryGetStructurallyClosedTypeCases(context, out allCases))
{
isClosed = true;
allConcreteTypes = allCases
.Where(t => t.IsConcrete());
}

var typesUsed = switchLabels
.OfType<CasePatternSwitchLabelSyntax>()
Expand All @@ -121,8 +130,7 @@ private static void AnalyzeSwitchOnClosed(
return; // No point in trying to check for uncovered types, this isn't closed
}

var uncoveredTypes = allCases
.Where(t => t.IsConcreteOrLeaf(closedAttributeType))
var uncoveredTypes = allConcreteTypes
.Where(t => !typesUsed.Any(t.IsSubtypeOf))
.ToArray();

Expand Down
31 changes: 31 additions & 0 deletions ExhaustiveMatching.Analyzer/TypeSymbolExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,43 @@ public static HashSet<ITypeSymbol> GetClosedTypeCases(
return types;
}

public static bool IsConcrete(this ITypeSymbol type)
{
return type != null
&& type.TypeKind != TypeKind.Error
&& !type.IsAbstract;
}

public static bool IsConcreteOrLeaf(this ITypeSymbol type, INamedTypeSymbol closedAttributeType)
{
return type != null
&& type.TypeKind != TypeKind.Error
&& (!type.IsAbstract
|| !type.HasAttribute(closedAttributeType));
}

public static bool TryGetStructurallyClosedTypeCases(this ITypeSymbol rootType, SyntaxNodeAnalysisContext context, out HashSet<ITypeSymbol> allCases)
{
allCases = new HashSet<ITypeSymbol>();

if (rootType is INamedTypeSymbol namedType
&& rootType.TypeKind != TypeKind.Error
&& namedType.InstanceConstructors.All(c => c.DeclaredAccessibility == Accessibility.Private)) {

var nestedTypes = context.SemanticModel.LookupSymbols(0, rootType)
.OfType<ITypeSymbol>()
.Where(t => t.IsSubtypeOf(rootType))
.ToArray();

if (nestedTypes.All(t => t.IsSealed || t is INamedTypeSymbol n && n.InstanceConstructors.All(c => c.DeclaredAccessibility == Accessibility.Private))) {
allCases.Add(rootType);
allCases.UnionWith(nestedTypes);

return true;
}
}

return false;
}
}
}
62 changes: 62 additions & 0 deletions ExhaustiveMatching.Examples/Result.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;

namespace Examples {
public abstract class Result<TSuccess, TError> : IEquatable<Result<TSuccess, TError>>
{
private Result() { }

public sealed class Success : Result<TSuccess, TError>
{
public TSuccess Value { get; }

public Success(TSuccess value) { Value = value; }

public override bool Equals(Result<TSuccess, TError> other)
{
return other is Success success && EqualityComparer<TSuccess>.Default.Equals(Value, success.Value);
}

public override int GetHashCode()
{
return EqualityComparer<TSuccess>.Default.GetHashCode(Value);
}
}

public sealed class Error : Result<TSuccess, TError>
{
public TError Value { get; }

public Error(TError value) { Value = value; }

public override bool Equals(Result<TSuccess, TError> other)
{
return other is Error error && EqualityComparer<TError>.Default.Equals(Value, error.Value);
}

public override int GetHashCode()
{
return EqualityComparer<TError>.Default.GetHashCode(Value);
}
}

public override bool Equals(object obj)
{
return obj is Result<TSuccess, TError> result && Equals(result);
}

public abstract override int GetHashCode();

public abstract bool Equals(Result<TSuccess, TError> other);

public static bool operator ==(Result<TSuccess, TError> left, Result<TSuccess, TError> right)
{
return Equals(left, right);
}

public static bool operator !=(Result<TSuccess, TError> left, Result<TSuccess, TError> right)
{
return !Equals(left, right);
}
}
}

0 comments on commit c5361a1

Please sign in to comment.