packages feed

diohsc-0.1.1: TextGemini.hs

-- This file is part of Diohsc
-- Copyright (C) 2020 Martin Bays <mbays@sdf.org>
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of version 3 of the GNU General Public License as
-- published by the Free Software Foundation, or any later version.
--
-- You should have received a copy of the GNU General Public License
-- along with this program.  If not, see http://www.gnu.org/licenses/.

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE Safe #-}

module TextGemini where

import Control.Monad.State
import Data.Maybe (catMaybes, mapMaybe)

import ANSIColour

import qualified Data.Text.Lazy as T
import URI

data Link = Link { linkUri :: URIRef, linkDescription :: T.Text }
    deriving (Eq,Ord,Show)

data GeminiLine
    = TextLine T.Text
    | LinkLine { linkLineIndex :: Int, linkLineLink :: Link }
    | AltTextLine T.Text
    | PreformatToggleLine
    | PreformattedLine T.Text
    | HeadingLine Int T.Text
    | ItemLine T.Text
    | QuoteLine T.Text
    | ErrorLine T.Text
    deriving (Eq,Ord,Show)

newtype GeminiDocument = GeminiDocument { geminiDocumentLines :: [GeminiLine] }
    deriving (Eq,Ord,Show)

extractLinks :: GeminiDocument -> [Link]
extractLinks (GeminiDocument ls) = mapMaybe linkOfLine ls
    where
    linkOfLine (LinkLine _ link) = Just link
    linkOfLine _ = Nothing

data PreOpt
    = PreOptAlt
    | PreOptPre
    | PreOptBoth
    deriving (Eq,Ord,Show)

showPreOpt :: PreOpt -> String
showPreOpt PreOptAlt = "alt"
showPreOpt PreOptPre = "pre"
showPreOpt PreOptBoth = "both"

data GemRenderOpts = GemRenderOpts
    { grOptsAnsi :: Bool
    , grOptsPre :: PreOpt
    , grOptsWrapWidth :: Int
    } deriving (Eq,Ord,Show)

printGemDoc :: GemRenderOpts -> (URIRef -> T.Text) -> GeminiDocument -> [T.Text]
printGemDoc (GemRenderOpts ansi preOpt width)
        showUri (GeminiDocument ls) = concatMap printLine ls
    where
    printLine (TextLine line) = wrap width line
    printLine (LinkLine n link) = (:[]) $
        printGemLinkLine ansi showUri (n+1) link
    printLine (AltTextLine line)
        | preOpt == PreOptPre = []
        | preOpt == PreOptBoth && T.null line = []
        | otherwise = (:[]) $ applyIf ansi withBoldStr "`` " <> line

    -- |the spec says preformat toggle lines should not be rendered, but it's the most convenient 
    -- non-disruptive way to unambiguously indicate which lines are preformatted.
    printLine PreformatToggleLine = []

    printLine (PreformattedLine line)
        | preOpt == PreOptAlt = []
        | otherwise = (:[]) $ applyIf ansi ((resetCode <>) . withBoldStr) "` " <> line
    printLine (HeadingLine level line) = (:[]) $ if ansi
            then applyIf (level /= 2) withUnderlineStr .
                applyIf (level < 3) withBoldStr $ line
            else T.take (fromIntegral level) (T.repeat '#') <> " " <> line
    printLine (ItemLine line) = wrapWith "* " "  " line
    printLine (QuoteLine line) = wrapWith "> " "> " line
    printLine (ErrorLine line) = (:[]) $ applyIf ansi (withColourStr Red)
        "! Formatting error in text/gemini: " <> line

    wrapWith first subsequent line =
        zipWith (<>) lineHeaders $ wrap (width - fromIntegral (T.length first)) line
        where lineHeaders = map (applyIf ansi withBoldStr) $ first : repeat subsequent

    wrap :: Int -> T.Text -> [T.Text]
    wrap wrapWidth line = wrap' "" 0 $ T.words line
        where
        wrap' l _ [] = [l]
        wrap' l n (w:ws) =
            let l' = if T.null l then w else l <> " " <> w
                nw = visibleLength w
                n' = n + nw + (if T.null l then 0 else 1)
            in if n' > wrapWidth
                then l : wrap' w nw ws
                else wrap' l' n' ws

printGemLinkLine :: Bool -> (URIRef -> T.Text) -> Int -> Link -> T.Text
printGemLinkLine ansi showUri n (Link uri desc) =
    applyIf ansi withBoldStr (T.pack $ '[' : show n ++ "]")
    <> " "
    <> showUri uri
    <> (if T.null desc then "" else
        applyIf ansi (withColourStr Cyan) $ " " <> desc)

data GeminiParseState = GeminiParseState { numLinks :: Int, preformatted :: Bool }

initialParseState :: GeminiParseState
initialParseState = GeminiParseState 0 False

parseGemini :: T.Text -> GeminiDocument
parseGemini text = GeminiDocument . catMaybes $ evalState
        (forM (T.lines text) (parseLine . stripTrailingCR)) initialParseState
    where
    stripTrailingCR = T.dropWhileEnd (== '\r')
    parseLine :: T.Text -> State GeminiParseState (Maybe GeminiLine)
    parseLine line = do
        pre <- gets preformatted
        if T.take 3 line == "```" then do
            modify $ \s -> s { preformatted = not pre }
            return $ if pre then case T.strip $ T.drop 3 line of
                "" -> Nothing
                _ -> Just . ErrorLine $ "Illegal non-empty text after closing '```'"
                -- ^The spec says we MUST ignore any text on a "```" line closing a preformatted 
                -- block. This seems like a gaping extensibility hole to me, so I'm interpreting it 
                -- as not disallowing an error message.
            else Just . AltTextLine . T.strip $ T.drop 3 line
        else if pre then return . Just $ PreformattedLine line
        else if T.take 2 line == "=>" then
            case parseLink . T.dropWhile isGemWhitespace $ T.drop 2 line of
                Nothing -> return . Just . ErrorLine $ "Unparseable link line: " <> line
                Just link -> do
                    n <- gets numLinks
                    modify $ \s -> s { numLinks = n + 1 }
                    return . Just $ LinkLine n link
        else let headers = T.length . T.takeWhile (== '#') $ line
            in if headers > 0 && headers < 4
            then return . Just . HeadingLine (fromIntegral headers) .
                T.dropWhile isGemWhitespace . T.dropWhile (== '#') $ line
        else if T.take 2 line == "* "
            then return . Just . ItemLine $ T.drop 2 line
        else if T.take 1 line == ">"
            then return . Just . QuoteLine $ T.drop 1 line
        else return . Just $ TextLine line

    parseLink :: T.Text -> Maybe Link
    parseLink linkInfo =
        let uriText = T.takeWhile (not . isGemWhitespace) linkInfo
            desc = T.dropWhile isGemWhitespace . T.dropWhile (not . isGemWhitespace) $ linkInfo
        in (`Link` desc) <$> parseUriReference (T.unpack uriText)

    isGemWhitespace :: Char -> Bool
    isGemWhitespace = (`elem` (" \t"::String))