FormulaParser.cs 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. using System.Text.RegularExpressions;
  2. namespace Admin.NET.Plugin.AiDOP.Infrastructure.FormulaExpr;
  3. /// <summary>
  4. /// 第三批公式结构化 — 最小 DSL v1 解析器。
  5. ///
  6. /// 语法:
  7. /// FormulaExpr ::= Token (Operator Token)*
  8. /// Token ::= MetricRef | FactRef | Number | '(' FormulaExpr ')'
  9. /// MetricRef ::= '@' MetricCode
  10. /// FactRef ::= '$' FactCode
  11. /// Operator ::= '+' | '-' | '*' | '/'
  12. /// Number ::= 数字(支持小数、百分号)
  13. ///
  14. /// 仅做语法 / 引用抽取,不做执行求值。
  15. /// </summary>
  16. public static class FormulaParser
  17. {
  18. // MetricCode 形如 S1_L2_003 / S4_L1_001,允许字母数字下划线
  19. private static readonly Regex MetricRefRe = new(@"@([A-Za-z][A-Za-z0-9_]*)", RegexOptions.Compiled);
  20. // FactCode 形如 F_S1_001 / F_COMMON_001
  21. private static readonly Regex FactRefRe = new(@"\$([A-Za-z][A-Za-z0-9_]*)", RegexOptions.Compiled);
  22. public sealed class ParseResult
  23. {
  24. public List<string> MetricRefs { get; } = new();
  25. public List<string> FactRefs { get; } = new();
  26. public List<string> Errors { get; } = new();
  27. public bool IsEmpty { get; set; }
  28. }
  29. public static ParseResult Parse(string? expr)
  30. {
  31. var result = new ParseResult();
  32. if (string.IsNullOrWhiteSpace(expr))
  33. {
  34. result.IsEmpty = true;
  35. return result;
  36. }
  37. var text = expr.Trim();
  38. // 1) 抽取 @ / $ 引用
  39. foreach (Match m in MetricRefRe.Matches(text))
  40. if (!result.MetricRefs.Contains(m.Groups[1].Value))
  41. result.MetricRefs.Add(m.Groups[1].Value);
  42. foreach (Match m in FactRefRe.Matches(text))
  43. if (!result.FactRefs.Contains(m.Groups[1].Value))
  44. result.FactRefs.Add(m.Groups[1].Value);
  45. // 2) 括号配对
  46. var stack = 0;
  47. foreach (var ch in text)
  48. {
  49. if (ch == '(') stack++;
  50. else if (ch == ')')
  51. {
  52. stack--;
  53. if (stack < 0) { result.Errors.Add("括号不匹配:右括号多余"); break; }
  54. }
  55. }
  56. if (stack > 0) result.Errors.Add("括号不匹配:左括号多余");
  57. // 3) 语法白名单:去掉 @xxx / $xxx 及合法符号后,不应剩下其它字符
  58. var residue = MetricRefRe.Replace(text, "");
  59. residue = FactRefRe.Replace(residue, "");
  60. // 允许:数字、小数点、百分号、算符 + - * / ( ) 、空白、中文"天"(兼容 (D2/D1)*30天)
  61. // 纯 ASCII 校验:保留 + 剔除后若出现其它字符标记为警告(不作为硬错)
  62. var allowedRe = new Regex(@"^[\s\d\.\+\-\*/\(\)%]*$", RegexOptions.Compiled);
  63. if (!allowedRe.IsMatch(residue))
  64. {
  65. // 找出不合法字符样本(最多 20 字)
  66. var bad = new string(residue.Where(c => !char.IsWhiteSpace(c)
  67. && !char.IsDigit(c) && "+-*/().%".IndexOf(c) < 0).Distinct().Take(20).ToArray());
  68. if (bad.Length > 0)
  69. result.Errors.Add($"公式包含未识别字符:'{bad}'。仅支持 @指标 $事实 数字及 + - * / ( ) % 算符");
  70. }
  71. // 4) 至少含一个 token
  72. if (result.MetricRefs.Count == 0 && result.FactRefs.Count == 0
  73. && !Regex.IsMatch(text, @"\d"))
  74. {
  75. result.Errors.Add("公式至少应包含一个引用(@指标 / $事实)或数字");
  76. }
  77. return result;
  78. }
  79. }