using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Windows.Controls; using Flow.Launcher.Plugin.Calculator.ViewModels; using Flow.Launcher.Plugin.Calculator.Views; using Mages.Core; namespace Flow.Launcher.Plugin.Calculator { public class Main : IPlugin, IPluginI18n, ISettingProvider { private static readonly Regex ThousandGroupRegex = MainRegexHelper.GetThousandGroupRegex(); private static readonly Regex NumberRegex = MainRegexHelper.GetNumberRegex(); private static readonly Regex PowRegex = MainRegexHelper.GetPowRegex(); private static readonly Regex LogRegex = MainRegexHelper.GetLogRegex(); private static readonly Regex LnRegex = MainRegexHelper.GetLnRegex(); private static readonly Regex FunctionRegex = MainRegexHelper.GetFunctionRegex(); private static Engine MagesEngine; private const string Comma = ","; private const string Dot = "."; private const string IcoPath = "Images/calculator.png"; private static readonly List EmptyResults = []; internal static PluginInitContext Context { get; private set; } = null!; private Settings _settings; private SettingsViewModel _viewModel; public void Init(PluginInitContext context) { Context = context; _settings = context.API.LoadSettingJsonStorage(); _viewModel = new SettingsViewModel(_settings); MagesEngine = new Engine(new Configuration { Scope = new Dictionary { { "e", Math.E }, // e is not contained in the default mages engine } }); } public List Query(Query query) { if (string.IsNullOrWhiteSpace(query.Search)) { return EmptyResults; } try { var search = query.Search; bool isFunctionPresent = FunctionRegex.IsMatch(search); // Mages is case sensitive, so we need to convert all function names to lower case. search = FunctionRegex.Replace(search, m => m.Value.ToLowerInvariant()); var decimalSep = GetDecimalSeparator(); var groupSep = GetGroupSeparator(decimalSep); var expression = NumberRegex.Replace(search, m => NormalizeNumber(m.Value, isFunctionPresent, decimalSep, groupSep)); // WORKAROUND START: The 'pow' function in Mages v3.0.0 is broken. // https://github.com/FlorianRappl/Mages/issues/132 // We bypass it by rewriting any pow(x,y) expression to the equivalent (x^y) expression // before the engine sees it. This loop handles nested calls. { string previous; do { previous = expression; expression = PowRegex.Replace(previous, PowMatchEvaluator); } while (previous != expression); } // WORKAROUND END // WORKAROUND START: The 'log' & 'ln' function in Mages v3.0.0 are broken. // https://github.com/FlorianRappl/Mages/issues/137 // We bypass it by rewriting any log & ln expression to the equivalent (log10 & log) expression // before the engine sees it. This loop handles nested calls. { string previous; do { previous = expression; expression = LogRegex.Replace(previous, LogMatchEvaluator); } while (previous != expression); } { string previous; do { previous = expression; expression = LnRegex.Replace(previous, LnMatchEvaluator); } while (previous != expression); } // WORKAROUND END var result = MagesEngine.Interpret(expression); if (result == null || string.IsNullOrEmpty(result.ToString())) { if (!_settings.ShowErrorMessage) return EmptyResults; return [ new Result { Title = Localize.flowlauncher_plugin_calculator_expression_not_complete(), IcoPath = IcoPath } ]; } if (result.ToString() == "NaN") { result = Localize.flowlauncher_plugin_calculator_not_a_number(); } if (result is Function) { result = Localize.flowlauncher_plugin_calculator_expression_not_complete(); } if (!string.IsNullOrEmpty(result.ToString())) { decimal roundedResult = Math.Round(Convert.ToDecimal(result), _settings.MaxDecimalPlaces, MidpointRounding.AwayFromZero); string newResult = FormatResult(roundedResult); return [ new Result { Title = newResult, IcoPath = IcoPath, Score = 300, // Check context nullability for unit testing SubTitle = Context == null ? string.Empty : Localize.flowlauncher_plugin_calculator_copy_number_to_clipboard(), CopyText = newResult, Action = c => { try { Context.API.CopyToClipboard(newResult); return true; } catch (ExternalException) { Context.API.ShowMsgBox(Localize.flowlauncher_plugin_calculator_failed_to_copy()); return false; } } } ]; } } catch (Exception) { // Mages engine can throw various exceptions, for simplicity we catch them all and show a generic message. if (!_settings.ShowErrorMessage) return EmptyResults; return [ new Result { Title = Localize.flowlauncher_plugin_calculator_expression_not_complete(), IcoPath = IcoPath } ]; } return EmptyResults; } private static string PowMatchEvaluator(Match m) { // m.Groups[1].Value will be `(...)` with parens var contentWithParen = m.Groups[1].Value; // remove outer parens. `(min(2,3), 4)` becomes `min(2,3), 4` var argsContent = contentWithParen[1..^1]; var bracketCount = 0; var splitIndex = -1; // Find the top-level comma that separates the two arguments of pow. for (var i = 0; i < argsContent.Length; i++) { switch (argsContent[i]) { case '(': case '[': bracketCount++; break; case ')': case ']': bracketCount--; break; case ',' when bracketCount == 0: splitIndex = i; break; } if (splitIndex != -1) break; } if (splitIndex == -1) { // This indicates malformed arguments for pow, e.g., pow(5) or pow(). // Return original string to let Mages handle the error. return m.Value; } var arg1 = argsContent[..splitIndex].Trim(); var arg2 = argsContent[(splitIndex + 1)..].Trim(); // Check for empty arguments which can happen with stray commas, e.g., pow(,5) if (string.IsNullOrEmpty(arg1) || string.IsNullOrEmpty(arg2)) { return m.Value; } return $"({arg1}^{arg2})"; } private static string LogMatchEvaluator(Match m) { // m.Groups[1].Value will be `(...)` with parens var contentWithParen = m.Groups[1].Value; var argsContent = contentWithParen[1..^1]; // log is unary — if malformed, return original to let Mages handle it var arg = argsContent.Trim(); if (string.IsNullOrEmpty(arg)) return m.Value; // log(x) -> log10(x) (natural log) return $"(log10({arg}))"; } private static string LnMatchEvaluator(Match m) { // m.Groups[1].Value will be `(...)` with parens var contentWithParen = m.Groups[1].Value; var argsContent = contentWithParen[1..^1]; // ln is unary — if malformed, return original to let Mages handle it var arg = argsContent.Trim(); if (string.IsNullOrEmpty(arg)) return m.Value; // ln(x) -> log(x) (natural log) return $"(log({arg}))"; } private static string NormalizeNumber(string numberStr, bool isFunctionPresent, string decimalSep, string groupSep) { if (isFunctionPresent) { // STRICT MODE: When functions are present, ',' is ALWAYS an argument separator. if (numberStr.Contains(',')) { return numberStr; } string processedStr = numberStr; // Handle group separator, with special care for ambiguous dot. if (!string.IsNullOrEmpty(groupSep)) { if (groupSep == ".") { var parts = processedStr.Split('.'); if (parts.Length > 1) { var culture = CultureInfo.CurrentCulture; if (IsValidGrouping(parts, culture.NumberFormat.NumberGroupSizes)) { processedStr = processedStr.Replace(groupSep, ""); } // If not grouped, it's likely a decimal number, so we don't strip dots. } } else { processedStr = processedStr.Replace(groupSep, ""); } } // Handle decimal separator. if (decimalSep != ".") { processedStr = processedStr.Replace(decimalSep, "."); } return processedStr; } else { // LENIENT MODE: No functions are present, so we can be flexible. string processedStr = numberStr; if (!string.IsNullOrEmpty(groupSep)) { processedStr = processedStr.Replace(groupSep, ""); } if (decimalSep != ".") { processedStr = processedStr.Replace(decimalSep, "."); } return processedStr; } } private static bool IsValidGrouping(string[] parts, int[] groupSizes) { if (parts.Length <= 1) return true; if (groupSizes is null || groupSizes.Length == 0 || groupSizes[0] == 0) return false; // has groups, but culture defines none. var firstPart = parts[0]; if (firstPart.StartsWith('-')) firstPart = firstPart[1..]; if (firstPart.Length == 0) return false; // e.g. ",123" if (firstPart.Length > groupSizes[0]) return false; var lastGroupSize = groupSizes.Last(); var canRepeatLastGroup = lastGroupSize != 0; int groupIndex = 0; for (int i = parts.Length - 1; i > 0; i--) { int expectedSize; if (groupIndex < groupSizes.Length) { expectedSize = groupSizes[groupIndex]; } else if (canRepeatLastGroup) { expectedSize = lastGroupSize; } else { return false; } if (parts[i].Length != expectedSize) return false; groupIndex++; } return true; } private string FormatResult(decimal roundedResult) { string decimalSeparator = GetDecimalSeparator(); string groupSeparator = GetGroupSeparator(decimalSeparator); string resultStr = roundedResult.ToString(CultureInfo.InvariantCulture); string[] parts = resultStr.Split('.'); string integerPart = parts[0]; string fractionalPart = parts.Length > 1 ? parts[1] : string.Empty; if (_settings.UseThousandsSeparator && integerPart.Length > 3) { integerPart = ThousandGroupRegex.Replace(integerPart, groupSeparator); } if (!string.IsNullOrEmpty(fractionalPart)) { return integerPart + decimalSeparator + fractionalPart; } return integerPart; } private string GetGroupSeparator(string decimalSeparator) { var culture = CultureInfo.CurrentCulture; var systemGroupSeparator = culture.NumberFormat.NumberGroupSeparator; if (_settings.DecimalSeparator == DecimalSeparator.UseSystemLocale) { return systemGroupSeparator; } // When a custom decimal separator is used, // use the system's group separator unless it conflicts with the custom decimal separator. if (decimalSeparator == systemGroupSeparator) { // Conflict: use the opposite of the decimal separator as a fallback. return decimalSeparator == Dot ? Comma : Dot; } return systemGroupSeparator; } private string GetDecimalSeparator() { string systemDecimalSeparator = CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator; return _settings.DecimalSeparator switch { DecimalSeparator.UseSystemLocale => systemDecimalSeparator, DecimalSeparator.Dot => Dot, DecimalSeparator.Comma => Comma, _ => systemDecimalSeparator, }; } public string GetTranslatedPluginTitle() { return Localize.flowlauncher_plugin_calculator_plugin_name(); } public string GetTranslatedPluginDescription() { return Localize.flowlauncher_plugin_calculator_plugin_description(); } public Control CreateSettingPanel() { return new CalculatorSettings(_settings); } public void OnCultureInfoChanged(CultureInfo newCulture) { DecimalSeparatorLocalized.UpdateLabels(_viewModel.AllDecimalSeparator); } } }