using System.Globalization;
using Nuuru.Server.Models.Booru;
using Nuuru.Server.Services.Search.Nodes;
using Nuuru.Server.Services.Search.Tokens;
namespace Nuuru.Server.Services.Search;
///
/// Parses a list of search tokens into an AST.
///
public class SearchParser
{
private readonly List _tokens;
private int _pos;
private readonly List _errors = new();
private readonly List _warnings = new();
public SearchParser(List tokens)
{
_tokens = tokens;
_pos = 0;
}
///
/// Parses the tokens into a search query AST.
///
public SearchParseResult Parse()
{
var filterNodes = new List();
OrderByNode? orderBy = null;
while (!AtEnd)
{
var token = Current;
Advance();
switch (token)
{
case TagToken t:
filterNodes.Add(new TagNode(t.Name));
break;
case NegatedTagToken t:
filterNodes.Add(new TagNode(t.Name, negated: true));
break;
case WildcardTagToken t:
filterNodes.Add(new WildcardTagNode(t.Prefix, t.Negated));
break;
case OrGroupStartToken:
var orNode = ParseOrGroup();
if (orNode != null)
filterNodes.Add(orNode);
break;
case OrGroupEndToken:
_warnings.Add("Unexpected '}' outside of OR group");
break;
case OrSeparatorToken:
_warnings.Add("Unexpected '~' outside of OR group");
break;
case MetaTagToken m:
var metaNode = ParseMetaTag(m);
if (metaNode is OrderByNode ob)
orderBy = ob;
else if (metaNode != null)
filterNodes.Add(metaNode);
break;
}
}
// Build root node
SearchNode? rootNode = filterNodes.Count switch
{
0 => null,
1 => filterNodes[0],
_ => new AndNode(filterNodes)
};
return new SearchParseResult(rootNode, orderBy, _errors, _warnings);
}
private OrNode? ParseOrGroup()
{
var alternatives = new List();
var currentTerms = new List();
while (!AtEnd)
{
var token = Current;
if (token is OrGroupEndToken)
{
Advance();
break;
}
if (token is OrSeparatorToken)
{
Advance();
// Flush current terms as one alternative
if (currentTerms.Count > 0)
{
alternatives.Add(currentTerms.Count == 1 ? currentTerms[0] : new AndNode(currentTerms));
currentTerms = new List();
}
continue;
}
Advance();
switch (token)
{
case TagToken t:
currentTerms.Add(new TagNode(t.Name));
break;
case NegatedTagToken t:
currentTerms.Add(new TagNode(t.Name, negated: true));
break;
case WildcardTagToken t:
currentTerms.Add(new WildcardTagNode(t.Prefix, t.Negated));
break;
case MetaTagToken m:
var node = ParseMetaTag(m);
if (node != null && node is not OrderByNode)
currentTerms.Add(node);
break;
}
}
// Flush remaining terms
if (currentTerms.Count > 0)
{
alternatives.Add(currentTerms.Count == 1 ? currentTerms[0] : new AndNode(currentTerms));
}
if (alternatives.Count == 0)
{
_warnings.Add("Empty OR group");
return null;
}
return new OrNode(alternatives);
}
private SearchNode? ParseMetaTag(MetaTagToken token)
{
return token.Key switch
{
"rating" or "r" => ParseRating(token),
"category" or "cat" or "c" => ParseCategory(token),
"uploader" or "user" => ParseUploader(token),
"id" => ParseNumericFilter("id", token),
"score" or "s" => ParseNumericFilter("score", token),
"width" or "w" => ParseNumericFilter("width", token),
"height" or "h" => ParseNumericFilter("height", token),
"filesize" or "size" => ParseFileSizeFilter(token),
"tagcount" or "tags" or "tag" => ParseNumericFilter("tagcount", token),
"date" or "uploaded" => ParseDateFilter(token),
"filetype" or "type" => ParseFileType(token),
"status" or "is" => ParseStatus(token),
"order" or "sort" => ParseOrderBy(token.Value),
_ => HandleUnknownMetaTag(token)
};
}
private SearchNode? ParseStatus(MetaTagToken token)
{
return new StatusFilterNode(token.Value) { Negated = token.Negated };
}
private SearchNode? ParseRating(MetaTagToken token)
{
var value = token.Value.ToLowerInvariant();
PostRating? rating = value switch
{
"s" or "safe" => PostRating.Safe,
"q" or "questionable" => PostRating.Questionable,
"e" or "explicit" => PostRating.Explicit,
_ => null
};
if (rating == null)
{
_warnings.Add($"Unknown rating '{token.Value}'. Valid values: safe, questionable, explicit");
return null;
}
return new RatingFilterNode(rating.Value) { Negated = token.Negated };
}
private SearchNode? ParseCategory(MetaTagToken token)
{
var value = token.Value.ToLowerInvariant();
PostCategory? category = value switch
{
"g" or "gallery" => PostCategory.Gallery,
"a" or "artwork" or "artworks" => PostCategory.Artworks,
_ => null
};
if (category == null)
{
_warnings.Add($"Unknown category '{token.Value}'. Valid values: gallery, artworks");
return null;
}
return new CategoryFilterNode(category.Value) { Negated = token.Negated };
}
private SearchNode ParseUploader(MetaTagToken token)
{
return new UploaderFilterNode(token.Value) { Negated = token.Negated };
}
private SearchNode? ParseNumericFilter(string field, MetaTagToken token)
{
long? min = null;
long? max = null;
switch (token.Operator)
{
case MetaOperator.Equals:
if (long.TryParse(token.Value, out var exact))
{
min = exact;
max = exact;
}
else
{
_warnings.Add($"Invalid numeric value '{token.Value}' for {field}");
return null;
}
break;
case MetaOperator.GreaterThan:
if (long.TryParse(token.Value, out var gt))
min = gt + 1;
else
{
_warnings.Add($"Invalid numeric value '{token.Value}' for {field}");
return null;
}
break;
case MetaOperator.GreaterThanOrEqual:
if (long.TryParse(token.Value, out var gte))
min = gte;
else
{
_warnings.Add($"Invalid numeric value '{token.Value}' for {field}");
return null;
}
break;
case MetaOperator.LessThan:
if (long.TryParse(token.Value, out var lt))
max = lt - 1;
else
{
_warnings.Add($"Invalid numeric value '{token.Value}' for {field}");
return null;
}
break;
case MetaOperator.LessThanOrEqual:
if (long.TryParse(token.Value, out var lte))
max = lte;
else
{
_warnings.Add($"Invalid numeric value '{token.Value}' for {field}");
return null;
}
break;
case MetaOperator.Range:
var parts = token.Value.Split("..");
if (parts.Length == 2)
{
if (!string.IsNullOrEmpty(parts[0]) && long.TryParse(parts[0], out var rangeMin))
min = rangeMin;
if (!string.IsNullOrEmpty(parts[1]) && long.TryParse(parts[1], out var rangeMax))
max = rangeMax;
}
if (min == null && max == null)
{
_warnings.Add($"Invalid range '{token.Value}' for {field}");
return null;
}
break;
}
return new NumericRangeFilterNode(field, min, max) { Negated = token.Negated };
}
private SearchNode? ParseFileSizeFilter(MetaTagToken token)
{
// Parse file size with optional suffixes: kb, mb, gb
long? bytes = ParseFileSize(token.Value, token.Operator);
if (bytes == null)
{
_warnings.Add($"Invalid file size '{token.Value}'");
return null;
}
long? min = null;
long? max = null;
switch (token.Operator)
{
case MetaOperator.Equals:
min = bytes;
max = bytes;
break;
case MetaOperator.GreaterThan:
min = bytes + 1;
break;
case MetaOperator.GreaterThanOrEqual:
min = bytes;
break;
case MetaOperator.LessThan:
max = bytes - 1;
break;
case MetaOperator.LessThanOrEqual:
max = bytes;
break;
case MetaOperator.Range:
var parts = token.Value.Split("..");
if (parts.Length == 2)
{
min = ParseFileSizeValue(parts[0]);
max = ParseFileSizeValue(parts[1]);
}
break;
}
return new NumericRangeFilterNode("filesize", min, max) { Negated = token.Negated };
}
private long? ParseFileSize(string value, MetaOperator op)
{
if (op == MetaOperator.Range)
return 0; // Handle in the switch case above
return ParseFileSizeValue(value);
}
private long? ParseFileSizeValue(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
value = value.ToLowerInvariant().Trim();
long multiplier = 1;
string numPart = value;
if (value.EndsWith("gb"))
{
multiplier = 1024L * 1024L * 1024L;
numPart = value[..^2];
}
else if (value.EndsWith("mb"))
{
multiplier = 1024L * 1024L;
numPart = value[..^2];
}
else if (value.EndsWith("kb"))
{
multiplier = 1024L;
numPart = value[..^2];
}
else if (value.EndsWith("b"))
{
numPart = value[..^1];
}
if (double.TryParse(numPart, NumberStyles.Float, CultureInfo.InvariantCulture, out var num))
{
return (long)(num * multiplier);
}
return null;
}
private SearchNode? ParseDateFilter(MetaTagToken token)
{
DateTime? min = null;
DateTime? max = null;
switch (token.Operator)
{
case MetaOperator.Equals:
if (TryParseDate(token.Value, out var exact))
{
min = exact.Date;
max = exact.Date.AddDays(1).AddTicks(-1);
}
else
{
_warnings.Add($"Invalid date '{token.Value}'");
return null;
}
break;
case MetaOperator.GreaterThan:
case MetaOperator.GreaterThanOrEqual:
if (TryParseDate(token.Value, out var gtDate))
min = token.Operator == MetaOperator.GreaterThan ? gtDate.Date.AddDays(1) : gtDate.Date;
else
{
var relative = ParseRelativeDate(token.Value);
if (relative.HasValue)
min = relative.Value;
else
{
_warnings.Add($"Invalid date '{token.Value}'");
return null;
}
}
break;
case MetaOperator.LessThan:
case MetaOperator.LessThanOrEqual:
if (TryParseDate(token.Value, out var ltDate))
max = token.Operator == MetaOperator.LessThan ? ltDate.Date.AddTicks(-1) : ltDate.Date.AddDays(1).AddTicks(-1);
else
{
var relative = ParseRelativeDate(token.Value);
if (relative.HasValue)
max = relative.Value;
else
{
_warnings.Add($"Invalid date '{token.Value}'");
return null;
}
}
break;
case MetaOperator.Range:
var parts = token.Value.Split("..");
if (parts.Length == 2)
{
if (!string.IsNullOrEmpty(parts[0]) && TryParseDate(parts[0], out var rangeMin))
min = rangeMin.Date;
if (!string.IsNullOrEmpty(parts[1]) && TryParseDate(parts[1], out var rangeMax))
max = rangeMax.Date.AddDays(1).AddTicks(-1);
}
if (min == null && max == null)
{
_warnings.Add($"Invalid date range '{token.Value}'");
return null;
}
break;
}
return new DateRangeFilterNode(min, max) { Negated = token.Negated };
}
private bool TryParseDate(string value, out DateTime result)
{
// Support formats: yyyy-MM-dd, yyyy/MM/dd, yyyyMMdd
string[] formats = { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMdd" };
return DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result);
}
private DateTime? ParseRelativeDate(string value)
{
var now = DateTime.UtcNow;
value = value.ToLowerInvariant();
return value switch
{
"day" or "today" => now.AddDays(-1),
"week" => now.AddDays(-7),
"month" => now.AddMonths(-1),
"year" => now.AddYears(-1),
_ => ParseDuration(value)
};
}
private DateTime? ParseDuration(string value)
{
var match = System.Text.RegularExpressions.Regex.Match(value, @"^(\d+)([hdwmy]?)$");
if (!match.Success) return null;
if (!int.TryParse(match.Groups[1].Value, out var amount)) return null;
var unit = match.Groups[2].Value;
var now = DateTime.UtcNow;
return unit switch
{
"h" => now.AddHours(-amount),
"d" => now.AddDays(-amount),
"w" => now.AddDays(-amount * 7),
"m" => now.AddMonths(-amount),
"y" => now.AddYears(-amount),
_ => now.AddDays(-amount) // Default to days if no unit
};
}
private SearchNode? ParseFileType(MetaTagToken token)
{
var value = token.Value.ToLowerInvariant();
// Map common extensions to MIME types
var mimeType = value switch
{
"png" => "image/png",
"jpg" or "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"webm" => "video/webm",
"mp4" => "video/mp4",
"avif" => "image/avif",
_ when value.Contains('/') => value, // Already a MIME type
_ => $"image/{value}" // Assume image if no slash
};
return new FileTypeFilterNode(mimeType) { Negated = token.Negated };
}
private OrderByNode? ParseOrderBy(string value)
{
value = value.ToLowerInvariant();
bool descending = true;
// Check for explicit direction suffix
if (value.EndsWith("_asc"))
{
descending = false;
value = value[..^4];
}
else if (value.EndsWith("_desc"))
{
descending = true;
value = value[..^5];
}
// Normalize field names
var field = value switch
{
"score" or "votes" => "score",
"date" or "uploaded" or "newest" => "date",
"id" => "id",
"random" or "rand" => "random",
"filesize" or "size" => "filesize",
"tagcount" or "tags" => "tagcount",
"controversial" or "drama" => "controversial",
_ => null
};
if (field == null)
{
_warnings.Add($"Unknown sort field '{value}'. Valid values: score, date, id, random, filesize, tagcount, controversial");
return null;
}
// Random sorting ignores direction
if (field == "random")
descending = true;
return new OrderByNode(field, descending);
}
private SearchNode? HandleUnknownMetaTag(MetaTagToken token)
{
// Treat unknown meta-tags as category-prefixed tags (e.g., "character:naruto")
// The query builder will search for tags with matching category slug and name
return new CategoryTagNode(token.Key, token.Value, token.Negated);
}
#region Helper Properties
private bool AtEnd => _pos >= _tokens.Count;
private SearchToken Current => AtEnd ? null! : _tokens[_pos];
private void Advance() => _pos++;
#endregion
}
///
/// Result of parsing a search query.
///
public record SearchParseResult(
SearchNode? RootNode,
OrderByNode? OrderBy,
List Errors,
List Warnings
);