SonarQube Analysis / sonarqube (pull_request) Successful in 3m9s
Introduce a default regex match timeout across the library to prevent potential ReDoS attacks (SonarQube rule S6444). Implement `[OnDeserialized]` methods to re-establish object invariants and validate state after deserialization, addressing SonarQube rule S5766.
405 lines
12 KiB
C#
405 lines
12 KiB
C#
using System.Text.RegularExpressions;
|
|
|
|
namespace Strata.SqlTools.Rules.Rule.Expression;
|
|
|
|
/// <summary>
|
|
/// Parses markdown/LaTeX mathematical expressions and converts them to Expression objects.
|
|
/// Supports parsing of logical operations, comparisons, properties, and literals.
|
|
/// </summary>
|
|
public static class Markdown
|
|
{
|
|
/// <summary>
|
|
/// Match timeout applied to all regular expressions to guard against catastrophic
|
|
/// backtracking / ReDoS denial-of-service attacks (SonarQube rule S6444).
|
|
/// </summary>
|
|
private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1);
|
|
|
|
private static readonly Dictionary<string, Func<Expression, Expression, BoolExpr>> LogicalOperators = new()
|
|
{
|
|
{ "\\land", (left, right) => new And((BoolExpr)left, (BoolExpr)right) },
|
|
{ "\\lor", (left, right) => new Or((BoolExpr)left, (BoolExpr)right) },
|
|
{ "\\wedge", (left, right) => new And((BoolExpr)left, (BoolExpr)right) },
|
|
{ "\\vee", (left, right) => new Or((BoolExpr)left, (BoolExpr)right) },
|
|
{ "AND", (left, right) => new And((BoolExpr)left, (BoolExpr)right) },
|
|
{ "OR", (left, right) => new Or((BoolExpr)left, (BoolExpr)right) },
|
|
};
|
|
|
|
private static readonly Dictionary<string, Func<Expression, Expression, Comparison>> ComparisonOperators = new()
|
|
{
|
|
{ "=", (left, right) => new Equal(left, right) },
|
|
{ "\\neq", (left, right) => new NotEqual(left, right) },
|
|
{ "!=", (left, right) => new NotEqual(left, right) },
|
|
{ ">", (left, right) => new GreaterThan(left, right) },
|
|
{ "\\gt", (left, right) => new GreaterThan(left, right) },
|
|
};
|
|
|
|
/// <summary>
|
|
/// Parses a markdown/LaTeX string into an Expression object.
|
|
/// </summary>
|
|
/// <param name="markdown">The markdown/LaTeX string to parse.</param>
|
|
/// <returns>The parsed Expression object.</returns>
|
|
/// <exception cref="ArgumentException">Thrown when the markdown cannot be parsed.</exception>
|
|
public static Expression Parse(string markdown)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(markdown))
|
|
{
|
|
throw new ArgumentException("Markdown cannot be null or empty", nameof(markdown));
|
|
}
|
|
|
|
// Remove common markdown delimiters
|
|
markdown = markdown.Trim();
|
|
markdown = StripMarkdownDelimiters(markdown);
|
|
|
|
return ParseExpression(markdown);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to parse a markdown/LaTeX string into an Expression object.
|
|
/// </summary>
|
|
/// <param name="markdown">The markdown/LaTeX string to parse.</param>
|
|
/// <param name="expression">The parsed Expression object if successful.</param>
|
|
/// <returns>True if parsing was successful, false otherwise.</returns>
|
|
public static bool TryParse(string markdown, out Expression? expression)
|
|
{
|
|
try
|
|
{
|
|
expression = Parse(markdown);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
expression = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string StripMarkdownDelimiters(string text)
|
|
{
|
|
// Remove $...$ or $$...$$ delimiters
|
|
text = Regex.Replace(text, @"^\$\$?\s*", "", RegexOptions.None, RegexTimeout);
|
|
text = Regex.Replace(text, @"\s*\$\$?$", "", RegexOptions.None, RegexTimeout);
|
|
|
|
// Remove ```math...``` code fence
|
|
text = Regex.Replace(text, @"^```math\s*", "", RegexOptions.Multiline, RegexTimeout);
|
|
text = Regex.Replace(text, @"\s*```$", "", RegexOptions.Multiline, RegexTimeout);
|
|
|
|
return text.Trim();
|
|
}
|
|
|
|
private static Expression ParseExpression(string text)
|
|
{
|
|
text = text.Trim();
|
|
|
|
// Try to parse logical operations (lowest precedence)
|
|
var logicalExpr = TryParseLogicalOperation(text);
|
|
if (logicalExpr is not null)
|
|
{
|
|
return logicalExpr;
|
|
}
|
|
|
|
// Try to parse comparison operations
|
|
var comparisonExpr = TryParseComparison(text);
|
|
if (comparisonExpr is not null)
|
|
{
|
|
return comparisonExpr;
|
|
}
|
|
|
|
// Handle parentheses
|
|
var parenthesisExpr = TryParseParentheses(text);
|
|
if (parenthesisExpr is not null)
|
|
{
|
|
return parenthesisExpr;
|
|
}
|
|
|
|
// Parse property, literal, or other atomic expressions
|
|
return ParseAtomicExpression(text);
|
|
}
|
|
|
|
private static Expression? TryParseLogicalOperation(string text)
|
|
{
|
|
foreach (var op in LogicalOperators.Keys)
|
|
{
|
|
var parts = SplitByOperator(text, op);
|
|
if (parts.Length == 2)
|
|
{
|
|
var left = ParseExpression(parts[0]);
|
|
var right = ParseExpression(parts[1]);
|
|
return LogicalOperators[op](left, right);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static Expression? TryParseComparison(string text)
|
|
{
|
|
foreach (var op in ComparisonOperators.Keys)
|
|
{
|
|
var parts = SplitByOperator(text, op);
|
|
if (parts.Length == 2)
|
|
{
|
|
var left = ParseExpression(parts[0]);
|
|
var right = ParseExpression(parts[1]);
|
|
return ComparisonOperators[op](left, right);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static Expression? TryParseParentheses(string text)
|
|
{
|
|
// Handle regular parentheses
|
|
if (text.StartsWith('(') && text.EndsWith(')'))
|
|
{
|
|
var inner = text.Substring(1, text.Length - 2);
|
|
if (IsBalanced(inner))
|
|
{
|
|
return ParseExpression(inner);
|
|
}
|
|
}
|
|
|
|
// Handle LaTeX \left( and \right)
|
|
if (text.StartsWith("\\left(") && text.EndsWith("\\right)"))
|
|
{
|
|
var inner = text.Substring(6, text.Length - 13);
|
|
if (IsBalanced(inner))
|
|
{
|
|
return ParseExpression(inner);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static Expression ParseAtomicExpression(string text)
|
|
{
|
|
// Try parsing as property access
|
|
var propertyExpr = TryParseProperty(text);
|
|
if (propertyExpr is not null)
|
|
{
|
|
return propertyExpr;
|
|
}
|
|
|
|
// Try parsing as literal
|
|
var literalExpr = TryParseLiteral(text);
|
|
if (literalExpr is not null)
|
|
{
|
|
return literalExpr;
|
|
}
|
|
|
|
throw new ArgumentException($"Unable to parse expression: {text}");
|
|
}
|
|
|
|
private static Expression? TryParseProperty(string text)
|
|
{
|
|
// Parse property access (e.g., x.PropertyName or \text{x.PropertyName})
|
|
var propertyMatch = Regex.Match(text, @"^([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)$", RegexOptions.None, RegexTimeout);
|
|
if (propertyMatch.Success)
|
|
{
|
|
return new Property(propertyMatch.Groups[1].Value, propertyMatch.Groups[2].Value);
|
|
}
|
|
|
|
// Parse \text{...} property access
|
|
var textMatch = Regex.Match(text, @"^\\text\{([^}]+)\}$", RegexOptions.None, RegexTimeout);
|
|
if (textMatch.Success)
|
|
{
|
|
var textContent = textMatch.Groups[1].Value;
|
|
var propMatch = Regex.Match(textContent, @"^([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)$", RegexOptions.None, RegexTimeout);
|
|
if (propMatch.Success)
|
|
{
|
|
return new Property(propMatch.Groups[1].Value, propMatch.Groups[2].Value);
|
|
}
|
|
|
|
// Check for boolean literals in \text{} format
|
|
if (textContent.Equals("true", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new Literal(true);
|
|
}
|
|
|
|
if (textContent.Equals("false", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new Literal(false);
|
|
}
|
|
|
|
// Single property name
|
|
if (Regex.IsMatch(textContent, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.None, RegexTimeout))
|
|
{
|
|
return new Property(textContent);
|
|
}
|
|
|
|
// String literal
|
|
return new StringLiteral(textContent);
|
|
}
|
|
|
|
// Check for boolean literals before simple property
|
|
if (text.Equals("true", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new Literal(true);
|
|
}
|
|
|
|
if (text.Equals("false", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return new Literal(false);
|
|
}
|
|
|
|
// Parse simple property without parameter
|
|
if (Regex.IsMatch(text, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.None, RegexTimeout))
|
|
{
|
|
return new Property(text);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static Expression? TryParseLiteral(string text)
|
|
{
|
|
// Parse string literals (quoted)
|
|
var stringMatch = Regex.Match(text, @"^[""'](.+?)[""']$", RegexOptions.None, RegexTimeout);
|
|
if (stringMatch.Success)
|
|
{
|
|
return new StringLiteral(stringMatch.Groups[1].Value);
|
|
}
|
|
|
|
// Parse empty string literals
|
|
if (text == "\"\"" || text == "''")
|
|
{
|
|
return new StringLiteral(string.Empty);
|
|
}
|
|
|
|
// Parse numeric literals
|
|
if (int.TryParse(text, out var intValue))
|
|
{
|
|
return new NumberLiteral(intValue);
|
|
}
|
|
|
|
if (decimal.TryParse(text, out var decimalValue))
|
|
{
|
|
return new NumberLiteral(decimalValue);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string[] SplitByOperator(string text, string op)
|
|
{
|
|
var result = new List<string>();
|
|
int depth = 0;
|
|
int lastIndex = 0;
|
|
int i = 0;
|
|
|
|
while (i < text.Length)
|
|
{
|
|
i = ProcessParentheses(text, i, ref depth);
|
|
if (i >= text.Length)
|
|
{
|
|
break;
|
|
}
|
|
|
|
// Check if we found the operator at depth 0
|
|
if (depth == 0 && i + op.Length <= text.Length && TryMatchOperator(text, i, op))
|
|
{
|
|
result.Add(text.Substring(lastIndex, i - lastIndex).Trim());
|
|
lastIndex = i + op.Length;
|
|
i += op.Length;
|
|
continue;
|
|
}
|
|
|
|
i++;
|
|
}
|
|
|
|
if (result.Count == 0)
|
|
{
|
|
return new[] { text };
|
|
}
|
|
|
|
result.Add(text.Substring(lastIndex).Trim());
|
|
return result.ToArray();
|
|
}
|
|
|
|
private static int ProcessParentheses(string text, int index, ref int depth)
|
|
{
|
|
// Track parentheses depth
|
|
if (text[index] == '(' || (index + 5 < text.Length && text.Substring(index, 6) == "\\left("))
|
|
{
|
|
depth++;
|
|
if (text[index] == '\\')
|
|
{
|
|
return index + 6;
|
|
}
|
|
else
|
|
{
|
|
return index + 1;
|
|
}
|
|
}
|
|
|
|
if (text[index] == ')' || (index + 6 < text.Length && text.Substring(index, 7) == "\\right)"))
|
|
{
|
|
depth--;
|
|
if (text[index] == '\\')
|
|
{
|
|
return index + 7;
|
|
}
|
|
else
|
|
{
|
|
return index + 1;
|
|
}
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
private static bool TryMatchOperator(string text, int index, string op)
|
|
{
|
|
var substring = text.Substring(index, op.Length);
|
|
if (substring != op)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Make sure it's a separate operator, not part of a larger token
|
|
bool validBefore = (index == 0 || char.IsWhiteSpace(text[index - 1]) || text[index] == '\\');
|
|
bool validAfter = (index + op.Length >= text.Length || char.IsWhiteSpace(text[index + op.Length]));
|
|
|
|
return validBefore && validAfter;
|
|
}
|
|
|
|
private static bool IsBalanced(string text)
|
|
{
|
|
int depth = 0;
|
|
int i = 0;
|
|
|
|
while (i < text.Length)
|
|
{
|
|
if (text[i] == '(')
|
|
{
|
|
depth++;
|
|
}
|
|
else if (text[i] == ')')
|
|
{
|
|
depth--;
|
|
if (depth < 0)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
else if (i + 5 < text.Length && text.Substring(i, 6) == "\\left(")
|
|
{
|
|
depth++;
|
|
i += 5;
|
|
}
|
|
else if (i + 6 < text.Length && text.Substring(i, 7) == "\\right)")
|
|
{
|
|
depth--;
|
|
if (depth < 0)
|
|
{
|
|
return false;
|
|
}
|
|
i += 6;
|
|
}
|
|
|
|
i++;
|
|
}
|
|
|
|
return depth == 0;
|
|
}
|
|
}
|