namespace BizHawk.Analyzers; using System.Collections.Immutable; [DiagnosticAnalyzer(LanguageNames.CSharp)] public class HawkSourceAnalyzer : DiagnosticAnalyzer { private const string ERR_MSG_LIST_EXPR_EMPTY = "Empty collection expression should be `[ ]`"; private const string ERR_MSG_LIST_EXPR_END = "Collection expression should end with ` ]`"; private const string ERR_MSG_LIST_EXPR_START = "Collection expression should start with `[ `"; private const string ERR_MSG_SWITCH_THROWS_UNKNOWN = "Indeterminable exception type in default switch branch, should be InvalidOperationException/SwitchExpressionException"; private const string ERR_MSG_SWITCH_THROWS_WRONG_TYPE = "Incorrect exception type in default switch branch, should be InvalidOperationException/SwitchExpressionException"; private static readonly DiagnosticDescriptor DiagInterpStringIsDollarAt = new( id: "BHI1004", title: "Verbatim interpolated strings should begin $@, not @$", messageFormat: "Swap @ and $ on interpolated string", category: "Usage", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); private static readonly DiagnosticDescriptor DiagListExprSpacing = new( id: "BHI1110", title: "Brackets of collection expression should be separated with spaces", messageFormat: "{0}", category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); private static readonly DiagnosticDescriptor DiagNoAnonClasses = new( id: "BHI1002", title: "Do not use anonymous types (classes)", messageFormat: "Replace anonymous class with tuple or explicit type", category: "Usage", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); private static readonly DiagnosticDescriptor DiagNoAnonDelegates = new( id: "BHI1001", title: "Do not use anonymous delegates", messageFormat: "Replace anonymous delegate with lambda or local method", category: "Usage", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); private static readonly DiagnosticDescriptor DiagNoDiscardingLocals = new( id: "BHI1006", title: "Do not discard local variables", messageFormat: "RHS is a local, so this discard has no effect, and is at best unhelpful for humans", category: "Usage", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); private static readonly DiagnosticDescriptor DiagNoQueryExpression = new( id: "BHI1003", title: "Do not use query expression syntax", messageFormat: "Use method chain for LINQ instead of query expression syntax", category: "Usage", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); private static readonly DiagnosticDescriptor DiagRecordImplicitlyRefType = new( id: "BHI1130", title: "Record type declaration missing class (or struct) keyword", messageFormat: "Add class (or struct) keyword", category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); private static readonly DiagnosticDescriptor DiagSwitchShouldThrowIOE = new( id: "BHI1005", title: "Default branch of switch expression should throw InvalidOperationException/SwitchExpressionException or not throw", messageFormat: "{0}", category: "Usage", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); public static readonly DiagnosticDescriptor DiagWTF = new( id: "BHI6660", title: "BizHawk.Analyzer ran into syntax which it doesn't understand/support", messageFormat: "{0}", category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); #if true public static OperationCanceledException ReportWTF(IOperation location, OperationAnalysisContext ctx, string message) { DiagWTF.ReportAt(location, ctx, message); return new(ctx.CancellationToken); } public static OperationCanceledException ReportWTF(SyntaxNode location, OperationAnalysisContext ctx, string message) { DiagWTF.ReportAt(location, ctx, message); return new(ctx.CancellationToken); } public static OperationCanceledException ReportWTF(SyntaxNode location, SyntaxNodeAnalysisContext ctx, string message) { DiagWTF.ReportAt(location, ctx, message); return new(ctx.CancellationToken); } #else // maybe move to something like this? public static OperationCanceledException ReportWTF(SyntaxNode alien, string analyzerName, string disambig, SyntaxNodeAnalysisContext ctx) { DiagWTF.ReportAt(alien, ctx, $"[{analyzerName}{disambig}] AST/model contained {alien.GetType().FullName} unexpectedly; Analyzer needs updating"); return new(ctx.CancellationToken); } #endif public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( DiagInterpStringIsDollarAt, DiagListExprSpacing, DiagNoAnonClasses, DiagNoAnonDelegates, DiagNoDiscardingLocals, DiagNoQueryExpression, DiagRecordImplicitlyRefType, DiagSwitchShouldThrowIOE, DiagWTF); public override void Initialize(AnalysisContext context) { static string? CheckSpacingInList( SeparatedSyntaxList listContents, SyntaxToken openBracketToken, Func serialiseOuter) where T : SyntaxNode { if (listContents.Count is 0) return serialiseOuter() is "[ ]" ? null : ERR_MSG_LIST_EXPR_EMPTY; var contentsWithTrivia = listContents.ToFullString(); if (contentsWithTrivia.Contains("\n")) return null; // don't need to police spaces for multi-line expressions if (contentsWithTrivia.Length > 1 ? (contentsWithTrivia[contentsWithTrivia.Length - 1] is not ' ' || contentsWithTrivia[contentsWithTrivia.Length - 2] is ' ' or '\t') : contentsWithTrivia[0] is not ' ') { return ERR_MSG_LIST_EXPR_END; } return openBracketToken.TrailingTrivia.ToFullString() is " " ? null : ERR_MSG_LIST_EXPR_START; } static bool IsDiscard(AssignmentExpressionSyntax aes) => aes.OperatorToken.RawKind is (int) SyntaxKind.EqualsToken && aes.Left is IdentifierNameSyntax { Identifier.Text: "_" }; context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); context.RegisterCompilationStartAction(initContext => { var invalidOperationExceptionSym = initContext.Compilation.GetTypeByMetadataName("System.InvalidOperationException")!; var switchExpressionExceptionSym = initContext.Compilation.GetTypeByMetadataName("System.Runtime.CompilerServices.SwitchExpressionException"); initContext.RegisterSyntaxNodeAction( snac => { void MaybeReportListExprSpacing(SyntaxNode listSyn, string? message) { if (message is null) return; var location = listSyn.GetLocation(); TextSpan? slice = message switch { ERR_MSG_LIST_EXPR_END => location.SourceSpan.Slice(start: location.SourceSpan.Length - 1), ERR_MSG_LIST_EXPR_START => location.SourceSpan.Slice(start: 0, length: 1), _ => null, }; if (slice is not null) location = Location.Create(location.SourceTree!, slice.Value); DiagListExprSpacing.ReportAt(location, snac, message); } switch (snac.Node) { case AnonymousMethodExpressionSyntax: DiagNoAnonDelegates.ReportAt(snac.Node, snac); break; case AnonymousObjectCreationExpressionSyntax: DiagNoAnonClasses.ReportAt(snac.Node, snac); break; case AssignmentExpressionSyntax aes: if (!IsDiscard(aes)) break; if (snac.SemanticModel.GetSymbolInfo(aes.Right, snac.CancellationToken).Symbol?.Kind is not SymbolKind.Local) break; DiagNoDiscardingLocals.ReportAt(snac.Node, snac); break; case CollectionExpressionSyntax ces: MaybeReportListExprSpacing( ces, CheckSpacingInList(ces.Elements, ces.OpenBracketToken, ces.ToString)); break; case InterpolatedStringExpressionSyntax ises: var interpTkn = ises.StringStartToken; if (interpTkn.Text[0] is '@') DiagInterpStringIsDollarAt.ReportAt(interpTkn, snac); break; case ListPatternSyntax lps: MaybeReportListExprSpacing( lps, CheckSpacingInList(lps.Patterns, lps.OpenBracketToken, lps.ToString)); break; case QueryExpressionSyntax: DiagNoQueryExpression.ReportAt(snac.Node, snac); break; case RecordDeclarationSyntax rds when rds.ClassOrStructKeyword.ToString() is not "class": // `record struct`s don't use this kind DiagRecordImplicitlyRefType.ReportAt(rds.Keyword, snac); break; case SwitchExpressionArmSyntax { WhenClause: null, Pattern: DiscardPatternSyntax, Expression: ThrowExpressionSyntax tes }: var thrownExceptionType = snac.SemanticModel.GetThrownExceptionType(tes); if (thrownExceptionType is null) { DiagSwitchShouldThrowIOE.ReportAt(tes, DiagnosticSeverity.Warning, snac, ERR_MSG_SWITCH_THROWS_UNKNOWN); } else if (!invalidOperationExceptionSym.Matches(thrownExceptionType) && switchExpressionExceptionSym?.Matches(thrownExceptionType) != true) { DiagSwitchShouldThrowIOE.ReportAt(tes, snac, ERR_MSG_SWITCH_THROWS_WRONG_TYPE); } // else correct usage, do not flag break; } }, SyntaxKind.AnonymousObjectCreationExpression, SyntaxKind.AnonymousMethodExpression, SyntaxKind.CollectionExpression, SyntaxKind.InterpolatedStringExpression, SyntaxKind.ListPattern, SyntaxKind.QueryExpression, SyntaxKind.RecordDeclaration, SyntaxKind.SimpleAssignmentExpression, SyntaxKind.SwitchExpressionArm); }); } }