{-# LANGUAGE OverloadedStrings #-}

-- |
-- Module      :  Text.Gemini
-- Copyright   :  (c) Sena, 2023
-- License     :  AGPL-3.0-or-later
--
-- Maintainer  :  Sena <jn-sena@proton.me>
-- Stability   :  stable
-- Portability :  portable
--
-- A tiny @text/gemini@ parser.
--
-- Parses Gemtext documents from and to 'Text'. See the Section 5 of the
-- [Gemini Protocol specification](https://geminiprotocol.net/docs/specification.gmi).

module Text.Gemini
  ( -- * Gemtext Types
    GemDocument
  , GemItem (..)
    -- * Decoding from Text
  , decode
  , decodeLine
    -- * Encoding to Text
  , encode
  , encodeItem
  ) where

import Data.Text (Text)
import qualified Data.Text as T
import Data.Maybe (maybeToList)
import Data.Char (isSpace)
import Data.List (foldl')
import Data.Bool (bool)


-- | A Gemtext document, in the form of an ordered list.
type GemDocument = [GemItem]

-- | A Gemtext item.
data GemItem = GemText !Text -- ^ A regular Gemtext line. -- @'GemText' \<Text>@
             | GemLink !Text !(Maybe Text) -- ^ A Gemtext link. -- @'GemLink' \<Link> \[Optional Description]@
             | GemHeading !Int !Text -- ^ A Gemtext heading of 3 levels max. -- @'GemHeading' \<Level> \<Text>@
             | GemList ![Text] -- ^ A Gemtext unordered list. -- @'GemList' \<Lines>@
             | GemQuote !Text -- ^ A Gemtext quote. -- @'GemQuote' \<Text>@
             | GemPre ![Text] !(Maybe Text) -- ^ A Gemtext preformat. -- @'GemPre' \<Lines> [Optional Alt Text]@
             deriving (Int -> GemItem -> ShowS
GemDocument -> ShowS
GemItem -> String
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
showList :: GemDocument -> ShowS
$cshowList :: GemDocument -> ShowS
show :: GemItem -> String
$cshow :: GemItem -> String
showsPrec :: Int -> GemItem -> ShowS
$cshowsPrec :: Int -> GemItem -> ShowS
Show, GemItem -> GemItem -> Bool
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
/= :: GemItem -> GemItem -> Bool
$c/= :: GemItem -> GemItem -> Bool
== :: GemItem -> GemItem -> Bool
$c== :: GemItem -> GemItem -> Bool
Eq)


-- | Parse a @text/gemini@ file as 'GemDocument'.
-- The text should be supplied as an LF-ending 'Text'.
decode :: Text -> GemDocument
decode :: Text -> GemDocument
decode = GemDocument -> [Text] -> GemDocument
parse [] forall b c a. (b -> c) -> (a -> b) -> a -> c
. Text -> [Text]
T.lines
    where -- Default line-by-line recursive parser with 'GemDocument' carried.
          -- If a list or preformatting is detected, will continue with
          -- @parseList@ and @parsePre@ respectively. Otherwise, will use
          -- 'decodeLine' on the current line only.
          parse :: GemDocument -> [Text] -> GemDocument
          parse :: GemDocument -> [Text] -> GemDocument
parse GemDocument
doc (Text
l:[Text]
ls)
              | Text
l Text -> Text -> Bool
`hasValueOf` Text
"* " = GemDocument -> [Text] -> [Text] -> GemDocument
parseList GemDocument
doc [Int -> Text -> Text
value Int
1 Text
l] [Text]
ls
              | Text
"```" Text -> Text -> Bool
`T.isPrefixOf` Text
l = GemDocument -> [Text] -> Maybe Text -> [Text] -> GemDocument
parsePre GemDocument
doc [] (Text -> Maybe Text
optional forall a b. (a -> b) -> a -> b
$ Int -> Text -> Text
value Int
3 Text
l) [Text]
ls
              | Bool
otherwise = GemDocument -> [Text] -> GemDocument
parse (GemDocument
doc forall a. Semigroup a => a -> a -> a
<> [Text -> GemItem
decodeLine Text
l]) [Text]
ls
          parse GemDocument
doc [] = GemDocument
doc

          -- List recursive parser with a list carried. Adds the line to
          -- the list if it starts with list prefix. If the line doesn't
          -- start with the prefix, will end the recursion and continue with
          -- @parse@, with the final list added to the carried 'GemDocument'
          -- as 'GemList'.
          parseList :: GemDocument -> [Text] -> [Text] -> GemDocument
          parseList :: GemDocument -> [Text] -> [Text] -> GemDocument
parseList GemDocument
doc [Text]
glist (Text
l:[Text]
ls)
              | Text
l Text -> Text -> Bool
`hasValueOf` Text
"* " = GemDocument -> [Text] -> [Text] -> GemDocument
parseList GemDocument
doc ([Text]
glist forall a. Semigroup a => a -> a -> a
<> [Int -> Text -> Text
value Int
1 Text
l]) [Text]
ls
              | Bool
otherwise = GemDocument -> [Text] -> GemDocument
parse (GemDocument
doc forall a. Semigroup a => a -> a -> a
<> [[Text] -> GemItem
GemList [Text]
glist]) (Text
lforall a. a -> [a] -> [a]
:[Text]
ls)
          parseList GemDocument
doc [Text]
glist [] = GemDocument
doc forall a. Semigroup a => a -> a -> a
<> [[Text] -> GemItem
GemList [Text]
glist]

          -- Preformatted recursive parser with lines carried. Adds the line to
          -- the list if the ending delimiter hasn't been reached yet. If the line
          -- is the ending with the delimiter, will end the recursion and continue
          -- with @parse@, with the final list of lines added to the carried
          -- 'GemDocument' as 'GemPre'.
          parsePre :: GemDocument -> [Text] -> Maybe Text -> [Text] -> GemDocument
          parsePre :: GemDocument -> [Text] -> Maybe Text -> [Text] -> GemDocument
parsePre GemDocument
doc [Text]
glines Maybe Text
alt (Text
l:[Text]
ls)
              | Text
"```" Text -> Text -> Bool
`T.isPrefixOf` Text
l = GemDocument -> [Text] -> GemDocument
parse (GemDocument
doc forall a. Semigroup a => a -> a -> a
<> [[Text] -> Maybe Text -> GemItem
GemPre [Text]
glines Maybe Text
alt]) [Text]
ls
              | Bool
otherwise = GemDocument -> [Text] -> Maybe Text -> [Text] -> GemDocument
parsePre GemDocument
doc ([Text]
glines forall a. Semigroup a => a -> a -> a
<> [Text
l]) Maybe Text
alt [Text]
ls
          parsePre GemDocument
doc [Text]
glines Maybe Text
alt [] = GemDocument
doc forall a. Semigroup a => a -> a -> a
<> [[Text] -> Maybe Text -> GemItem
GemPre [Text]
glines Maybe Text
alt]

-- | Parse a /single/ @text/gemini@ line as 'GemItem'.
--
-- Notes:
--
--     * Isn't able to decode preformatted texts as they are strictly multiline.
--     * 'GemList's are also obviously singleton.
decodeLine :: Text -> GemItem
decodeLine :: Text -> GemItem
decodeLine Text
line
    | Text
line Text -> Text -> Bool
`hasValueOf` Text
"=>" = let (Text
link, Text
desc) = (Char -> Bool) -> Text -> (Text, Text)
T.break Char -> Bool
isSpace forall a b. (a -> b) -> a -> b
$ Int -> Text -> Text
value Int
2 Text
line
                                in Text -> Maybe Text -> GemItem
GemLink Text
link forall a b. (a -> b) -> a -> b
$ Text -> Maybe Text
optional forall a b. (a -> b) -> a -> b
$ Text -> Text
T.strip Text
desc
    | Text
line Text -> Text -> Bool
`hasValueOf` Text
"#" = Text -> GemItem
parseHeading Text
line
    | Text
line Text -> Text -> Bool
`hasValueOf` Text
"* " = [Text] -> GemItem
GemList [Int -> Text -> Text
value Int
1 Text
line]
    | Text
line Text -> Text -> Bool
`hasValueOf` Text
">" = Text -> GemItem
GemQuote forall a b. (a -> b) -> a -> b
$ Int -> Text -> Text
value Int
1 Text
line
    | Bool
otherwise = Text -> GemItem
GemText forall a b. (a -> b) -> a -> b
$ Text -> Text
T.stripEnd Text
line
    where -- Parse a Gemtext heading.
          -- The max level of a heading is 3.
          parseHeading :: Text -> GemItem
          parseHeading :: Text -> GemItem
parseHeading Text
l
              | Text -> Bool
T.null forall a b. (a -> b) -> a -> b
$ Text -> Text
T.strip Text
text = Text -> GemItem
GemText forall a b. (a -> b) -> a -> b
$ Text -> Text
T.strip Text
l
              | Bool
otherwise = Int -> Text -> GemItem
GemHeading (forall a. Ord a => a -> a -> a
min (Text -> Int
T.length Text
pre) Int
3) forall a b. (a -> b) -> a -> b
$ Text -> Text
T.strip Text
text
              where (Text
pre, Text
text) = (Char -> Bool) -> Text -> (Text, Text)
T.span (forall a. Eq a => a -> a -> Bool
==Char
'#') Text
l


-- | Encode parsed 'GemDocument' to a @text/gemini@ file.
-- The output 'Text' uses LF-endings.
encode :: GemDocument -> Text
encode :: GemDocument -> Text
encode = [Text] -> Text
T.unlines forall b c a. (b -> c) -> (a -> b) -> a -> c
. forall (t :: * -> *) b a.
Foldable t =>
(b -> a -> b) -> b -> t a -> b
foldl' (\[Text]
acc GemItem
x -> [Text]
acc forall a. Semigroup a => a -> a -> a
<> [GemItem -> Text
encodeItem GemItem
x]) []

-- | Encode a /single/ parsed 'GemItem' as @text/gemini@ text.
-- The output 'Text' uses LF-endings and might be multiple lines.
--
-- /Beware/ that the final newline is always stripped, if any.
--
-- Notes:
--
--     * The text of the 'GemText' will follow a whitespace to escape the prefix
--     at the beginning if the line starts with a valid one.
--     * The link in the 'GemLink' should not have spaces.
encodeItem :: GemItem -> Text
encodeItem :: GemItem -> Text
encodeItem (GemText Text
line) = Text -> Text
escapePrefixes Text
line
encodeItem (GemLink Text
link Maybe Text
desc) = [Text] -> Text
T.unwords forall a b. (a -> b) -> a -> b
$ [Text
"=>", Text
link] forall a. Semigroup a => a -> a -> a
<> forall a. Maybe a -> [a]
maybeToList Maybe Text
desc
encodeItem (GemHeading Int
level Text
text) = [Text] -> Text
T.unwords [Int -> Text -> Text
T.replicate (forall a. Ord a => a -> a -> a
min Int
level Int
3) Text
"#", Text
text]
encodeItem (GemList [Text]
list) = Text -> Text
T.stripEnd forall b c a. (b -> c) -> (a -> b) -> a -> c
. [Text] -> Text
T.unlines forall a b. (a -> b) -> a -> b
$ forall a b. (a -> b) -> [a] -> [b]
map (Text -> Text -> Text
T.append Text
"* ") [Text]
list
encodeItem (GemQuote Text
text) = [Text] -> Text
T.unwords [Text
">", Text
text]
encodeItem (GemPre [Text]
text Maybe Text
alt) = Text -> Text
T.stripEnd forall b c a. (b -> c) -> (a -> b) -> a -> c
. [Text] -> Text
T.unlines forall a b. (a -> b) -> a -> b
$ [[Text] -> Text
T.concat ([Text
"```"] forall a. Semigroup a => a -> a -> a
<> forall a. Maybe a -> [a]
maybeToList Maybe Text
alt)] forall a. Semigroup a => a -> a -> a
<> [Text]
text forall a. Semigroup a => a -> a -> a
<> [Text
"```"]

-- Escape the line prefixes by adding a whitespace at the beginning for 'GemText'.
escapePrefixes :: Text -> Text
escapePrefixes :: Text -> Text
escapePrefixes Text
line = forall a. a -> a -> Bool -> a
bool Text
line (Char -> Text -> Text
T.cons Char
' ' Text
line) Bool
escape
    where escape :: Bool
escape = Text
"```" Text -> Text -> Bool
`T.isPrefixOf` Text
line Bool -> Bool -> Bool
||
                   forall (t :: * -> *) a. Foldable t => (a -> Bool) -> t a -> Bool
any (Text -> Text -> Bool
hasValueOf Text
line) [Text
"=>", Text
"* ", Text
">"] Bool -> Bool -> Bool
||
                   (Text
line Text -> Text -> Bool
`hasValueOf` Text
"#" Bool -> Bool -> Bool
&& let (Text
_, Text
text) = (Char -> Bool) -> Text -> (Text, Text)
T.span (forall a. Eq a => a -> a -> Bool
==Char
'#') Text
line
                                              in Bool -> Bool
not (Text -> Bool
T.null forall a b. (a -> b) -> a -> b
$ Text -> Text
T.strip Text
text))

-- @True@ if the the line has prefix /and/ a following value.
hasValueOf :: Text -> Text -> Bool
hasValueOf :: Text -> Text -> Bool
hasValueOf Text
line Text
prefix = Text
prefix Text -> Text -> Bool
`T.isPrefixOf` Text
line Bool -> Bool -> Bool
&&
                         Bool -> Bool
not (Text -> Bool
T.null forall a b. (a -> b) -> a -> b
$ Int -> Text -> Text
value (Text -> Int
T.length Text
prefix) Text
line)

-- Get the value of a line.
-- Removes the prefix and strips.
value :: Int -> Text -> Text
value :: Int -> Text -> Text
value Int
prefix = Text -> Text
T.strip forall b c a. (b -> c) -> (a -> b) -> a -> c
. Int -> Text -> Text
T.drop Int
prefix

-- @Nothing@ if the text is empty.
optional :: Text -> Maybe Text
optional :: Text -> Maybe Text
optional Text
text = if Text -> Bool
T.null Text
text then forall a. Maybe a
Nothing else forall a. a -> Maybe a
Just Text
text