Skip to content

Commit

Permalink
Format NumericLiteral fraction too
Browse files Browse the repository at this point in the history
  • Loading branch information
menees committed May 27, 2024
1 parent 89036c3 commit 2a2cf64
Show file tree
Hide file tree
Showing 2 changed files with 98 additions and 36 deletions.
115 changes: 81 additions & 34 deletions src/Menees.Analyzers/NumericLiteral.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public sealed class NumericLiteral
private static readonly HashSet<char> BinaryDigits = ['0', '1'];
private static readonly HashSet<char> DecimalDigits = [.. BinaryDigits, '2', '3', '4', '5', '6', '7', '8', '9'];
private static readonly HashSet<char> HexadecimalDigits = [.. DecimalDigits, 'A', 'B', 'C', 'D', 'E', 'F', 'a', 'b', 'c', 'd', 'e', 'f'];
private static readonly char[] ExponentChar = ['e', 'E'];

private string originalText;

Expand Down Expand Up @@ -113,57 +114,62 @@ public string ToString(byte groupSize)

if (groupSize == 0)
{
result = this.ToString();
result = $"{this.Prefix}{this.ScrubbedDigits}{this.Suffix}";
}
else
{
const int SeparatorPadding = 10;
StringBuilder sb = new(this.Prefix.Length + this.ScrubbedDigits.Length + this.Suffix.Length + SeparatorPadding);
sb.Append(this.Prefix);

// For integers, we can format all the scrubbed digits.
// For real numbers, we only format the integral part, and we don't format scientific notation.
int formatLength = this.ScrubbedDigits.Length;
if (!this.IsInteger)
if (this.IsInteger)
{
if (this.ScrubbedDigits.Contains('e', StringComparison.OrdinalIgnoreCase))
this.AppendIntegerDigits(sb, 0, this.ScrubbedDigits.Length, groupSize, this.Prefix.Length > 0);
}
else
{
// A real number is basically "Integer Fraction Exponent", and any of those parts can be empty.
// They can't all be empty, and we can't have just an Exponent part. The other six possibilities
// are allowed: .123, .12e3, 123d, 12e3, 1.23, 1.2e3
int decimalIndex = this.ScrubbedDigits.IndexOf('.');
int exponentIndex = this.ScrubbedDigits.IndexOfAny(ExponentChar, decimalIndex + 1);

if (decimalIndex < 0 && exponentIndex < 0)
{
// Scientific notation is its own type of formatting, so we won't try to mix in separators.
formatLength = 0;
// Integer part only. Example: 123d
this.AppendIntegerDigits(sb, 0, this.ScrubbedDigits.Length, groupSize);
}
else
else if (exponentIndex < 0)
{
// We have no exponent, and we only want to return the integer part.
// Think about 123D, 123.0, .123, and 0.123.
int decimalIndex = this.ScrubbedDigits.IndexOf('.');
if (decimalIndex >= 0)
{
formatLength = decimalIndex;
}
// Has fraction part and may have an integer part. Examples: .123 or 1.23
this.AppendIntegerDigits(sb, 0, decimalIndex, groupSize);
sb.Append(this.ScrubbedDigits[decimalIndex]);
int fractionIndex = decimalIndex + 1;
this.AppendScrubbedDigits(sb, fractionIndex, this.ScrubbedDigits.Length - fractionIndex, groupSize, fractionIndex + groupSize);
}
}
else if (decimalIndex < 0)
{
// Has exponent part with a required integer part. Example: 12e3
this.AppendIntegerDigits(sb, 0, exponentIndex, groupSize);

// A separator can't come first, but it can follow a prefix. A separator can never be last.
int modulus = formatLength % groupSize;
int separatorIndex = modulus == 0 && this.Prefix.Length == 0 ? groupSize : modulus;
for (int index = 0; index < formatLength; index++)
{
if (index == separatorIndex)
// See comments below about why we don't format the exponent.
sb.Append(this.ScrubbedDigits, exponentIndex, this.ScrubbedDigits.Length - exponentIndex);
}
else
{
separatorIndex += groupSize;
if (sb.Length > 0)
{
sb.Append('_');
}
// Has fraction part, exponent part, and maybe an integer part. Examples: .12e3 or 1.2e3
this.AppendIntegerDigits(sb, 0, decimalIndex, groupSize);
sb.Append(this.ScrubbedDigits[decimalIndex]);
int fractionIndex = decimalIndex + 1;
this.AppendScrubbedDigits(sb, fractionIndex, exponentIndex - fractionIndex, groupSize, fractionIndex + groupSize);

// Note: Double only allows 3 digit exponents, so we'll never try to format those.
// Technically, we could if groupSize < 3, but it's so rare that it's not worth the
// hassle of parsing the optional sign and doing another integer right-to-left format.
sb.Append(this.ScrubbedDigits, exponentIndex, this.ScrubbedDigits.Length - exponentIndex);
}

sb.Append(this.ScrubbedDigits, index, 1);
}

// TODO: Format fractional part too? Not reversed grouping like integer part. [Bill, 5/26/2024]
// For reals, this will add the fractional or scientific notation part.
sb.Append(this.ScrubbedDigits, formatLength, this.ScrubbedDigits.Length - formatLength);

sb.Append(this.Suffix);
result = sb.ToString();
}
Expand Down Expand Up @@ -254,5 +260,46 @@ private static bool TryParseReal(string text, [NotNullWhen(true)] out NumericLit
return value != null;
}

/// <summary>
/// Appends the integer portion of <see cref="ScrubbedDigits"/> with separators added from right to left.
/// </summary>
private void AppendIntegerDigits(
StringBuilder sb,
int startIndex,
int length,
byte groupSize,
bool allowLeadingSeparator = false)
{
// A separator can't come first, but it can follow a prefix. A separator can never be last.
int modulus = length % groupSize;
int separatorIndex = (modulus == 0 && !allowLeadingSeparator ? groupSize : modulus) + startIndex;
AppendScrubbedDigits(sb, startIndex, length, groupSize, separatorIndex);
}

/// <summary>
/// Appends any portion of <see cref="ScrubbedDigits"/> with separators added from left to right.
/// </summary>
private void AppendScrubbedDigits(
StringBuilder sb,
int startIndex,
int length,
byte groupSize,
int separatorIndex)
{
for (int index = startIndex; index < (startIndex + length); index++)
{
if (index == separatorIndex)
{
separatorIndex += groupSize;
if (sb.Length > 0)
{
sb.Append('_');
}
}

sb.Append(this.ScrubbedDigits, index, 1);
}
}

#endregion
}
19 changes: 17 additions & 2 deletions tests/Menees.Analyzers.Test/NumericLiteralTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,22 @@ public void ToStringTest()
Test("0b1111_1111_0000UL", "0b_1111_1111_0000UL");
Test("0B__111", "0B111");

Test("1.234_567", "1.234567");
Test("1.234_567", "1.234567", 0);
Test("1_234.567", "1234.567", 0);

Test("1.234567", "1.234_567");
Test("123.4567", "123.456_7");
Test("1234.567", "1_234.567");
Test(".123456", ".123_456");
Test(".1234567", ".123_456_7");
Test(".12345e67", ".123_45e67");
Test("1234567d", "1_234_567d");
Test("12345e67", "12_345e67");
Test("1234.567e89", "1_234.567e89");
Test("1234.5678e9", "1_234.567_8e9");

Test(".3e5f", ".3e5f");
Test("2_345E-2_0", "2345E-20");
Test("2345E-2_0", "2_345E-20");
Test("15D", "15D");
Test("19.73M", "19.73M");
Test("1234d", "1_234d");
Expand All @@ -108,6 +121,8 @@ static void Test(string text, string expected, byte? groupSize = null)
{
NumericLiteral.TryParse(text, out NumericLiteral? literal).ShouldBeTrue(text);
literal.ToString().ShouldBe(text);
literal.ToString(0).ShouldBe(text.Replace("_", string.Empty));

groupSize ??= literal.Base switch
{
NumericBase.Hexadecimal => 2,
Expand Down

0 comments on commit 2a2cf64

Please sign in to comment.