Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -258,4 +258,30 @@ public void MultiMathExpressionConverterNullInputTest()
#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type.
Assert.Throws<ArgumentNullException>(() => new MultiMathExpressionConverter().Convert([0.0, 7], typeof(bool), null, null));
}

[Theory]
[InlineData("en-US", "x0 ? -123.4 : 123.4", new object?[] { true }, -123.4)]
[InlineData("en-US", "x0 ? -123.4 : 123.4", new object?[] { false }, 123.4)]
[InlineData("en-US", "str(x0)", new object?[] { 3.1415 }, "3.1415")]
[InlineData("en-US", "len(str(x0))", new object?[] { 3.1415 }, 6)]
[InlineData("en-US", "x0 * x1", new object?[] { "3.1415", 2 }, 6.283)]
[InlineData("en-US", "int(double(x0))", new object?[] { "3.1415" }, 3)]
[InlineData("en-US", "int(double(x0)) / x1", new object?[] { "3.1415", 2 }, 1.5)]
[InlineData("ar-AR", "x0 ? -123.4 : 123.4", new object?[] { true }, -123.4)]
[InlineData("ar-AR", "x0 ? -123.4 : 123.4", new object?[] { false }, 123.4)]
[InlineData("ar-AR", "str(x0)", new object?[] { 3.1415 }, "3.1415")]
[InlineData("ar-AR", "len(str(x0))", new object?[] { 3.1415 }, 6)]
[InlineData("ar-AR", "x0 * x1", new object?[] { "3.1415", 2 }, 6.283)]
[InlineData("ar-AR", "int(double(x0))", new object?[] { "3.1415" }, 3)]
[InlineData("ar-AR", "int(double(x0)) / x1", new object?[] { "3.1415", 2 }, 1.5)]
public void MathExpressionConverter_WithAlternateCulture_ReturnsCorrectNumericResult(string cultureName, string expression, object[] variables, object? expectedResult)
{
CultureInfo.CurrentCulture = new CultureInfo(cultureName);
var mathExpressionConverter = new MultiMathExpressionConverter();

object? result = mathExpressionConverter.Convert(variables, mathExpressionTargetType, expression);

Assert.True(result is not null);
Assert.Equal(expectedResult, result);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.RegularExpressions;
using CommunityToolkit.Maui.Core;

Expand All @@ -27,56 +26,56 @@ internal MathExpression(in string expression, in IReadOnlyList<object?> argument

List<MathOperator> operators =
[
new ("+", 2, x => Convert.ToDouble(x[0]) + Convert.ToDouble(x[1])),
new ("-", 2, x => Convert.ToDouble(x[0]) - Convert.ToDouble(x[1])),
new ("*", 2, x => Convert.ToDouble(x[0]) * Convert.ToDouble(x[1])),
new ("/", 2, x => Convert.ToDouble(x[0]) / Convert.ToDouble(x[1])),
new ("%", 2, x => Convert.ToDouble(x[0]) % Convert.ToDouble(x[1])),
new ("+", 2, x => ConvertToDouble(x[0]) + ConvertToDouble(x[1])),
new ("-", 2, x => ConvertToDouble(x[0]) - ConvertToDouble(x[1])),
new ("*", 2, x => ConvertToDouble(x[0]) * ConvertToDouble(x[1])),
new ("/", 2, x => ConvertToDouble(x[0]) / ConvertToDouble(x[1])),
new ("%", 2, x => ConvertToDouble(x[0]) % ConvertToDouble(x[1])),

new ("and", 2, x => ConvertToBoolean(x[0]) ? x[1] : x[0]),
new ("or", 2, x => ConvertToBoolean(x[0]) ? x[0] : x[1]),

new ("==", 2, x => object.Equals(x[0], x[1])),
new ("!=", 2, x => !object.Equals(x[0], x[1])),

new ("ge", 2, x => Convert.ToDouble(x[0]) >= Convert.ToDouble(x[1])),
new ("gt", 2, x => Convert.ToDouble(x[0]) > Convert.ToDouble(x[1])),
new ("le", 2, x => Convert.ToDouble(x[0]) <= Convert.ToDouble(x[1])),
new ("lt", 2, x => Convert.ToDouble(x[0]) < Convert.ToDouble(x[1])),
new ("neg", 1, x => -Convert.ToDouble(x[0])),
new ("ge", 2, x => ConvertToDouble(x[0]) >= ConvertToDouble(x[1])),
new ("gt", 2, x => ConvertToDouble(x[0]) > ConvertToDouble(x[1])),
new ("le", 2, x => ConvertToDouble(x[0]) <= ConvertToDouble(x[1])),
new ("lt", 2, x => ConvertToDouble(x[0]) < ConvertToDouble(x[1])),
new ("neg", 1, x => -ConvertToDouble(x[0])),
new ("not", 1, x => !ConvertToBoolean(x[0])),
new ("if", 3, x => ConvertToBoolean(x[0]) ? x[1] : x[2]),

new ("abs", 1, x => Math.Abs(Convert.ToDouble(x[0]))),
new ("acos", 1, x => Math.Acos(Convert.ToDouble(x[0]))),
new ("asin", 1, x => Math.Asin(Convert.ToDouble(x[0]))),
new ("atan", 1, x => Math.Atan(Convert.ToDouble(x[0]))),
new ("atan2", 2, x => Math.Atan2(Convert.ToDouble(x[0]), Convert.ToDouble(x[1]))),
new ("ceiling", 1, x => Math.Ceiling(Convert.ToDouble(x[0]))),
new ("cos", 1, x => Math.Cos(Convert.ToDouble(x[0]))),
new ("cosh", 1, x => Math.Cosh(Convert.ToDouble(x[0]))),
new ("exp", 1, x => Math.Exp(Convert.ToDouble(x[0]))),
new ("floor", 1, x => Math.Floor(Convert.ToDouble(x[0]))),
new ("ieeeremainder", 2, x => Math.IEEERemainder(Convert.ToDouble(x[0]), Convert.ToDouble(x[1]))),
new ("log", 2, x => Math.Log(Convert.ToDouble(x[0]), Convert.ToDouble(x[1]))),
new ("log10", 1, x => Math.Log10(Convert.ToDouble(x[0]))),
new ("max", 2, x => Math.Max(Convert.ToDouble(x[0]), Convert.ToDouble(x[1]))),
new ("min", 2, x => Math.Min(Convert.ToDouble(x[0]), Convert.ToDouble(x[1]))),
new ("pow", 2, x => Math.Pow(Convert.ToDouble(x[0]), Convert.ToDouble(x[1]))),
new ("round", 2, x => Math.Round(Convert.ToDouble(x[0]), Convert.ToInt32(x[1]))),
new ("sign", 1, x => Math.Sign(Convert.ToDouble(x[0]))),
new ("sin", 1, x => Math.Sin(Convert.ToDouble(x[0]))),
new ("sinh", 1, x => Math.Sinh(Convert.ToDouble(x[0]))),
new ("sqrt", 1, x => Math.Sqrt(Convert.ToDouble(x[0]))),
new ("tan", 1, x => Math.Tan(Convert.ToDouble(x[0]))),
new ("tanh", 1, x => Math.Tanh(Convert.ToDouble(x[0]))),
new ("truncate", 1, x => Math.Truncate(Convert.ToDouble(x[0]))),
new ("int", 1, x => Convert.ToInt32(x[0])),
new ("double", 1, x => Convert.ToDouble(x[0])),
new ("bool", 1, x => Convert.ToBoolean(x[0])),
new ("str", 1, x => x[0]?.ToString()),
new ("len", 1, x => x[0]?.ToString()?.Length),
new ("^", 2, x => Math.Pow(Convert.ToDouble(x[0]), Convert.ToDouble(x[1]))),
new ("abs", 1, x => Math.Abs(ConvertToDouble(x[0]))),
new ("acos", 1, x => Math.Acos(ConvertToDouble(x[0]))),
new ("asin", 1, x => Math.Asin(ConvertToDouble(x[0]))),
new ("atan", 1, x => Math.Atan(ConvertToDouble(x[0]))),
new ("atan2", 2, x => Math.Atan2(ConvertToDouble(x[0]), ConvertToDouble(x[1]))),
new ("ceiling", 1, x => Math.Ceiling(ConvertToDouble(x[0]))),
new ("cos", 1, x => Math.Cos(ConvertToDouble(x[0]))),
new ("cosh", 1, x => Math.Cosh(ConvertToDouble(x[0]))),
new ("exp", 1, x => Math.Exp(ConvertToDouble(x[0]))),
new ("floor", 1, x => Math.Floor(ConvertToDouble(x[0]))),
new ("ieeeremainder", 2, x => Math.IEEERemainder(ConvertToDouble(x[0]), ConvertToDouble(x[1]))),
new ("log", 2, x => Math.Log(ConvertToDouble(x[0]), ConvertToDouble(x[1]))),
new ("log10", 1, x => Math.Log10(ConvertToDouble(x[0]))),
new ("max", 2, x => Math.Max(ConvertToDouble(x[0]), ConvertToDouble(x[1]))),
new ("min", 2, x => Math.Min(ConvertToDouble(x[0]), ConvertToDouble(x[1]))),
new ("pow", 2, x => Math.Pow(ConvertToDouble(x[0]), ConvertToDouble(x[1]))),
new ("round", 2, x => Math.Round(ConvertToDouble(x[0]), ConvertToInt32(x[1]))),
new ("sign", 1, x => Math.Sign(ConvertToDouble(x[0]))),
new ("sin", 1, x => Math.Sin(ConvertToDouble(x[0]))),
new ("sinh", 1, x => Math.Sinh(ConvertToDouble(x[0]))),
new ("sqrt", 1, x => Math.Sqrt(ConvertToDouble(x[0]))),
new ("tan", 1, x => Math.Tan(ConvertToDouble(x[0]))),
new ("tanh", 1, x => Math.Tanh(ConvertToDouble(x[0]))),
new ("truncate", 1, x => Math.Truncate(ConvertToDouble(x[0]))),
new ("int", 1, x => ConvertToInt32(x[0])),
new ("double", 1, x => ConvertToDouble(x[0])),
new ("bool", 1, x => ConvertToBoolean(x[0])),
new ("str", 1, x => ConvertToString(x[0])),
new ("len", 1, x => ConvertToString(x[0])?.Length),
new ("^", 2, x => Math.Pow(ConvertToDouble(x[0]), ConvertToDouble(x[1]))),
new ("pi", 0, _ => Math.PI),
new ("e", 0, _ => Math.E),
new ("true", 0, _ => true),
Expand Down Expand Up @@ -248,9 +247,15 @@ internal MathExpression(in string expression, in IReadOnlyList<object?> argument
null => false,
double doubleValue => doubleValue != 0 && !double.IsNaN(doubleValue),
string stringValue => !string.IsNullOrEmpty(stringValue),
_ => Convert.ToBoolean(b)
_ => Convert.ToBoolean(b, CultureInfo.InvariantCulture)
};

static double ConvertToDouble(object? x) => Convert.ToDouble(x, CultureInfo.InvariantCulture);

static int ConvertToInt32(object? x) => Convert.ToInt32(x, CultureInfo.InvariantCulture);

static string? ConvertToString(object? x) => Convert.ToString(x, CultureInfo.InvariantCulture);

bool ParsePattern(Regex regex)
{
var whitespaceMatch = EvaluateWhitespace().Match(Expression[ExpressionIndex..]);
Expand Down Expand Up @@ -362,7 +367,7 @@ bool ParsePrimary()
if (ParsePattern(EvaluateNumberPattern()))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be good to turn on the CA1305 analyzer in the project to avoid similar bugs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MartyIX based on your feedback, I applied the CA1305 analyzer and found multiple conversions in MathExpression.shared.cs that needed updates to satisfy the analyzer. After the changes all unit tests for the math converters still passed.

The CA1305 analyzer also picked up new issues which isn't part of the scope of this PR. Would you like me to create split 3 issues to look? (Camera CA1305, SpeechToText CA1305, Badge CA1305)?

F:\Maui\src\CommunityToolkit.Maui.Camera\CameraInfo.shared.cs(99,4): error CA1305: The behavior of 'StringBuilder.Append(ref StringBuilder.AppendInterpolatedStringHandler)' could vary based on the current user's locale settings. Replace this call in 'CameraInfo.ToString()' with a call to 'StringBuilder.Append(IFormatProvider, ref StringBuilder.AppendInterpolatedStringHandler)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
F:\Maui\src\CommunityToolkit.Maui.Camera\CameraInfo.shared.cs(99,4): error CA1305: The behavior of 'StringBuilder.Append(ref StringBuilder.AppendInterpolatedStringHandler)' could vary based on the current user's locale settings. Replace this call in 'CameraInfo.ToString()' with a call to 'StringBuilder.Append(IFormatProvider, ref StringBuilder.AppendInterpolatedStringHandler)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
F:\Maui\src\CommunityToolkit.Maui.Camera\CameraInfo.shared.cs(99,4): error CA1305: The behavior of 'StringBuilder.Append(ref StringBuilder.AppendInterpolatedStringHandler)' could vary based on the current user's locale settings. Replace this call in 'CameraInfo.ToString()' with a call to 'StringBuilder.Append(IFormatProvider, ref StringBuilder.AppendInterpolatedStringHandler)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
F:\Maui\src\CommunityToolkit.Maui.Camera\CameraInfo.shared.cs(99,4): error CA1305: The behavior of 'StringBuilder.Append(ref StringBuilder.AppendInterpolatedStringHandler)' could vary based on the current user's locale settings. Replace this call in 'CameraInfo.ToString()' with a call to 'StringBuilder.Append(IFormatProvider, ref StringBuilder.AppendInterpolatedStringHandler)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
F:\Maui\src\CommunityToolkit.Maui.Camera\CameraInfo.shared.cs(99,4): error CA1305: The behavior of 'StringBuilder.Append(ref StringBuilder.AppendInterpolatedStringHandler)' could vary based on the current user's locale settings. Replace this call in 'CameraInfo.ToString()' with a call to 'StringBuilder.Append(IFormatProvider, ref StringBuilder.AppendInterpolatedStringHandler)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
F:\Maui\src\CommunityToolkit.Maui.Core\Essentials\SpeechToText\OfflineSpeechToTextImplementation.android.cs(188,56): error CA1305: The behavior of 'int.ToString()' could vary based on the current user's locale settings. Replace this call in 'RecognitionSupportCallback.OnError(int)' with a call to 'int.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
F:\Maui\src\CommunityToolkit.Maui.Core\Essentials\Badge\BadgeImplementation.windows.cs(22,40): error CA1305: The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'BadgeImplementation.SetCount(uint)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Build failed with 7 error(s) and 81 warning(s) in 3.8s

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in no position to say what you should do or not, I'm just a random passerby :)) However, I believe it would be a nice new PR or PRs1 to avoid cramming this PR.

Footnotes

  1. If the number of necessary changes is small, then probably just one PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MartyIX, as per your feedback, I have created a new split issue #2744 to avoid cramming this PR.

Copy link
Collaborator

@VladislavAntonyuk VladislavAntonyuk Jul 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can change the pattern to something like this:

@"^-?\d+[.,]?\d*$"

It should match:

  • 123
  • -123.45
  • -123,45

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @VladislavAntonyuk, sorry it's been awhile since I've replied to your comment. The struggle I have with your suggestion is that we're trying to make the expressions follow InvariantCulture so that the expressions are deterministic and will not be influence by language changes. The other reason is that the comma (,) is already part of the expression for delimiting functional arguments (e.g. atan2).

Copy link
Contributor Author

@stephenquan stephenquan Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @VladislavAntonyuk, I watched this month's standup and noted two key discussions:

  • Whether developers can pass CultureInfo as a parameter as a new parameter to the MathExpressionConvertre/MultiMathExpressionConverter
  • Whether the CultureInfo parameter passed to IValueConverter/IMultiValueConverter convert methods can be used (which, from my limited understanding/testing, seems to follow whatever CultureInfo.CurrentUICulture is set to)

Just to reiterate my application's use case: the application supports runtime switching between 30+ languages, which updates CultureInfo.CurrentCulture and CultureInfo.CurrentUICulture. However, our UI uses MultiMathExpressionConverter for some spinner logic (e.g., x0 ? -90 : 0) which needs to be language agnostic. In Arabic, parsing the negative sign causes a crash. The crash can be understood because of Arabic not recognizing the minus sign.

CultureInfo.CurrentCulture = new CultureInfo("ar-AR");
var value = double.Parse("-90"); // Throws System.FormatException: 'The input string '-90' was not in a correct format.'

In our application, we have worked around this problem by coming up with alternates ways to specify -90.

{
string _number = PatternMatch.Groups[1].Value;
RPN.Add(new MathToken(MathTokenType.Value, _number, double.Parse(_number)));
RPN.Add(new MathToken(MathTokenType.Value, _number, double.Parse(_number, CultureInfo.InvariantCulture)));
return true;
}

Expand Down
Loading