[parsing] Migrate SearchQuery parser to C#

This commit is contained in:
Laura Hausmann 2025-01-08 17:33:31 +01:00
parent 553c0cf7ab
commit 8d9856fd62
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
11 changed files with 448 additions and 407 deletions

View file

@ -12,10 +12,10 @@ WORKDIR /src
ARG BUILDPLATFORM
ARG AOT=false
# copy csproj/fsproj & nuget config, then restore as distinct layers
# copy csproj files & nuget config, then restore as distinct layers
COPY NuGet.Config /src
COPY Iceshrimp.Backend/*.csproj /src/Iceshrimp.Backend/
COPY Iceshrimp.Parsing/*.fsproj /src/Iceshrimp.Parsing/
COPY Iceshrimp.Parsing/*.csproj /src/Iceshrimp.Parsing/
COPY Iceshrimp.Frontend/*.csproj /src/Iceshrimp.Frontend/
COPY Iceshrimp.Shared/*.csproj /src/Iceshrimp.Shared/
COPY Iceshrimp.Build/*.csproj /src/Iceshrimp.Build/

View file

@ -7,7 +7,6 @@ using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Parsing;
using Microsoft.EntityFrameworkCore;
using static Iceshrimp.Parsing.SearchQueryFilters;
namespace Iceshrimp.Backend.Core.Extensions;
@ -17,7 +16,7 @@ public static class QueryableFtsExtensions
this IQueryable<Note> query, string input, User user, Config.InstanceSection config, DatabaseContext db
)
{
var parsed = SearchQuery.parse(input);
var parsed = SearchQueryParser.Parse(input);
var caseSensitivity = parsed.OfType<CaseFilter>().LastOrDefault()?.Value ?? CaseFilterType.Insensitive;
var matchType = parsed.OfType<MatchFilter>().LastOrDefault()?.Value ?? MatchFilterType.Substring;
@ -106,8 +105,8 @@ public static class QueryableFtsExtensions
private static IQueryable<Note> ApplyReplyFilter(
this IQueryable<Note> query, ReplyFilter filter, Config.InstanceSection config, DatabaseContext db
) => query.Where(p => p.Reply != null &&
p.Reply.User.UserSubqueryMatches(filter.Value, filter.Negated, config, db));
) => query.Where(p => p.Reply != null
&& p.Reply.User.UserSubqueryMatches(filter.Value, filter.Negated, config, db));
private static IQueryable<Note> ApplyInFilter(
this IQueryable<Note> query, InFilter filter, User user, DatabaseContext db
@ -115,11 +114,11 @@ public static class QueryableFtsExtensions
{
return filter.Value switch
{
{ IsLikes: true } => query.ApplyInLikesFilter(user, filter.Negated, db),
{ IsBookmarks: true } => query.ApplyInBookmarksFilter(user, filter.Negated, db),
{ IsReactions: true } => query.ApplyInReactionsFilter(user, filter.Negated, db),
{ IsInteractions: true } => query.ApplyInInteractionsFilter(user, filter.Negated, db),
_ => throw new ArgumentOutOfRangeException(nameof(filter), filter.Value, null)
InFilterType.Likes => query.ApplyInLikesFilter(user, filter.Negated, db),
InFilterType.Bookmarks => query.ApplyInBookmarksFilter(user, filter.Negated, db),
InFilterType.Reactions => query.ApplyInReactionsFilter(user, filter.Negated, db),
InFilterType.Interactions => query.ApplyInInteractionsFilter(user, filter.Negated, db),
_ => throw new ArgumentOutOfRangeException(nameof(filter), filter.Value, null)
};
}
@ -155,11 +154,11 @@ public static class QueryableFtsExtensions
{
return filter.Value switch
{
{ IsFollowers: true } => query.ApplyFollowersFilter(user, filter.Negated),
{ IsFollowing: true } => query.ApplyFollowingFilter(user, filter.Negated),
{ IsRenotes: true } => query.ApplyBoostsFilter(filter.Negated),
{ IsReplies: true } => query.ApplyRepliesFilter(filter.Negated),
_ => throw new ArgumentOutOfRangeException(nameof(filter))
MiscFilterType.Followers => query.ApplyFollowersFilter(user, filter.Negated),
MiscFilterType.Following => query.ApplyFollowingFilter(user, filter.Negated),
MiscFilterType.Renotes => query.ApplyBoostsFilter(filter.Negated),
MiscFilterType.Replies => query.ApplyRepliesFilter(filter.Negated),
_ => throw new ArgumentOutOfRangeException(nameof(filter))
};
}
@ -182,26 +181,26 @@ public static class QueryableFtsExtensions
private static IQueryable<Note> ApplyRegularAttachmentFilter(this IQueryable<Note> query, AttachmentFilter filter)
{
if (filter.Value.IsMedia)
if (filter.Value is AttachmentFilterType.Media)
return query.Where(p => p.AttachedFileTypes.Count != 0);
if (filter.Value.IsPoll)
if (filter.Value is AttachmentFilterType.Poll)
return query.Where(p => p.HasPoll);
if (filter.Value.IsImage || filter.Value.IsVideo || filter.Value.IsAudio)
if (filter.Value is AttachmentFilterType.Image or AttachmentFilterType.Video or AttachmentFilterType.Audio)
{
return query.Where(p => p.AttachedFileTypes.Count != 0 &&
EF.Functions.ILike(p.RawAttachments, GetAttachmentILikeQuery(filter.Value)));
return query.Where(p => p.AttachedFileTypes.Count != 0
&& EF.Functions.ILike(p.RawAttachments, GetAttachmentILikeQuery(filter.Value)));
}
if (filter.Value.IsFile)
if (filter.Value is AttachmentFilterType.File)
{
return query.Where(p => p.AttachedFileTypes.Count != 0 &&
(!EF.Functions.ILike(p.RawAttachments,
GetAttachmentILikeQuery(AttachmentFilterType.Image)) ||
!EF.Functions.ILike(p.RawAttachments,
GetAttachmentILikeQuery(AttachmentFilterType.Video)) ||
!EF.Functions.ILike(p.RawAttachments,
GetAttachmentILikeQuery(AttachmentFilterType.Audio))));
return query.Where(p => p.AttachedFileTypes.Count != 0
&& (!EF.Functions.ILike(p.RawAttachments,
GetAttachmentILikeQuery(AttachmentFilterType.Image))
|| !EF.Functions.ILike(p.RawAttachments,
GetAttachmentILikeQuery(AttachmentFilterType.Video))
|| !EF.Functions.ILike(p.RawAttachments,
GetAttachmentILikeQuery(AttachmentFilterType.Audio))));
}
throw new ArgumentOutOfRangeException(nameof(filter), filter.Value, null);
@ -209,21 +208,21 @@ public static class QueryableFtsExtensions
private static IQueryable<Note> ApplyNegatedAttachmentFilter(this IQueryable<Note> query, AttachmentFilter filter)
{
if (filter.Value.IsMedia)
if (filter.Value is AttachmentFilterType.Media)
return query.Where(p => p.AttachedFileTypes.Count == 0);
if (filter.Value.IsPoll)
if (filter.Value is AttachmentFilterType.Poll)
return query.Where(p => !p.HasPoll);
if (filter.Value.IsImage || filter.Value.IsVideo || filter.Value.IsAudio)
if (filter.Value is AttachmentFilterType.Image or AttachmentFilterType.Video or AttachmentFilterType.Audio)
return query.Where(p => !EF.Functions.ILike(p.RawAttachments, GetAttachmentILikeQuery(filter.Value)));
if (filter.Value.IsFile)
if (filter.Value is AttachmentFilterType.File)
{
return query.Where(p => EF.Functions
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Image)) ||
EF.Functions
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Video)) ||
EF.Functions
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Audio)));
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Image))
|| EF.Functions
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Video))
|| EF.Functions
.ILike(p.RawAttachments, GetAttachmentILikeQuery(AttachmentFilterType.Audio)));
}
throw new ArgumentOutOfRangeException(nameof(filter), filter.Value, null);
@ -235,10 +234,10 @@ public static class QueryableFtsExtensions
{
return filter switch
{
{ IsImage: true } => "%image/%",
{ IsVideo: true } => "%video/%",
{ IsAudio: true } => "%audio/%",
_ => throw new ArgumentOutOfRangeException(nameof(filter), filter, null)
AttachmentFilterType.Image => "%image/%",
AttachmentFilterType.Video => "%video/%",
AttachmentFilterType.Audio => "%audio/%",
_ => throw new ArgumentOutOfRangeException(nameof(filter), filter, null)
};
}
@ -264,8 +263,8 @@ public static class QueryableFtsExtensions
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
Justification = "Projectable chain must have consistent visibility")]
internal static IQueryable<User> UserSubquery((string username, string? host) filter, DatabaseContext db) =>
db.Users.Where(p => p.UsernameLower == filter.username &&
p.Host == (filter.host != null ? filter.host.ToPunycodeLower() : null));
db.Users.Where(p => p.UsernameLower == filter.username
&& p.Host == (filter.host != null ? filter.host.ToPunycodeLower() : null));
[Projectable]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
@ -275,34 +274,34 @@ public static class QueryableFtsExtensions
) => matchType.Equals(MatchFilterType.Substring)
? caseSensitivity.Equals(CaseFilterType.Sensitive)
? negated
? !EF.Functions.Like(note.Text!, "%" + query + "%", @"\") &&
!EF.Functions.Like(note.Cw!, "%" + query + "%", @"\") &&
!EF.Functions.Like(note.CombinedAltText!, "%" + query + "%", @"\")
: EF.Functions.Like(note.Text!, "%" + query + "%", @"\") ||
EF.Functions.Like(note.Cw!, "%" + query + "%", @"\") ||
EF.Functions.Like(note.CombinedAltText!, "%" + query + "%", @"\")
? !EF.Functions.Like(note.Text!, "%" + query + "%", @"\")
&& !EF.Functions.Like(note.Cw!, "%" + query + "%", @"\")
&& !EF.Functions.Like(note.CombinedAltText!, "%" + query + "%", @"\")
: EF.Functions.Like(note.Text!, "%" + query + "%", @"\")
|| EF.Functions.Like(note.Cw!, "%" + query + "%", @"\")
|| EF.Functions.Like(note.CombinedAltText!, "%" + query + "%", @"\")
: negated
? !EF.Functions.ILike(note.Text!, "%" + query + "%", @"\") &&
!EF.Functions.ILike(note.Cw!, "%" + query + "%", @"\") &&
!EF.Functions.ILike(note.CombinedAltText!, "%" + query + "%", @"\")
: EF.Functions.ILike(note.Text!, "%" + query + "%", @"\") ||
EF.Functions.ILike(note.Cw!, "%" + query + "%", @"\") ||
EF.Functions.ILike(note.CombinedAltText!, "%" + query + "%", @"\")
? !EF.Functions.ILike(note.Text!, "%" + query + "%", @"\")
&& !EF.Functions.ILike(note.Cw!, "%" + query + "%", @"\")
&& !EF.Functions.ILike(note.CombinedAltText!, "%" + query + "%", @"\")
: EF.Functions.ILike(note.Text!, "%" + query + "%", @"\")
|| EF.Functions.ILike(note.Cw!, "%" + query + "%", @"\")
|| EF.Functions.ILike(note.CombinedAltText!, "%" + query + "%", @"\")
: caseSensitivity.Equals(CaseFilterType.Sensitive)
? negated
? !Regex.IsMatch(note.Text!, "\\y" + query + "\\y") &&
!Regex.IsMatch(note.Cw!, "\\y" + query + "\\y") &&
!Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y")
: Regex.IsMatch(note.Text!, "\\y" + query + "\\y") ||
Regex.IsMatch(note.Cw!, "\\y" + query + "\\y") ||
Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y")
? !Regex.IsMatch(note.Text!, "\\y" + query + "\\y")
&& !Regex.IsMatch(note.Cw!, "\\y" + query + "\\y")
&& !Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y")
: Regex.IsMatch(note.Text!, "\\y" + query + "\\y")
|| Regex.IsMatch(note.Cw!, "\\y" + query + "\\y")
|| Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y")
: negated
? !Regex.IsMatch(note.Text!, "\\y" + query + "\\y", RegexOptions.IgnoreCase) &&
!Regex.IsMatch(note.Cw!, "\\y" + query + "\\y", RegexOptions.IgnoreCase) &&
!Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
: Regex.IsMatch(note.Text!, "\\y" + query + "\\y", RegexOptions.IgnoreCase) ||
Regex.IsMatch(note.Cw!, "\\y" + query + "\\y", RegexOptions.IgnoreCase) ||
Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y", RegexOptions.IgnoreCase);
? !Regex.IsMatch(note.Text!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
&& !Regex.IsMatch(note.Cw!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
&& !Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
: Regex.IsMatch(note.Text!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|| Regex.IsMatch(note.Cw!, "\\y" + query + "\\y", RegexOptions.IgnoreCase)
|| Regex.IsMatch(note.CombinedAltText!, "\\y" + query + "\\y", RegexOptions.IgnoreCase);
[Projectable]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global",
@ -338,4 +337,4 @@ public static class QueryableFtsExtensions
this Note note, IEnumerable<string> words, CaseFilterType caseSensitivity, MatchFilterType matchType
) => words.Select(p => PreEscapeFtsQuery(p, matchType))
.Any(p => note.FtsQueryPreEscaped(p, false, caseSensitivity, matchType));
}
}

View file

@ -13,7 +13,7 @@
<ItemGroup>
<ProjectReference Include="..\Iceshrimp.Build\Iceshrimp.Build.csproj" PrivateAssets="all" Private="false" />
<ProjectReference Include="..\Iceshrimp.Frontend\Iceshrimp.Frontend.csproj" />
<ProjectReference Include="..\Iceshrimp.Parsing\Iceshrimp.Parsing.fsproj" />
<ProjectReference Include="..\Iceshrimp.Parsing\Iceshrimp.Parsing.csproj" />
<ProjectReference Include="..\Iceshrimp.Shared\Iceshrimp.Shared.csproj" />
</ItemGroup>

View file

@ -4,8 +4,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Backend", "Iceshr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Tests", "Iceshrimp.Tests\Iceshrimp.Tests.csproj", "{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Iceshrimp.Parsing", "Iceshrimp.Parsing\Iceshrimp.Parsing.fsproj", "{665B7CCA-6B5B-44DC-9CDB-D070622476C2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Frontend", "Iceshrimp.Frontend\Iceshrimp.Frontend.csproj", "{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Shared", "Iceshrimp.Shared\Iceshrimp.Shared.csproj", "{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}"
@ -61,6 +59,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{B14D01
.docker\ci-env-dotnet9.Dockerfile = .docker\ci-env-dotnet9.Dockerfile
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Parsing", "Iceshrimp.Parsing\Iceshrimp.Parsing.csproj", "{6BB21937-A781-4D2A-B64A-19E985870B38}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -75,10 +75,6 @@ Global
{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0C93C33B-3D68-41DE-8BD6-2C19EB1C95F7}.Release|Any CPU.Build.0 = Release|Any CPU
{665B7CCA-6B5B-44DC-9CDB-D070622476C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{665B7CCA-6B5B-44DC-9CDB-D070622476C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{665B7CCA-6B5B-44DC-9CDB-D070622476C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{665B7CCA-6B5B-44DC-9CDB-D070622476C2}.Release|Any CPU.Build.0 = Release|Any CPU
{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8BAF3DEB-19A7-4044-A3F3-75C8B9B51863}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -91,6 +87,10 @@ Global
{B2598946-03CA-4C6B-8E3E-7F2AC77021E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2598946-03CA-4C6B-8E3E-7F2AC77021E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2598946-03CA-4C6B-8E3E-7F2AC77021E5}.Release|Any CPU.Build.0 = Release|Any CPU
{6BB21937-A781-4D2A-B64A-19E985870B38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6BB21937-A781-4D2A-B64A-19E985870B38}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6BB21937-A781-4D2A-B64A-19E985870B38}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6BB21937-A781-4D2A-B64A-19E985870B38}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{12AC2DB4-4817-4F73-B541-20568AC51685} = {2000A25C-AF38-47BC-9432-D1278C12010B}

View file

@ -1 +0,0 @@
*.fs linguist-language=F#

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -1,12 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<Compile Include="*.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FParsec" Version="1.1.1" />
<PackageReference Update="FSharp.Core" Version="9.0.100" />
</ItemGroup>
</Project>

View file

@ -1,264 +0,0 @@
namespace Iceshrimp.Parsing
open System
open FParsec
module SearchQueryFilters =
type Filter() = class end
type WordFilter(neg: bool, value: string) =
inherit Filter()
member val Negated = neg
member val Value = value
type CwFilter(neg: bool, value: string) =
inherit Filter()
member val Negated = neg
member val Value = value
type MultiWordFilter(values: string list) =
inherit Filter()
member val Values = values
type FromFilter(neg: bool, value: string) =
inherit Filter()
member val Negated = neg
member val Value = value
type MentionFilter(neg: bool, value: string) =
inherit Filter()
member val Negated = neg
member val Value = value
type ReplyFilter(neg: bool, value: string) =
inherit Filter()
member val Negated = neg
member val Value = value
type InstanceFilter(neg: bool, value: string) =
inherit Filter()
member val Negated = neg
member val Value = value
type MiscFilterType =
| Followers
| Following
| Replies
| Renotes
type MiscFilter(neg: bool, value: string) =
inherit Filter()
member val Negated = neg
member val Value =
match value with
| "followers" -> Followers
| "following" -> Following
| "replies" -> Replies
| "reply" -> Replies
| "boosts" -> Renotes
| "boost" -> Renotes
| "renote" -> Renotes
| "renotes" -> Renotes
| _ -> failwith $"Invalid type: {value}"
type InFilterType =
| Bookmarks
| Likes
| Reactions
| Interactions
type InFilter(neg: bool, value: string) =
inherit Filter()
member val Negated = neg
member val Value =
match value with
| "bookmarks" -> Bookmarks
| "likes" -> Likes
| "favorites" -> Likes
| "favourites" -> Likes
| "reactions" -> Reactions
| "interactions" -> Interactions
| _ -> failwith $"Invalid type: {value}"
type AttachmentFilterType =
| Media
| Image
| Video
| Audio
| File
| Poll
type AttachmentFilter(neg: bool, value: string) =
inherit Filter()
member val Negated = neg
member val Value =
match value with
| "any" -> Media
| "media" -> Media
| "image" -> Image
| "video" -> Video
| "audio" -> Audio
| "file" -> File
| "poll" -> Poll
| _ -> failwith $"Invalid type: {value}"
type AfterFilter(d: DateOnly) =
inherit Filter()
member val Value = d
type BeforeFilter(d: DateOnly) =
inherit Filter()
member val Value = d
type CaseFilterType =
| Sensitive
| Insensitive
type CaseFilter(v: string) =
inherit Filter()
member val Value =
match v with
| "sensitive" -> Sensitive
| "insensitive" -> Insensitive
| _ -> failwith $"Invalid type: {v}"
type MatchFilterType =
| Words
| Substring
type MatchFilter(v: string) =
inherit Filter()
member val Value =
match v with
| "word" -> Words
| "words" -> Words
| "substr" -> Substring
| "substring" -> Substring
| _ -> failwith $"Invalid type: {v}"
module private SearchQueryParser =
open SearchQueryFilters
// Abstractions
let str s = pstring s
let tokenEnd = (skipChar ' ' <|> eof)
let token = anyChar |> manyCharsTill <| tokenEnd
let orTokenEnd = (skipChar ' ' <|> lookAhead (skipChar ')') <|> eof)
let orToken = spaces >>. anyChar |> manyCharsTill <| orTokenEnd
let key s = str s .>>? pchar ':'
let strEnd s = str s .>>? tokenEnd
let anyStr s = choice (s |> Seq.map strEnd)
let anyKey k = choice (k |> Seq.map key)
let seqAttempt s = s |> Seq.map attempt
let neg = opt <| pchar '-'
let negFilter k = pipe2 neg (anyKey k >>. token)
let negKeyFilter k v = pipe2 neg (anyKey k >>. anyStr v)
let keyFilter k v = anyKey k >>. anyStr v
let strSepByOr = sepBy orToken (str "OR ")
let parseDate (s: string) =
match DateOnly.TryParseExact(s, "O") with
| true, result -> preturn result
| false, _ -> fail $"Invalid date: {s}"
let dateFilter k = anyKey k >>. token >>= parseDate
// Filters
let wordFilter = pipe2 neg token <| fun a b -> WordFilter(a.IsSome, b) :> Filter
let cwFilter = negFilter [ "cw" ] <| fun n v -> CwFilter(n.IsSome, v) :> Filter
let multiWordFilter =
skipChar '(' >>. strSepByOr .>> skipChar ')'
|>> fun v -> MultiWordFilter(v) :> Filter
let literalStringFilter =
skipChar '"' >>. manyCharsTill anyChar (skipChar '"')
|>> fun v -> WordFilter(false, v) :> Filter
let fromFilter =
negFilter [ "from"; "author"; "by"; "user" ]
<| fun n v -> FromFilter(n.IsSome, v) :> Filter
let mentionFilter =
negFilter [ "mention"; "mentions"; "mentioning" ]
<| fun n v -> MentionFilter(n.IsSome, v) :> Filter
let replyFilter =
negFilter [ "reply"; "replying"; "to" ]
<| fun n v -> ReplyFilter(n.IsSome, v) :> Filter
let instanceFilter =
negFilter [ "instance"; "domain"; "host" ]
<| fun n v -> InstanceFilter(n.IsSome, v) :> Filter
let miscFilter =
negKeyFilter
[ "filter" ]
[ "followers"
"following"
"replies"
"reply"
"renote"
"renotes"
"boosts"
"boost" ]
<| fun n v -> MiscFilter(n.IsSome, v) :> Filter
let inFilter =
negKeyFilter [ "in" ] [ "bookmarks"; "favorites"; "favourites"; "reactions"; "likes"; "interactions" ]
<| fun n v -> InFilter(n.IsSome, v) :> Filter
let attachmentFilter =
negKeyFilter [ "has"; "attachment"; "attached" ] [ "any"; "media"; "image"; "video"; "audio"; "file"; "poll" ]
<| fun n v -> AttachmentFilter(n.IsSome, v) :> Filter
let afterFilter =
dateFilter [ "after"; "since" ] |>> fun v -> AfterFilter(v) :> Filter
let beforeFilter =
dateFilter [ "before"; "until" ] |>> fun v -> BeforeFilter(v) :> Filter
let caseFilter =
keyFilter [ "case" ] [ "sensitive"; "insensitive" ]
|>> fun v -> CaseFilter(v) :> Filter
let matchFilter =
keyFilter [ "match" ] [ "words"; "word"; "substr"; "substring" ]
|>> fun v -> MatchFilter(v) :> Filter
// Filter collection
let filterSeq =
[ literalStringFilter
fromFilter
mentionFilter
replyFilter
instanceFilter
miscFilter
inFilter
attachmentFilter
afterFilter
beforeFilter
caseFilter
matchFilter
cwFilter
multiWordFilter
wordFilter ]
// Final parse commands
let filters = choice <| seqAttempt filterSeq
let parse = manyTill (spaces >>. filters .>> spaces) eof
module SearchQuery =
open SearchQueryParser
let parse str =
match run parse str with
| Success(result, _, _) -> result
| Failure(s, _, _) -> failwith $"Failed to parse query: {s}"

View file

@ -0,0 +1,190 @@
using System.Diagnostics.CodeAnalysis;
namespace Iceshrimp.Parsing;
public interface ISearchQueryFilter;
public record WordFilter(bool Negated, string Value) : ISearchQueryFilter;
public record CwFilter(bool Negated, string Value) : ISearchQueryFilter;
public record MultiWordFilter(bool Negated, string[] Values) : ISearchQueryFilter;
public record FromFilter(bool Negated, string Value) : ISearchQueryFilter;
public record MentionFilter(bool Negated, string Value) : ISearchQueryFilter;
public record ReplyFilter(bool Negated, string Value) : ISearchQueryFilter;
public record InstanceFilter(bool Negated, string Value) : ISearchQueryFilter;
public enum MiscFilterType
{
Followers,
Following,
Replies,
Renotes
}
public record MiscFilter(bool Negated, MiscFilterType Value) : ISearchQueryFilter
{
public static bool TryParse(bool negated, ReadOnlySpan<char> value, [NotNullWhen(true)] out MiscFilter? result)
{
MiscFilterType? type = value switch
{
"followers" => MiscFilterType.Followers,
"following" => MiscFilterType.Following,
"replies" => MiscFilterType.Replies,
"reply" => MiscFilterType.Replies,
"renote" => MiscFilterType.Renotes,
"renotes" => MiscFilterType.Renotes,
"boosts" => MiscFilterType.Renotes,
"boost" => MiscFilterType.Renotes,
_ => null
};
if (!type.HasValue)
{
result = null;
return false;
}
result = new MiscFilter(negated, type.Value);
return true;
}
}
public enum InFilterType
{
Bookmarks,
Likes,
Reactions,
Interactions
}
public record InFilter(bool Negated, InFilterType Value) : ISearchQueryFilter
{
public static bool TryParse(bool negated, ReadOnlySpan<char> value, [NotNullWhen(true)] out InFilter? result)
{
InFilterType? type = value switch
{
"bookmarks" => InFilterType.Bookmarks,
"likes" => InFilterType.Likes,
"favorites" => InFilterType.Likes,
"favourites" => InFilterType.Likes,
"reactions" => InFilterType.Reactions,
"interactions" => InFilterType.Interactions,
_ => null
};
if (!type.HasValue)
{
result = null;
return false;
}
result = new InFilter(negated, type.Value);
return true;
}
}
public enum AttachmentFilterType
{
Media,
Image,
Video,
Audio,
File,
Poll
}
public record AttachmentFilter(bool Negated, AttachmentFilterType Value) : ISearchQueryFilter
{
public static bool TryParse(
bool negated, ReadOnlySpan<char> value, [NotNullWhen(true)] out AttachmentFilter? result
)
{
AttachmentFilterType? type = value switch
{
"any" => AttachmentFilterType.Media,
"media" => AttachmentFilterType.Media,
"image" => AttachmentFilterType.Image,
"video" => AttachmentFilterType.Video,
"audio" => AttachmentFilterType.Audio,
"file" => AttachmentFilterType.File,
"poll" => AttachmentFilterType.Poll,
_ => null
};
if (!type.HasValue)
{
result = null;
return false;
}
result = new AttachmentFilter(negated, type.Value);
return true;
}
}
public record AfterFilter(DateOnly Value) : ISearchQueryFilter;
public record BeforeFilter(DateOnly Value) : ISearchQueryFilter;
public enum CaseFilterType
{
Sensitive,
Insensitive
}
public record CaseFilter(CaseFilterType Value) : ISearchQueryFilter
{
public static bool TryParse(ReadOnlySpan<char> value, [NotNullWhen(true)] out CaseFilter? result)
{
CaseFilterType? type = value switch
{
"sensitive" => CaseFilterType.Sensitive,
"insensitive" => CaseFilterType.Insensitive,
_ => null
};
if (!type.HasValue)
{
result = null;
return false;
}
result = new CaseFilter(type.Value);
return true;
}
}
public enum MatchFilterType
{
Words,
Substring
}
public record MatchFilter(MatchFilterType Value) : ISearchQueryFilter
{
public static bool TryParse(ReadOnlySpan<char> value, [NotNullWhen(true)] out MatchFilter? result)
{
MatchFilterType? type = value switch
{
"words" => MatchFilterType.Words,
"word" => MatchFilterType.Words,
"substring" => MatchFilterType.Substring,
"substr" => MatchFilterType.Substring,
_ => null
};
if (!type.HasValue)
{
result = null;
return false;
}
result = new MatchFilter(type.Value);
return true;
}
}

View file

@ -0,0 +1,102 @@
namespace Iceshrimp.Parsing;
public static class SearchQueryParser
{
public static List<ISearchQueryFilter> Parse(ReadOnlySpan<char> input)
{
var results = new List<ISearchQueryFilter>();
input = input.Trim();
if (input.Length == 0) return [];
int pos = 0;
while (pos < input.Length)
{
var oldPos = pos;
var res = ParseToken(input, ref pos);
if (res == null) return results;
if (pos <= oldPos) throw new Exception("Infinite loop detected!");
results.Add(res);
}
return results;
}
private static ISearchQueryFilter? ParseToken(ReadOnlySpan<char> input, ref int pos)
{
while (input[pos] == ' ')
{
pos++;
if (pos >= input.Length) return null;
}
var negated = false;
if (input[pos] == '-' && input.Length > pos + 1)
{
negated = true;
pos++;
}
if (input[pos] == '"' && input.Length > pos + 2)
{
var closingQuote = pos + 1 + input[(pos + 1)..].IndexOf('"');
if (closingQuote != -1)
{
var literalRes = new WordFilter(negated, input[++pos..closingQuote].ToString());
pos = closingQuote + 1;
return literalRes;
}
}
if (input[pos] == '(' && input.Length > pos + 2)
{
var closingParen = pos + 1 + input[(pos + 1)..].IndexOf(')');
if (closingParen != -1)
{
var items = input[++pos..closingParen].ToString().Split(" OR ").Select(p => p.Trim()).ToArray();
var literalRes = new MultiWordFilter(negated, items);
if (items.Length > 0)
{
pos = closingParen + 1;
return literalRes;
}
}
}
var end = input[pos..].IndexOf(' ');
if (end == -1)
end = input.Length;
else
end += pos;
var splitIdx = input[pos..end].IndexOf(':');
var keyRange = splitIdx < 1 ? ..0 : pos..(pos + splitIdx);
var key = splitIdx < 1 ? ReadOnlySpan<char>.Empty : input[keyRange];
var value = splitIdx < 1 ? input : input[(keyRange.End.Value + 1)..end];
ISearchQueryFilter res = key switch
{
"cw" => new CwFilter(negated, value.ToString()),
"from" or "author" or "by" or "user" => new FromFilter(negated, value.ToString()),
"mention" or "mentions" or "mentioning" => new MentionFilter(negated, value.ToString()),
"reply" or "replying" or "to" => new ReplyFilter(negated, value.ToString()),
"instance" or "domain" or "host" => new InstanceFilter(negated, value.ToString()),
"filter" when MiscFilter.TryParse(negated, value, out var parsed) => parsed,
"in" when InFilter.TryParse(negated, value, out var parsed) => parsed,
"has" or "attachment" or "attached" when AttachmentFilter.TryParse(negated, value, out var parsed)
=> parsed,
"case" when CaseFilter.TryParse(value, out var parsed) => parsed,
"match" when MatchFilter.TryParse(value, out var parsed) => parsed,
"after" or "since" when DateOnly.TryParse(value, out var date) => new AfterFilter(date),
"before" or "until" when DateOnly.TryParse(value, out var date) => new BeforeFilter(date),
_ => new WordFilter(negated, input[pos..end].ToString())
};
pos = end;
return res;
}
}

View file

@ -1,15 +1,14 @@
using Iceshrimp.Parsing;
using static Iceshrimp.Parsing.SearchQueryFilters;
namespace Iceshrimp.Tests.Parsing;
[TestClass]
public class SearchQueryTests
{
private static List<Filter> GetCandidatesByUsername(IEnumerable<string> candidates) =>
candidates.Select(p => $"{p}:username").SelectMany(SearchQuery.parse).ToList();
private static List<ISearchQueryFilter> GetCandidatesByUsername(IEnumerable<string> candidates) =>
candidates.Select(p => $"{p}:username").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
private static void Validate(ICollection<Filter> results, object expectedResult, int count)
private static void Validate(ICollection<ISearchQueryFilter> results, object expectedResult, int count)
{
results.Count.Should().Be(count);
foreach (var res in results) res.Should().BeEquivalentTo(expectedResult);
@ -20,7 +19,7 @@ public class SearchQueryTests
[DataRow(true)]
public void TestParseCw(bool negated)
{
var result = SearchQuery.parse(negated ? "-cw:meta" : "cw:meta").ToList();
var result = SearchQueryParser.Parse(negated ? "-cw:meta" : "cw:meta").ToList();
var expectedResult = new CwFilter(negated, "meta");
Validate(result, expectedResult, 1);
}
@ -37,6 +36,17 @@ public class SearchQueryTests
Validate(results, expectedResult, candidates.Count);
}
[TestMethod]
[DataRow(false)]
[DataRow(true)]
public void TestParseInvalid(bool negated)
{
var prefix = negated ? "-" : "";
//SearchQueryParser.Parse($"{prefix}from:");
//SearchQueryParser.Parse($"{prefix}:");
SearchQueryParser.Parse($"{prefix}asd {prefix}:");
}
[TestMethod]
[DataRow(false)]
[DataRow(true)]
@ -68,26 +78,26 @@ public class SearchQueryTests
{
List<string> candidates = ["instance", "domain", "host"];
if (negated) candidates = candidates.Select(p => "-" + p).ToList();
var results = candidates.Select(p => $"{p}:instance.tld").SelectMany(SearchQuery.parse).ToList();
var expectedResult = new InstanceFilter(negated, "instance.tld");
var results = candidates.Select(p => $"{p}:instance.tld").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
var expectedResult = new InstanceFilter(negated, "instance.tld");
Validate(results, expectedResult, candidates.Count);
}
[TestMethod]
public void TestParseAfter()
{
List<string> candidates = ["after", "since"];
var results = candidates.Select(p => $"{p}:2024-03-01").SelectMany(SearchQuery.parse).ToList();
var expectedResult = new AfterFilter(DateOnly.ParseExact("2024-03-01", "O"));
List<string> candidates = ["after", "since"];
var results = candidates.Select(p => $"{p}:2024-03-01").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
var expectedResult = new AfterFilter(DateOnly.ParseExact("2024-03-01", "O"));
Validate(results, expectedResult, candidates.Count);
}
[TestMethod]
public void TestParseBefore()
{
List<string> candidates = ["before", "until"];
var results = candidates.Select(p => $"{p}:2024-03-01").SelectMany(SearchQuery.parse).ToList();
var expectedResult = new BeforeFilter(DateOnly.ParseExact("2024-03-01", "O"));
List<string> candidates = ["before", "until"];
var results = candidates.Select(p => $"{p}:2024-03-01").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
var expectedResult = new BeforeFilter(DateOnly.ParseExact("2024-03-01", "O"));
Validate(results, expectedResult, candidates.Count);
}
@ -100,16 +110,18 @@ public class SearchQueryTests
if (negated) keyCandidates = keyCandidates.Select(p => "-" + p).ToList();
List<string> candidates = ["any", "media", "image", "video", "audio", "file", "poll"];
var results =
keyCandidates.Select(k => candidates.Select(v => $"{k}:{v}").SelectMany(SearchQuery.parse).ToList());
List<Filter> expectedResults =
keyCandidates.Select(k => candidates.Select(v => $"{k}:{v}")
.SelectMany(p => SearchQueryParser.Parse(p))
.ToList());
List<ISearchQueryFilter> expectedResults =
[
new AttachmentFilter(negated, "any"),
new AttachmentFilter(negated, "media"),
new AttachmentFilter(negated, "image"),
new AttachmentFilter(negated, "video"),
new AttachmentFilter(negated, "audio"),
new AttachmentFilter(negated, "file"),
new AttachmentFilter(negated, "poll")
new AttachmentFilter(negated, AttachmentFilterType.Media),
new AttachmentFilter(negated, AttachmentFilterType.Media),
new AttachmentFilter(negated, AttachmentFilterType.Image),
new AttachmentFilter(negated, AttachmentFilterType.Video),
new AttachmentFilter(negated, AttachmentFilterType.Audio),
new AttachmentFilter(negated, AttachmentFilterType.File),
new AttachmentFilter(negated, AttachmentFilterType.Poll)
];
results.Should()
.HaveCount(keyCandidates.Count)
@ -119,10 +131,13 @@ public class SearchQueryTests
[TestMethod]
public void TestParseCase()
{
const string key = "case";
List<string> candidates = ["sensitive", "insensitive"];
var results = candidates.Select(v => $"{key}:{v}").SelectMany(SearchQuery.parse).ToList();
List<Filter> expectedResults = [new CaseFilter("sensitive"), new CaseFilter("insensitive")];
const string key = "case";
List<string> candidates = ["sensitive", "insensitive"];
var results = candidates.Select(v => $"{key}:{v}").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
List<ISearchQueryFilter> expectedResults =
[
new CaseFilter(CaseFilterType.Sensitive), new CaseFilter(CaseFilterType.Insensitive)
];
results.Should()
.HaveCount(expectedResults.Count)
.And.BeEquivalentTo(expectedResults, opts => opts.RespectingRuntimeTypes());
@ -131,12 +146,15 @@ public class SearchQueryTests
[TestMethod]
public void TestParseMatch()
{
const string key = "match";
const string key = "match";
List<string> candidates = ["words", "word", "substr", "substring"];
var results = candidates.Select(v => $"{key}:{v}").SelectMany(SearchQuery.parse).ToList();
List<Filter> expectedResults =
var results = candidates.Select(v => $"{key}:{v}").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
List<ISearchQueryFilter> expectedResults =
[
new MatchFilter("words"), new MatchFilter("words"), new MatchFilter("substr"), new MatchFilter("substr")
new MatchFilter(MatchFilterType.Words),
new MatchFilter(MatchFilterType.Words),
new MatchFilter(MatchFilterType.Substring),
new MatchFilter(MatchFilterType.Substring)
];
results.Should()
.HaveCount(expectedResults.Count)
@ -148,17 +166,17 @@ public class SearchQueryTests
[DataRow(true)]
public void TestParseIn(bool negated)
{
var key = negated ? "-in" : "in";
var key = negated ? "-in" : "in";
List<string> candidates = ["bookmarks", "likes", "favorites", "favourites", "reactions", "interactions"];
var results = candidates.Select(v => $"{key}:{v}").SelectMany(SearchQuery.parse).ToList();
List<Filter> expectedResults =
var results = candidates.Select(v => $"{key}:{v}").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
List<ISearchQueryFilter> expectedResults =
[
new InFilter(negated, "bookmarks"),
new InFilter(negated, "likes"),
new InFilter(negated, "likes"),
new InFilter(negated, "likes"),
new InFilter(negated, "reactions"),
new InFilter(negated, "interactions")
new InFilter(negated, InFilterType.Bookmarks),
new InFilter(negated, InFilterType.Likes),
new InFilter(negated, InFilterType.Likes),
new InFilter(negated, InFilterType.Likes),
new InFilter(negated, InFilterType.Reactions),
new InFilter(negated, InFilterType.Interactions)
];
results.Should()
.HaveCount(expectedResults.Count)
@ -175,17 +193,17 @@ public class SearchQueryTests
[
"followers", "following", "replies", "reply", "renote", "renotes", "boosts", "boost"
];
var results = candidates.Select(v => $"{key}:{v}").SelectMany(SearchQuery.parse).ToList();
List<Filter> expectedResults =
var results = candidates.Select(v => $"{key}:{v}").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
List<ISearchQueryFilter> expectedResults =
[
new MiscFilter(negated, "followers"),
new MiscFilter(negated, "following"),
new MiscFilter(negated, "replies"),
new MiscFilter(negated, "replies"),
new MiscFilter(negated, "renotes"),
new MiscFilter(negated, "renotes"),
new MiscFilter(negated, "renotes"),
new MiscFilter(negated, "renotes")
new MiscFilter(negated, MiscFilterType.Followers),
new MiscFilter(negated, MiscFilterType.Following),
new MiscFilter(negated, MiscFilterType.Replies),
new MiscFilter(negated, MiscFilterType.Replies),
new MiscFilter(negated, MiscFilterType.Renotes),
new MiscFilter(negated, MiscFilterType.Renotes),
new MiscFilter(negated, MiscFilterType.Renotes),
new MiscFilter(negated, MiscFilterType.Renotes)
];
results.Should()
.HaveCount(expectedResults.Count)
@ -199,8 +217,8 @@ public class SearchQueryTests
{
List<string> candidates = ["test", "word", "since:2023-10-10invalid", "in:bookmarkstypo"];
if (negated) candidates = candidates.Select(p => "-" + p).ToList();
var results = candidates.Select(v => $"{v}").SelectMany(SearchQuery.parse).ToList();
List<Filter> expectedResults =
var results = candidates.Select(v => $"{v}").SelectMany(p => SearchQueryParser.Parse(p)).ToList();
List<ISearchQueryFilter> expectedResults =
[
new WordFilter(negated, "test"),
new WordFilter(negated, "word"),
@ -216,7 +234,7 @@ public class SearchQueryTests
public void TestParseMultiWord()
{
const string input = "(word OR word2 OR word3)";
var results = SearchQuery.parse(input).ToList();
var results = SearchQueryParser.Parse(input).ToList();
results.Should().HaveCount(1);
results[0].Should().BeOfType<MultiWordFilter>();
((MultiWordFilter)results[0]).Values.ToList().Should().BeEquivalentTo(["word", "word2", "word3"]);
@ -226,10 +244,10 @@ public class SearchQueryTests
public void TestParseLiteralString()
{
const string input = "\"literal string with spaces $# and has:image before:2023-10-10 other things\"";
var results = SearchQuery.parse(input).ToList();
var results = SearchQueryParser.Parse(input).ToList();
results.Should().HaveCount(1);
results[0].Should().BeOfType<WordFilter>();
((WordFilter)results[0]).Value.Should()
.BeEquivalentTo("literal string with spaces $# and has:image before:2023-10-10 other things");
}
}
}