arxiv-client-0.1.0.0: src/Arxiv/Query.hs
{-# LANGUAGE OverloadedStrings #-}
{-|
Module : Arxiv.Query
Description : A tiny, composable DSL for arXiv API search queries
Copyright : (c) Eiko 2025
License : BSD3
Maintainer : eikochanowo@outlook.com
Stability : experimental
Portability : portable
This module provides a small, ergonomic DSL to compose arXiv queries that
render to the API's @search_query@ parameter. It also carries paging and
sorting settings, plus an optional @id_list@ (server-side filtering by IDs).
### TL;DR
Build queries fluently with /builders/ (functions of type 'QBuilder'):
@
import Data.Function ((&))
import Arxiv.Query
let q =
emptyQuery
& ( titleAll [\"Coleman\",\"Tropical\"]
.|| title \"Chabauty\"
.&& (title \"Differential\" .|| title \"Equation\")
)
.&& inCategory \"math.NT\"
& setSort SubmittedDate Desc
& setPaging 0 25
renderSearchQuery q
-- (ti:Coleman+AND+ti:Tropical)+OR+(ti:Chabauty)+AND+((ti:Differential)+OR+(ti:Equation))+AND+cat:math.NT
@
- '(.&&)' and '(.||)' are boolean-style combinators with sensible precedence:
@(.&&)@ binds tighter than @(.||)@ (like '&&' vs '||' in Haskell).
- Builders keep parentheses correct using an internal AST.
### Fields & Syntax
arXiv supports fielded search terms such as:
* @ti:@ (title), @abs:@ (abstract), @au:@ (author), @cat:@ (category)
* @co:@ (comment), @jr:@ (journal reference), @rn:@ (report number)
* @all:@ searches all fields (used by 'anyWords', 'allWords', 'exactPhrase')
Values are automatically quoted when they contain spaces or special characters.
You can also inject raw snippets via 'rawTerm' / 'rawGroupAnd' / 'rawGroupOr'
when you want full control.
### Paging, Sorting, and id_list
- Use 'setPaging' to set @start@ and @max_results@ (arXiv typically allows up
to ~300).
- Use 'setSort' to choose 'SortBy' and 'SortOrder'.
- 'setIdList' / 'addId' / 'clearIdList' manipulate the optional @id_list@
parameter (comma-separated), which the client layer will send alongside
@search_query@.
@renderSearchQuery@ only renders the query expression; paging, sorting, and
@id_list@ are carried in 'ArxivQuery' and used by the client code.
### Not / Negation
- To negate words across all fields, use 'notWords'.
- To negate field-specific predicates, use 'notInTitle', 'notInAbstract',
'notInCategories', or the general 'groupNot'.
@since 0.1.0.0
-}
module Arxiv.Query
( -- * Sorting
SortBy(..)
, SortOrder(..)
-- * QBuilder type
, QBuilder
-- * Query record & basics
, ArxivQuery(..)
, emptyQuery
, setPaging
, setSort
-- * Author predicates
, byAuthor
, authorAny
, authorAll
, authorExact
-- * Title predicates
, titleHas
, titleAny
, titleAll
, titlePhrase
, notInTitle
-- * Abstract predicates
, abstractHas
, abstractAny
, abstractAll
, abstractPhrase
, notInAbstract
-- * Category predicates
, inCategory
, categoriesAny
, categoriesAll
, notInCategories
-- * All-fields helpers
, anyWords
, allWords
, exactPhrase
, notWords
-- * Raw injections
, rawTerm
, rawGroupAnd
, rawGroupOr
-- * Boolean-style combinators and grouping
, (.&&)
, (.||)
, groupAnd
, groupOr
, groupNot
-- * Other fields
, commentHas
, journalRefHas
, reportNumberIs
-- * id_list helpers
, setIdList
, addId
, clearIdList
-- * Rendering
, renderSearchQuery
-- * Term type
, Term(..)
, addTerm
-- * Helper Functions
, (&)
) where
import Data.Function ((&))
import Data.Text (Text)
import qualified Data.Text as T
-- | Builder type for arXiv queries.
--
-- A 'QBuilder' takes an 'ArxivQuery' and returns a modified one. Most
-- functions in this module are such builders and can be composed with
-- function application or with '(.&&)' / '(.||)'.
type QBuilder = ArxivQuery -> ArxivQuery
-- Sorting ---------------------------------------------------------------
-- | Sorting key for the arXiv API.
data SortBy
= Relevance -- ^ @sortBy=relevance@
| LastUpdatedDate -- ^ @sortBy=lastUpdatedDate@
| SubmittedDate -- ^ @sortBy=submittedDate@
deriving (Eq, Show)
-- | Sort direction.
data SortOrder = Asc | Desc
deriving (Eq, Show)
-- Tiny internal AST -----------------------------------------------------
-- | Internal query AST (keeps parentheses and negations correct).
data Term
= TField Text Text -- ^ @f:v@ (auto-quoting values as needed)
| TAllWord Text -- ^ @all:v@
| TAllPhrase Text -- ^ @all:\"...\"@
| TRaw Text -- ^ Injected verbatim snippet
| TNot Term
| TAnd [Term]
| TOr [Term]
deriving (Eq, Show)
-- Query record ----------------------------------------------------------
-- | The full query configuration that the client consumes.
data ArxivQuery = ArxivQuery
{ qTerms :: [Term] -- ^ Query expression terms (top-level AND).
, qStart :: !Int -- ^ Paging start index (offset).
, qMax :: !Int -- ^ Paging size (max_results).
, qSortBy :: !(Maybe SortBy) -- ^ Optional sort key.
, qSortOrder :: !(Maybe SortOrder) -- ^ Optional sort direction.
, qIdList :: ![Text] -- ^ Optional @id_list@ (comma-separated).
} deriving (Eq, Show)
-- | An empty query with no terms, start @0@, max @50@, and no sorting.
emptyQuery :: ArxivQuery
emptyQuery = ArxivQuery [] 0 50 Nothing Nothing []
-- | Set paging parameters.
--
-- Values are clamped to @start >= 0@ and @max >= 1@.
setPaging :: Int -> Int -> QBuilder
setPaging s m q = q { qStart = max 0 s, qMax = max 1 m }
-- | Set sorting: see 'SortBy' and 'SortOrder'.
setSort :: SortBy -> SortOrder -> QBuilder
setSort sb so q = q { qSortBy = Just sb, qSortOrder = Just so }
-- Helpers ---------------------------------------------------------------
quoteIfNeeded :: Text -> Text
quoteIfNeeded t
| T.any (\c -> c == ' ' || c == ':' || c == '(' || c == ')' || c == '"') t
= "\"" <> t <> "\""
| otherwise = t
paren :: Text -> Text
paren x = "(" <> x <> ")"
renderTerm :: Term -> Text
renderTerm (TField f v) = f <> ":" <> quoteIfNeeded v
renderTerm (TAllWord w) = "all:" <> quoteIfNeeded w
renderTerm (TAllPhrase p) = "all:" <> "\"" <> p <> "\""
renderTerm (TRaw t) = t
renderTerm (TNot t) = "-" <> renderTerm t
renderTerm (TAnd xs) = paren (T.intercalate " AND " (map renderTerm xs))
renderTerm (TOr xs) = paren (T.intercalate " OR " (map renderTerm xs))
addTerm :: Term -> QBuilder
addTerm t q = q { qTerms = qTerms q <> [t] }
-- Existing API (preserved) ---------------------------------------------
-- | Search by author: @au:<author>@.
--
-- Examples:
--
-- @
-- emptyQuery & byAuthor "Minhyong Kim"
-- emptyQuery & authorExact "Minhyong Kim" -- quoted exact match
-- @
byAuthor :: Text -> QBuilder
byAuthor a = addTerm (TField "au" a)
-- | Title contains a token: @ti:<title>@.
titleHas :: Text -> QBuilder
titleHas t = addTerm (TField "ti" t)
-- | Abstract contains a token: @abs:<abstract>@.
abstractHas :: Text -> QBuilder
abstractHas t = addTerm (TField "abs" t)
-- | In category: @cat:<category>@ (e.g. @\"math.NT\"@).
inCategory :: Text -> QBuilder
inCategory c = addTerm (TField "cat" c)
-- | Match any of the given words across all fields (OR): @all:...@.
anyWords :: [Text] -> QBuilder
anyWords [] q = q
anyWords ws q = addTerm (TOr (map TAllWord ws)) q
-- | Match all the given words across all fields (AND): @all:...@.
allWords :: [Text] -> QBuilder
allWords [] q = q
allWords ws q = addTerm (TAnd (map TAllWord ws)) q
-- | Match an exact phrase across all fields: @all:\"...\"@.
exactPhrase :: Text -> QBuilder
exactPhrase p = addTerm (TAllPhrase p)
-- | Negate words across all fields: @-all:...@.
notWords :: [Text] -> QBuilder
notWords [] q = q
notWords ws q = addTerm (TAnd (map (TNot . TAllWord) ws)) q
-- | Inject a raw term snippet exactly as written.
--
-- Use this when you need complete control over the fielding/quoting.
rawTerm :: Text -> QBuilder
rawTerm t = addTerm (TRaw t)
-- New fielded combinators ----------------------------------------------
-- | Title contains /any/ of these tokens: @(ti:a OR ti:b ...)@.
titleAny :: [Text] -> QBuilder
titleAny ws = addTerm (TOr (map (TField "ti") ws))
-- | Title contains /all/ of these tokens: @(ti:a AND ti:b ...)@.
titleAll :: [Text] -> QBuilder
titleAll ws = addTerm (TAnd (map (TField "ti") ws))
-- | Title contains this exact phrase: @ti:\"...\"@.
titlePhrase :: Text -> QBuilder
titlePhrase p = addTerm (TRaw ("ti:" <> "\"" <> p <> "\""))
-- | Title does /not/ contain any of these tokens: @-ti:a AND -ti:b ...@.
notInTitle :: [Text] -> QBuilder
notInTitle ws = addTerm (TAnd (map (TNot . TField "ti") ws))
-- | Abstract contains /any/ of these tokens: @(abs:a OR abs:b ...)@.
abstractAny :: [Text] -> QBuilder
abstractAny ws = addTerm (TOr (map (TField "abs") ws))
-- | Abstract contains /all/ of these tokens: @(abs:a AND abs:b ...)@.
abstractAll :: [Text] -> QBuilder
abstractAll ws = addTerm (TAnd (map (TField "abs") ws))
-- | Abstract contains this exact phrase: @abs:\"...\"@.
abstractPhrase :: Text -> QBuilder
abstractPhrase p = addTerm (TRaw ("abs:" <> "\"" <> p <> "\""))
-- | Abstract does /not/ contain any of these tokens.
notInAbstract :: [Text] -> QBuilder
notInAbstract ws = addTerm (TAnd (map (TNot . TField "abs") ws))
-- | Author matches /any/ token: @(au:a OR au:b ...)@.
authorAny :: [Text] -> QBuilder
authorAny ws = addTerm (TOr (map (TField "au") ws))
-- | Author matches /all/ tokens: @(au:a AND au:b ...)@.
authorAll :: [Text] -> QBuilder
authorAll ws = addTerm (TAnd (map (TField "au") ws))
-- | Author exact (quoted): @au:\"...\"@.
authorExact :: Text -> QBuilder
authorExact a = addTerm (TRaw ("au:" <> "\"" <> a <> "\""))
-- | Category is /any/ of: @(cat:a OR cat:b ...)@.
categoriesAny :: [Text] -> QBuilder
categoriesAny cs = addTerm (TOr (map (TField "cat") cs))
-- | Category is /all/ of: @(cat:a AND cat:b ...)@.
categoriesAll :: [Text] -> QBuilder
categoriesAll cs = addTerm (TAnd (map (TField "cat") cs))
-- | Category is /not/ any of: @-cat:a AND -cat:b ...@.
notInCategories :: [Text] -> QBuilder
notInCategories cs = addTerm (TAnd (map (TNot . TField "cat") cs))
-- | Comment contains a token: @co:<text>@.
commentHas :: Text -> QBuilder
commentHas t = addTerm (TField "co" t)
-- | Journal reference contains a token: @jr:<text>@.
journalRefHas :: Text -> QBuilder
journalRefHas t = addTerm (TField "jr" t)
-- | Report number equals token (usually exact): @rn:<id>@.
reportNumberIs :: Text -> QBuilder
reportNumberIs rn = addTerm (TField "rn" rn)
-- Boolean-style combinators & grouping ---------------------------------
infixr 3 .&& -- | AND; binds tighter (like '&&').
infixr 2 .|| -- | OR; binds looser (like '||').
-- | Combine two builders under an AND group (parenthesized).
--
-- @
-- title \"Chabauty\" .&& inCategory \"math.NT\"
-- -- renders as: (ti:Chabauty)+AND+cat:math.NT
-- @
(.&&) :: QBuilder -> QBuilder -> QBuilder
(.&&) f g = groupAnd [f, g]
-- | Combine two builders under an OR group (parenthesized).
(.||) :: QBuilder -> QBuilder -> QBuilder
(.||) f g = groupOr [f, g]
-- | Group a list of builders using AND: @(A AND B AND ...)@.
groupAnd :: [QBuilder] -> QBuilder
groupAnd fs q0 =
let ts = qTerms (foldl (\q f -> f q) (q0 { qTerms = [] }) fs)
in addTerm (TAnd ts) q0
-- | Group a list of builders using OR: @(A OR B OR ...)@.
groupOr :: [QBuilder] -> QBuilder
groupOr fs q0 =
let ts = qTerms (foldl (\q f -> f q) (q0 { qTerms = [] }) fs)
in addTerm (TOr ts) q0
-- | Negate a grouped builder: @-(A AND B ...)@.
--
-- If the builder expands to multiple terms, they are AND-ed before negation.
groupNot :: QBuilder -> QBuilder
groupNot f q0 =
let ts = qTerms (f (q0 { qTerms = [] }))
in case ts of
[] -> q0
[one] -> addTerm (TNot one) q0
many -> addTerm (TNot (TAnd many)) q0
-- Raw groups (inject ready-made snippets) -------------------------------
-- | Group raw snippets with AND, injected verbatim.
rawGroupAnd :: [Text] -> QBuilder
rawGroupAnd xs = addTerm (TAnd (map TRaw xs))
-- | Group raw snippets with OR, injected verbatim.
rawGroupOr :: [Text] -> QBuilder
rawGroupOr xs = addTerm (TOr (map TRaw xs))
-- id_list helpers -------------------------------------------------------
-- | Replace the entire @id_list@ (comma-separated on the wire).
--
-- This is sent alongside @search_query@ by the client layer; arXiv will
-- restrict results to those IDs.
setIdList :: [Text] -> QBuilder
setIdList ids q = q { qIdList = ids }
-- | Append a single ID to @id_list@.
addId :: Text -> QBuilder
addId i q = q { qIdList = qIdList q <> [i] }
-- | Clear @id_list@.
clearIdList :: QBuilder
clearIdList q = q { qIdList = [] }
-- Render ---------------------------------------------------------------
-- | Render the query expression (for the API's @search_query@ parameter).
--
-- Note that paging, sorting, and @id_list@ are not part of this string; they
-- are carried in 'ArxivQuery' and used by the HTTP client.
--
-- If no terms are present, renders to @all:*@.
renderSearchQuery :: ArxivQuery -> Text
renderSearchQuery q =
if null (qTerms q)
then "all:*"
else T.intercalate " AND " (map renderTerm (qTerms q))