Skip to content

Commit 97a8e90

Browse files
committed
Add Analyzer to warn about decimal<=>float/double casts
fixes c882fe4 and 32a66a9
1 parent 6dbee18 commit 97a8e90

File tree

10 files changed

+203
-7
lines changed

10 files changed

+203
-7
lines changed

.global.editorconfig.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ dotnet_diagnostic.BHI1102.severity = error
2929
dotnet_diagnostic.BHI1103.severity = error
3030
# Don't use ^= (XOR-assign) for inverting the value of booleans
3131
dotnet_diagnostic.BHI1104.severity = error
32+
# Use unambiguous decimal<=>float/double conversion methods
33+
dotnet_diagnostic.BHI1105.severity = error
3234
# Brackets of collection expression should be separated with spaces
3335
dotnet_diagnostic.BHI1110.severity = warning
3436
# Expression-bodied member should be flowed to next line correctly
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
namespace BizHawk.Analyzers;
2+
3+
using System.Collections.Immutable;
4+
5+
using Microsoft.CodeAnalysis;
6+
using Microsoft.CodeAnalysis.CSharp;
7+
using Microsoft.CodeAnalysis.Diagnostics;
8+
using Microsoft.CodeAnalysis.Operations;
9+
10+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
11+
public sealed class AmbiguousMoneyToFloatConversionAnalyzer : DiagnosticAnalyzer
12+
{
13+
private static readonly DiagnosticDescriptor DiagAmbiguousMoneyToFloatConversion = new(
14+
id: "BHI1105",
15+
title: "Use unambiguous decimal<=>float/double conversion methods",
16+
messageFormat: "use {0} for checked conversion, or {1} for unchecked",
17+
category: "Usage",
18+
defaultSeverity: DiagnosticSeverity.Warning,
19+
isEnabledByDefault: true);
20+
21+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(DiagAmbiguousMoneyToFloatConversion);
22+
23+
public override void Initialize(AnalysisContext context)
24+
{
25+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
26+
context.EnableConcurrentExecution();
27+
context.RegisterCompilationStartAction(initContext =>
28+
{
29+
var decimalSym = initContext.Compilation.GetTypeByMetadataName("System.Decimal")!;
30+
var doubleSym = initContext.Compilation.GetTypeByMetadataName("System.Double")!;
31+
var floatSym = initContext.Compilation.GetTypeByMetadataName("System.Single")!;
32+
initContext.RegisterOperationAction(oac =>
33+
{
34+
var conversionOp = (IConversionOperation) oac.Operation;
35+
var typeOutput = conversionOp.Type;
36+
var typeInput = conversionOp.Operand.Type;
37+
bool isToDecimal;
38+
bool isDoublePrecision;
39+
if (decimalSym.Matches(typeOutput))
40+
{
41+
if (doubleSym.Matches(typeInput)) isDoublePrecision = true;
42+
else if (floatSym.Matches(typeInput)) isDoublePrecision = false;
43+
else return;
44+
isToDecimal = true;
45+
}
46+
else if (decimalSym.Matches(typeInput))
47+
{
48+
if (doubleSym.Matches(typeOutput)) isDoublePrecision = true;
49+
else if (floatSym.Matches(typeOutput)) isDoublePrecision = false;
50+
else return;
51+
isToDecimal = false;
52+
}
53+
else
54+
{
55+
return;
56+
}
57+
var conversionSyn = conversionOp.Syntax;
58+
//TODO check the suggested methods are accessible (i.e. BizHawk.Common is referenced)
59+
oac.ReportDiagnostic(Diagnostic.Create(
60+
DiagAmbiguousMoneyToFloatConversion,
61+
(conversionSyn.Parent?.Kind() is SyntaxKind.CheckedExpression or SyntaxKind.UncheckedExpression
62+
? conversionSyn.Parent
63+
: conversionSyn).GetLocation(),
64+
conversionOp.IsChecked ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning,
65+
additionalLocations: null,
66+
properties: null,
67+
messageArgs: isToDecimal
68+
? [
69+
$"new decimal({(isDoublePrecision ? "double" : "float")})", // "checked"
70+
"static NumberExtensions.ConvertToMoneyTruncated", // "unchecked"
71+
]
72+
: [
73+
$"decimal.{(isDoublePrecision ? "ConvertToF64" : "ConvertToF32")} ext. (from NumberExtensions)", // "checked"
74+
$"static Decimal.{(isDoublePrecision ? "ToDouble" : "ToSingle")}", // "unchecked"
75+
]));
76+
},
77+
OperationKind.Conversion);
78+
});
79+
}
80+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
namespace BizHawk.Tests.Analyzers;
2+
3+
using System.Threading.Tasks;
4+
5+
using Microsoft.VisualStudio.TestTools.UnitTesting;
6+
7+
using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
8+
BizHawk.Analyzers.AmbiguousMoneyToFloatConversionAnalyzer,
9+
Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
10+
11+
[TestClass]
12+
public sealed class AmbiguousMoneyToFloatConversionAnalyzerTests
13+
{
14+
[TestMethod]
15+
public Task CheckMisuseOfDecimalExplicitCastOperators()
16+
=> Verify.VerifyAnalyzerAsync("""
17+
public static class Cases {
18+
private static float Y(decimal m)
19+
=> decimal.ToSingle(m);
20+
private static decimal Z(double d)
21+
=> new(d);
22+
private static float A(decimal m)
23+
=> {|BHI1105:unchecked((float) m)|};
24+
private static decimal B(double d)
25+
=> {|BHI1105:checked((decimal) d)|};
26+
private static decimal C(float d)
27+
=> {|BHI1105:unchecked((decimal) d)|};
28+
private static double D(decimal m)
29+
=> {|BHI1105:checked((double) m)|};
30+
}
31+
""");
32+
}

ExternalProjects/LibCommon.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project>
22
<Import Project="../Common.props" />
33
<PropertyGroup>
4-
<NoWarn>$(NoWarn);MEN018;SA1200</NoWarn>
4+
<NoWarn>$(NoWarn);BHI1105;MEN018;SA1200</NoWarn>
55
</PropertyGroup>
66
<ItemGroup>
77
<None Remove="*.sh" />

References/BizHawk.Analyzer.dll

2 KB
Binary file not shown.

src/BizHawk.Client.Common/movie/BasicMovieInfo.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Globalization;
33
using System.IO;
44

5+
using BizHawk.Common.NumberExtensions;
56
using BizHawk.Common.StringExtensions;
67
using BizHawk.Emulation.Common;
78

@@ -71,7 +72,7 @@ public double FrameRate
7172
const decimal attosInSec = 1_000_000_000_000_000_000.0M;
7273
var m = attosInSec;
7374
m /= ulong.Parse(vsyncAttoStr);
74-
return checked((double) m);
75+
return m.ConvertToF64();
7576
}
7677

7778
return PlatformFrameRates.GetFrameRate(SystemID, IsPal);

src/BizHawk.Client.EmuHawk/config/RewindConfig.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Numerics;
12
using System.Windows.Forms;
23

34
using BizHawk.Client.Common;
@@ -57,7 +58,12 @@ private void RewindConfig_Load(object sender, EventArgs e)
5758
RewindEnabledBox.Checked = _config.Rewind.Enabled;
5859
UseCompression.Checked = _config.Rewind.UseCompression;
5960
cbDeltaCompression.Checked = _config.Rewind.UseDelta;
60-
BufferSizeUpDown.Value = Math.Max((decimal) Math.Log(_config.Rewind.BufferSize, 2), BufferSizeUpDown.Minimum);
61+
BufferSizeUpDown.Value = Math.Max(
62+
BufferSizeUpDown.Minimum,
63+
_config.Rewind.BufferSize < 0L
64+
? 0.0M
65+
: new decimal(BitOperations.Log2(unchecked((ulong) _config.Rewind.BufferSize)))
66+
);
6167
TargetFrameLengthRadioButton.Checked = !_config.Rewind.UseFixedRewindInterval;
6268
TargetRewindIntervalRadioButton.Checked = _config.Rewind.UseFixedRewindInterval;
6369
TargetFrameLengthNumeric.Value = Math.Max(_config.Rewind.TargetFrameLength, TargetFrameLengthNumeric.Minimum);

src/BizHawk.Client.EmuHawk/tools/BasicBot/BotControlsRow.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System.Windows.Forms;
22

3+
using BizHawk.Common.NumberExtensions;
4+
35
namespace BizHawk.Client.EmuHawk
46
{
57
public partial class BotControlsRow : UserControl
@@ -21,8 +23,8 @@ public string ButtonName
2123

2224
public double Probability
2325
{
24-
get => (double)ProbabilityUpDown.Value;
25-
set => ProbabilityUpDown.Value = (decimal)value;
26+
get => ProbabilityUpDown.Value.ConvertToF64();
27+
set => ProbabilityUpDown.Value = new(value);
2628
}
2729

2830
private void ProbabilityUpDown_ValueChanged(object sender, EventArgs e)

src/BizHawk.Client.EmuHawk/tools/VirtualPads/controls/VirtualPadTargetScreen.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Drawing;
22
using System.Windows.Forms;
33
using BizHawk.Client.Common;
4+
using BizHawk.Common.NumberExtensions;
45
using BizHawk.Emulation.Common;
56

67
namespace BizHawk.Client.EmuHawk
@@ -195,7 +196,7 @@ public int X
195196
XNumeric.Value = XNumeric.Maximum;
196197
}
197198

198-
_stickyXorAdapter.SetAxis(XName, (int)((float)XNumeric.Value * MultiplierX));
199+
_stickyXorAdapter.SetAxis(XName, (XNumeric.Value.ConvertToF32() * MultiplierX).RoundToInt());
199200
_isSet = true;
200201
}
201202
}
@@ -217,7 +218,7 @@ public int Y
217218
YNumeric.Value = YNumeric.Maximum;
218219
}
219220

220-
_stickyXorAdapter.SetAxis(YName, (int)((float)YNumeric.Value * MultiplierY));
221+
_stickyXorAdapter.SetAxis(YName, (YNumeric.Value.ConvertToF32() * MultiplierY).RoundToInt());
221222
_isSet = true;
222223
}
223224
}

src/BizHawk.Common/Extensions/NumberExtensions.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ namespace BizHawk.Common.NumberExtensions
55
{
66
public static class NumberExtensions
77
{
8+
private const string ERR_MSG_PRECISION_LOSS = "unable to convert from decimal without loss of precision";
9+
810
public static string ToHexString(this int n, int numDigits)
911
{
1012
return string.Format($"{{0:X{numDigits}}}", n);
@@ -55,6 +57,76 @@ public static byte BCDtoBin(this byte v)
5557
return (byte)(((v / 16) * 10) + (v % 16));
5658
}
5759

60+
/// <returns>the <see langword="float"/> whose value is closest to <paramref name="m"/></returns>
61+
/// <exception cref="OverflowException">loss of precision (the value won't survive a round-trip)</exception>
62+
/// <remarks>like a <c>checked</c> conversion</remarks>
63+
public static float ConvertToF32(this decimal m)
64+
{
65+
var f = decimal.ToSingle(m);
66+
return m.Equals(new decimal(f)) ? f : throw new OverflowException(ERR_MSG_PRECISION_LOSS);
67+
}
68+
69+
/// <returns>the <see langword="double"/> whose value is closest to <paramref name="m"/></returns>
70+
/// <exception cref="OverflowException">loss of precision (the value won't survive a round-trip)</exception>
71+
/// <remarks>like a <c>checked</c> conversion</remarks>
72+
public static double ConvertToF64(this decimal m)
73+
{
74+
var d = decimal.ToDouble(m);
75+
return m.Equals(new decimal(d)) ? d : throw new OverflowException(ERR_MSG_PRECISION_LOSS);
76+
}
77+
78+
/// <returns>the <see langword="decimal"/> whose value is closest to <paramref name="f"/></returns>
79+
/// <exception cref="NotFiniteNumberException">
80+
/// iff <paramref name="f"/> is NaN and <paramref name="throwIfNaN"/> is set
81+
/// (infinite values are rounded to <see cref="decimal.MinValue"/>/<see cref="decimal.MaxValue"/>)
82+
/// </exception>
83+
/// <remarks>like an <c>unchecked</c> conversion</remarks>
84+
public static decimal ConvertToMoneyTruncated(float f, bool throwIfNaN = false)
85+
{
86+
try
87+
{
88+
#pragma warning disable BHI1105 // this is the sanctioned call-site
89+
return (decimal) f;
90+
#pragma warning restore BHI1105
91+
}
92+
catch (OverflowException)
93+
{
94+
return float.IsNaN(f)
95+
? throwIfNaN
96+
? throw new NotFiniteNumberException(f)
97+
: default
98+
: f < 0.0f
99+
? decimal.MinValue
100+
: decimal.MaxValue;
101+
}
102+
}
103+
104+
/// <returns>the <see langword="decimal"/> whose value is closest to <paramref name="d"/></returns>
105+
/// <exception cref="NotFiniteNumberException">
106+
/// iff <paramref name="d"/> is NaN and <paramref name="throwIfNaN"/> is set
107+
/// (infinite values are rounded to <see cref="decimal.MinValue"/>/<see cref="decimal.MaxValue"/>)
108+
/// </exception>
109+
/// <remarks>like an <c>unchecked</c> conversion</remarks>
110+
public static decimal ConvertToMoneyTruncated(double d, bool throwIfNaN = false)
111+
{
112+
try
113+
{
114+
#pragma warning disable BHI1105 // this is the sanctioned call-site
115+
return (decimal) d;
116+
#pragma warning restore BHI1105
117+
}
118+
catch (OverflowException)
119+
{
120+
return double.IsNaN(d)
121+
? throwIfNaN
122+
? throw new NotFiniteNumberException(d)
123+
: default
124+
: d < 0.0
125+
? decimal.MinValue
126+
: decimal.MaxValue;
127+
}
128+
}
129+
58130
/// <summary>
59131
/// Receives a number and returns the number of hexadecimal digits it is
60132
/// Note: currently only returns 2, 4, 6, or 8

0 commit comments

Comments
 (0)