#nullable disable using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; namespace StardewModdingAPI.ModBuildConfig.Analyzer { /// Detects implicit conversion from Stardew Valley's Netcode types. These have very unintuitive implicit conversion rules, so mod authors should always explicitly convert the type with appropriate null checks. [DiagnosticAnalyzer(LanguageNames.CSharp)] public class NetFieldAnalyzer : DiagnosticAnalyzer { /********* ** Fields *********/ /// The namespace for Stardew Valley's Netcode types. private const string NetcodeNamespace = "Netcode"; /// Maps net fields to their equivalent non-net properties where available. private readonly IDictionary NetFieldWrapperProperties = new Dictionary { // AnimatedSprite ["StardewValley.AnimatedSprite::currentAnimation"] = "CurrentAnimation", ["StardewValley.AnimatedSprite::currentFrame"] = "CurrentFrame", ["StardewValley.AnimatedSprite::sourceRect"] = "SourceRect", ["StardewValley.AnimatedSprite::spriteHeight"] = "SpriteHeight", ["StardewValley.AnimatedSprite::spriteWidth"] = "SpriteWidth", // Character ["StardewValley.Character::currentLocationRef"] = "currentLocation", ["StardewValley.Character::facingDirection"] = "FacingDirection", ["StardewValley.Character::name"] = "Name", ["StardewValley.Character::position"] = "Position", ["StardewValley.Character::scale"] = "Scale", ["StardewValley.Character::speed"] = "Speed", ["StardewValley.Character::sprite"] = "Sprite", // Chest ["StardewValley.Objects.Chest::tint"] = "Tint", // Farmer ["StardewValley.Farmer::houseUpgradeLevel"] = "HouseUpgradeLevel", ["StardewValley.Farmer::isMale"] = "IsMale", ["StardewValley.Farmer::items"] = "Items", ["StardewValley.Farmer::magneticRadius"] = "MagneticRadius", ["StardewValley.Farmer::stamina"] = "Stamina", ["StardewValley.Farmer::uniqueMultiplayerID"] = "UniqueMultiplayerID", ["StardewValley.Farmer::usingTool"] = "UsingTool", // Forest ["StardewValley.Locations.Forest::netTravelingMerchantDay"] = "travelingMerchantDay", ["StardewValley.Locations.Forest::netLog"] = "log", // FruitTree ["StardewValley.TerrainFeatures.FruitTree::greenHouseTileTree"] = "GreenHouseTileTree", ["StardewValley.TerrainFeatures.FruitTree::greenHouseTree"] = "GreenHouseTree", // GameLocation ["StardewValley.GameLocation::isFarm"] = "IsFarm", ["StardewValley.GameLocation::isOutdoors"] = "IsOutdoors", ["StardewValley.GameLocation::lightLevel"] = "LightLevel", ["StardewValley.GameLocation::name"] = "Name", // Item ["StardewValley.Item::category"] = "Category", ["StardewValley.Item::netName"] = "Name", ["StardewValley.Item::parentSheetIndex"] = "ParentSheetIndex", ["StardewValley.Item::specialVariable"] = "SpecialVariable", // Junimo ["StardewValley.Characters.Junimo::eventActor"] = "EventActor", // LightSource ["StardewValley.LightSource::identifier"] = "Identifier", // Monster ["StardewValley.Monsters.Monster::damageToFarmer"] = "DamageToFarmer", ["StardewValley.Monsters.Monster::experienceGained"] = "ExperienceGained", ["StardewValley.Monsters.Monster::health"] = "Health", ["StardewValley.Monsters.Monster::maxHealth"] = "MaxHealth", ["StardewValley.Monsters.Monster::netFocusedOnFarmers"] = "focusedOnFarmers", ["StardewValley.Monsters.Monster::netWildernessFarmMonster"] = "wildernessFarmMonster", ["StardewValley.Monsters.Monster::slipperiness"] = "Slipperiness", // NPC ["StardewValley.NPC::age"] = "Age", ["StardewValley.NPC::birthday_Day"] = "Birthday_Day", ["StardewValley.NPC::birthday_Season"] = "Birthday_Season", ["StardewValley.NPC::breather"] = "Breather", ["StardewValley.NPC::defaultMap"] = "DefaultMap", ["StardewValley.NPC::gender"] = "Gender", ["StardewValley.NPC::hideShadow"] = "HideShadow", ["StardewValley.NPC::isInvisible"] = "IsInvisible", ["StardewValley.NPC::isWalkingTowardPlayer"] = "IsWalkingTowardPlayer", ["StardewValley.NPC::manners"] = "Manners", ["StardewValley.NPC::optimism"] = "Optimism", ["StardewValley.NPC::socialAnxiety"] = "SocialAnxiety", // Object ["StardewValley.Object::canBeGrabbed"] = "CanBeGrabbed", ["StardewValley.Object::canBeSetDown"] = "CanBeSetDown", ["StardewValley.Object::edibility"] = "Edibility", ["StardewValley.Object::flipped"] = "Flipped", ["StardewValley.Object::fragility"] = "Fragility", ["StardewValley.Object::hasBeenPickedUpByFarmer"] = "HasBeenPickedUpByFarmer", ["StardewValley.Object::isHoedirt"] = "IsHoeDirt", ["StardewValley.Object::isOn"] = "IsOn", ["StardewValley.Object::isRecipe"] = "IsRecipe", ["StardewValley.Object::isSpawnedObject"] = "IsSpawnedObject", ["StardewValley.Object::minutesUntilReady"] = "MinutesUntilReady", ["StardewValley.Object::netName"] = "name", ["StardewValley.Object::price"] = "Price", ["StardewValley.Object::quality"] = "Quality", ["StardewValley.Object::stack"] = "Stack", ["StardewValley.Object::tileLocation"] = "TileLocation", ["StardewValley.Object::type"] = "Type", // Projectile ["StardewValley.Projectiles.Projectile::ignoreLocationCollision"] = "IgnoreLocationCollision", // Tool ["StardewValley.Tool::currentParentTileIndex"] = "CurrentParentTileIndex", ["StardewValley.Tool::indexOfMenuItemView"] = "IndexOfMenuItemView", ["StardewValley.Tool::initialParentTileIndex"] = "InitialParentTileIndex", ["StardewValley.Tool::instantUse"] = "InstantUse", ["StardewValley.Tool::netName"] = "BaseName", ["StardewValley.Tool::stackable"] = "Stackable", ["StardewValley.Tool::upgradeLevel"] = "UpgradeLevel" }; /// The diagnostic info for an implicit net field cast. private readonly DiagnosticDescriptor AvoidImplicitNetFieldCastRule = new( id: "AvoidImplicitNetFieldCast", title: "Netcode types shouldn't be implicitly converted", messageFormat: "This implicitly converts '{0}' from {1} to {2}, but {1} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/package/avoid-implicit-net-field-cast for details.", category: "SMAPI.CommonErrors", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, helpLinkUri: "https://smapi.io/package/avoid-implicit-net-field-cast" ); /// The diagnostic info for an avoidable net field access. private readonly DiagnosticDescriptor AvoidNetFieldRule = new( id: "AvoidNetField", title: "Avoid Netcode types when possible", messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/package/avoid-net-field for details.", category: "SMAPI.CommonErrors", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, helpLinkUri: "https://smapi.io/package/avoid-net-field" ); /********* ** Accessors *********/ /// The descriptors for the diagnostics that this analyzer is capable of producing. public override ImmutableArray SupportedDiagnostics { get; } /********* ** Public methods *********/ /// Construct an instance. public NetFieldAnalyzer() { this.SupportedDiagnostics = ImmutableArray.CreateRange(new[] { this.AvoidNetFieldRule, this.AvoidImplicitNetFieldCastRule }); } /// Called once at session start to register actions in the analysis context. /// The analysis context. public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); context.EnableConcurrentExecution(); context.RegisterSyntaxNodeAction( this.AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression, SyntaxKind.ConditionalAccessExpression ); context.RegisterSyntaxNodeAction( this.AnalyzeCast, SyntaxKind.CastExpression, SyntaxKind.AsExpression ); context.RegisterSyntaxNodeAction( this.AnalyzeBinaryComparison, SyntaxKind.EqualsExpression, SyntaxKind.NotEqualsExpression, SyntaxKind.GreaterThanExpression, SyntaxKind.GreaterThanOrEqualExpression, SyntaxKind.LessThanExpression, SyntaxKind.LessThanOrEqualExpression ); } /********* ** Private methods *********/ /// Analyze a member access syntax node and add a diagnostic message if applicable. /// The analysis context. /// Returns whether any warnings were added. private void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) { this.HandleErrors(context.Node, () => { // get member access info if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName)) return; if (!this.IsNetType(memberType.Type)) return; // warn: use property wrapper if available foreach (ITypeSymbol type in AnalyzerUtilities.GetConcreteTypes(declaringType)) { if (this.NetFieldWrapperProperties.TryGetValue($"{type}::{memberName}", out string suggestedPropertyName)) { context.ReportDiagnostic(Diagnostic.Create(this.AvoidNetFieldRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, suggestedPropertyName)); return; } } // warn: implicit conversion if (this.IsInvalidConversion(memberType.Type, memberType.ConvertedType)) context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, memberType.ConvertedType)); }); } /// Analyze an explicit cast or 'x as y' node and add a diagnostic message if applicable. /// The analysis context. /// Returns whether any warnings were added. private void AnalyzeCast(SyntaxNodeAnalysisContext context) { // NOTE: implicit conversion within the expression is detected by the member access // checks. This method is only concerned with the conversion of its final value. this.HandleErrors(context.Node, () => { if (AnalyzerUtilities.TryGetCastOrAsInfo(context.Node, context.SemanticModel, out ExpressionSyntax fromExpression, out TypeInfo fromType, out TypeInfo toType)) { if (this.IsInvalidConversion(fromType.ConvertedType, toType.Type)) context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), fromExpression, fromType.ConvertedType.Name, toType.Type)); } }); } /// Analyze a binary comparison syntax node and add a diagnostic message if applicable. /// The analysis context. /// Returns whether any warnings were added. private void AnalyzeBinaryComparison(SyntaxNodeAnalysisContext context) { // NOTE: implicit conversion within an operand is detected by the member access checks. // This method is only concerned with the conversion of each side's final value. this.HandleErrors(context.Node, () => { BinaryExpressionSyntax expression = (BinaryExpressionSyntax)context.Node; foreach (var pair in new[] { Tuple.Create(expression.Left, expression.Right), Tuple.Create(expression.Right, expression.Left) }) { // get node info ExpressionSyntax curExpression = pair.Item1; // the side of the comparison being examined ExpressionSyntax otherExpression = pair.Item2; // the other side TypeInfo curType = context.SemanticModel.GetTypeInfo(curExpression); TypeInfo otherType = context.SemanticModel.GetTypeInfo(otherExpression); if (!this.IsNetType(curType.ConvertedType)) continue; // warn for comparison to null // An expression like `building.indoors != null` will sometimes convert `building.indoors` to NetFieldBase instead of object before comparison. Haven't reproduced this in unit tests yet. Optional otherValue = context.SemanticModel.GetConstantValue(otherExpression); if (otherValue.HasValue && otherValue.Value == null) { context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), curExpression, curType.Type.Name, "null")); break; } // warn for implicit conversion if (!this.IsNetType(otherType.ConvertedType)) { context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), curExpression, curType.Type.Name, curType.ConvertedType)); break; } } }); } /// Handle exceptions raised while analyzing a node. /// The node being analyzed. /// The callback to invoke. private void HandleErrors(SyntaxNode node, Action action) { try { action(); } catch (Exception ex) { throw new InvalidOperationException($"Failed processing expression: '{node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}"); } } /// Get whether a net field was converted in an error-prone way. /// The source type. /// The target type. private bool IsInvalidConversion(ITypeSymbol fromType, ITypeSymbol toType) { // no conversion if (!this.IsNetType(fromType) || this.IsNetType(toType)) return false; // conversion to implemented interface is OK if (fromType.AllInterfaces.Contains(toType, SymbolEqualityComparer.Default)) return false; // avoid any other conversions return true; } /// Get whether a type symbol references a Netcode type. /// The type symbol. private bool IsNetType(ITypeSymbol typeSymbol) { return typeSymbol?.ContainingNamespace?.Name == NetFieldAnalyzer.NetcodeNamespace; } } }