lima-0.1.0.2: src/Converter.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# HLINT ignore "Redundant bracket" #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
-- | Functions to convert @Haskell@ to @Markdown@ and between @Literate Haskell@ (@.lhs@) and @Markdown@.
module Converter (hsToMd, mdToHs, lhsToMd, mdToLhs, Config (..), ConfigHsMd (..)) where
import Data.Default (Default)
import Data.Foldable (Foldable (..))
import Data.Function ((&))
import Data.List (isPrefixOf, isSuffixOf)
import Data.Yaml (FromJSON (..))
import Data.Yaml.Aeson (withObject, (.:), (.:?))
import GHC.Generics (Generic)
-- | App config.
newtype Config = Config {configHsMd :: Maybe ConfigHsMd} deriving (Generic, Default)
instance FromJSON Config where
parseJSON = withObject "Configs" (\v -> Config <$> v .:? "hs-md")
-- | Config for @Haskell@ to @Markdown@ converter.
newtype ConfigHsMd = ConfigHs2Md {specialComments :: [String]} deriving (Generic, Default)
instance FromJSON ConfigHsMd where
parseJSON =
withObject
"CommentsToIgnore"
(\v -> ConfigHs2Md <$> v .: "special-comments")
backticks_ :: Int -> String
backticks_ n = indentN n backticks
backticks :: String
backticks = "```"
haskellSnippet :: String
haskellSnippet = backticks ++ "haskell"
consoleSnippet :: String
consoleSnippet = backticks ++ "console"
chooseSnippetType :: String -> String
chooseSnippetType s
| s `startsWith` birdTrack = haskellSnippet
| otherwise = consoleSnippet
birdTrack :: String
birdTrack = "> "
reverseBirdTrack :: String
reverseBirdTrack = "< "
birdTracks :: [String]
birdTracks = [birdTrack, reverseBirdTrack]
-- | Convert @Literate Haskell@ to @Markdown@.
--
-- Convert @LHS@ birdtick style to @Markdown@, replacing the code marked by birdticks with @```haskell ... ```@.
lhsToMd :: String -> String
lhsToMd = unlines . convert "" . lines
where
convert :: String -> [String] -> [String]
convert prev []
| -- close code tags at the end
prev `startsWithAnyOf` birdTracks =
[backticks]
| otherwise = []
convert prev (h : t)
| -- check for Haskell code to start
-- insert newline above code block if needed
not (prev `startsWithAnyOf` birdTracks)
&& (h `startsWithAnyOf` birdTracks) =
(["" | prev /= ""]) ++ [chooseSnippetType h, drop 2 h] ++ rest
| -- check for code
h `startsWithAnyOf` birdTracks =
drop 2 h : rest
| -- check for code end, insert newline after code block if needed
prev `startsWithAnyOf` birdTracks =
[backticks] ++ (["" | h /= ""]) ++ [h] ++ rest
| h `startsWith` (' ' : birdTrack) =
drop 1 h : rest
| otherwise = shiftIfHeader h ++ rest
where
rest = convert h t
-- | Convert @Markdown@ file to @Literate Haskell@.
--
-- Replace code marked with @```haskell ...```@ with birdticks (@>@)
-- and code marked with @``` ... ```@ with reverse birdticks (@<@).
mdToLhs :: String -> String
mdToLhs = unlines . convert False False "" . lines
where
convert :: Bool -> Bool -> String -> [String] -> [String]
convert inHsCode inSample prev [] = []
convert inHsCode inSample prev (h : t)
| -- handle code block starts, add newline if needed
h == haskellSnippet =
(["" | prev /= ""]) ++ convert True False prev t
| -- handle code
inHsCode && h /= backticks =
("> " ++ h) : convert True False h t
| -- handle code sample
inSample && h /= backticks =
("< " ++ h) : convert False True h t
| -- move headings one space to right
isHeading h =
(' ' : h) : rest
| -- handle code and sample block ends
(inHsCode || inSample)
&& h == backticks =
(["" | null t || head t /= ""])
++ convert False False prev t
| -- handle sample block starts, add newline if needed
take 3 h == backticks =
(["" | prev /= ""]) ++ convert False True prev t
| -- handle quotes
h `startsWith` birdTrack =
(" >" ++ drop 1 h) : rest
| otherwise = h : rest
where
-- count headings
isHeading h = not (null (takeWhile (== '#') h))
rest = convert False False h t
startsWith :: String -> String -> Bool
startsWith = flip isPrefixOf
startsWithAnyOf :: String -> [String] -> Bool
startsWithAnyOf l = any (startsWith l)
shiftIfHeader :: String -> [String]
shiftIfHeader "" = [""]
shiftIfHeader (' ' : '#' : x) = ['#' : x]
shiftIfHeader x = [x]
_LIMA_DISABLE :: String
_LIMA_DISABLE = "LIMA_DISABLE"
_LIMA_ENABLE :: String
_LIMA_ENABLE = "LIMA_ENABLE"
_LIMA_INDENT :: String
_LIMA_INDENT = "LIMA_INDENT"
_LIMA_DEDENT :: String
_LIMA_DEDENT = "LIMA_DEDENT"
-- | Comments that should be ignored for some reason
--
-- FOURMOLU_DISABLE is ignored because it's a special comment and shouldn't be visible in a `.md`
specialCommentsDefault :: [String]
specialCommentsDefault = ["FOURMOLU_DISABLE", "FOURMOLU_ENABLE", _LIMA_INDENT, _LIMA_DEDENT]
endsWith :: String -> String -> Bool
endsWith = flip isSuffixOf
-- multi-line comments
mcOpen :: String
mcOpen = "{-"
mcOpenSpace :: String
mcOpenSpace = mcOpen ++ " "
-- | multiline comment closing symbols
mcClose :: String
mcClose = "-}"
mcCloseSpace :: String
mcCloseSpace = " -}"
dropEnd :: Int -> String -> String
dropEnd n s = reverse (drop n (reverse s))
backticksHs :: String
backticksHs = backticks ++ "haskell"
backticksHs_ :: Int -> String
backticksHs_ n = indentN n backticksHs
dropEmpties :: [String] -> [String]
dropEmpties = dropWhile (\x -> dropSpaces x == "")
-- Markdown comments
-- | Open a @Markdown@ comment
mdcOpen :: String
mdcOpen = "<!--"
-- | Close a @Markdown@ comment
mdcClose :: String
mdcClose = "-->"
mdcOpenSpace :: String
mdcOpenSpace = mdcOpen ++ " "
mdcCloseSpace :: String
mdcCloseSpace = " " ++ mdcClose
-- | Remove multiline comment marks from the beginning and end of a string
stripMC :: String -> String
stripMC x = dropEnd (length mcCloseSpace) (drop (length mcOpenSpace) x)
indentN :: Int -> String -> String
indentN x s = concat (replicate x " ") ++ s
-- | Convert @Haskell@ to @Markdown@.
--
-- Multi-line comments are copied as text blocks and @Haskell@ code is copied as @Haskell@ snippets.
hsToMd :: ConfigHsMd -> String -> String
hsToMd ConfigHs2Md{..} = unlines . dropWhile (== "") . reverse . (\x -> convert True False False 0 x []) . lines
where
specialComments_ = specialCommentsDefault ++ specialComments
convert :: Bool -> Bool -> Bool -> Int -> [String] -> [String] -> [String]
convert inLimaEnable inComments inSnippet indent_ (h : hs) res
| not inComments =
if
| -- disable
-- split a snippet
h == mcOpenSpace ++ _LIMA_DISABLE ++ mcCloseSpace ->
(convert False False False indent_ hs)
([mdcOpenSpace ++ _LIMA_DISABLE] ++ [backticks_ indent_ | inSnippet] ++ dropEmpties res)
| -- enable
h == mcOpenSpace ++ _LIMA_ENABLE ++ mcCloseSpace ->
convert True False inSnippet indent_ hs ((_LIMA_ENABLE ++ mdcCloseSpace) : res)
| -- disabled
not inLimaEnable ->
convert inLimaEnable False False indent_ hs (h : res)
| -- indentation comment
-- splits a snippet
h `startsWithAnyOf` ((mcOpenSpace ++) <$> [_LIMA_INDENT, _LIMA_DEDENT]) ->
let h_ = stripMC h
isIndent = h_ `startsWith` _LIMA_INDENT
newIndent_ = if isIndent then indent_ + (read @Int $ drop (length _LIMA_INDENT) h_) else 0
in (convert inLimaEnable False False newIndent_ hs)
( [mdcOpenSpace ++ stripMC h ++ mdcCloseSpace]
++ ["" | inSnippet]
++ [backticks_ indent_ | inSnippet]
++ (if isIndent then squashEmpties else dropEmpties) res
)
| -- a special comment
-- a comment should be in multi-line style like '{- FOURMOLU_DISABLE -}'
-- it should occupy a single line
-- it may be followed by arbitrary text in that line
-- splits a snippet
h `startsWithAnyOf` ((mcOpenSpace ++) <$> specialComments_) ->
(convert inLimaEnable False False indent_ hs)
([mdcOpenSpace ++ stripMC h ++ mdcCloseSpace] ++ ["" | inSnippet] ++ [backticks_ indent_ | inSnippet] ++ dropEmpties res)
| -- start of a multi-line comment
-- it should be either like '{- ...' or '{-\n'
(h `startsWith` mcOpenSpace || h == mcOpen) ->
let x' = drop 3 h
pref = "" : [backticks_ indent_ | inSnippet]
res' = if inSnippet then dropEmpties res else res
in -- if a multiline comment ends on the same line
-- it should end with '-}'
if h `endsWith` mcClose
then convert inLimaEnable False False indent_ hs ([dropEnd (length mcCloseSpace) x'] ++ pref ++ res')
else convert inLimaEnable True False indent_ hs ([x' | not (null x')] ++ pref ++ res')
| -- non-empty line means the start of a Haskell snippet
not inSnippet ->
if not (null h)
then -- non-empty line means the start of a Haskell snippet
convert inLimaEnable False True indent_ hs ([indentN indent_ h, backticksHs_ indent_] ++ squashEmpties res)
else -- if not in snippet, collapse consequent empty lines
convert inLimaEnable False False indent_ hs res
| inSnippet ->
convert inLimaEnable False True indent_ hs ((indentN indent_ h) : res)
| inComments =
if h `startsWith` mcClose
then -- end of a multiline comment
convert inLimaEnable False False indent_ hs (res)
else -- copy everything from comments
convert inLimaEnable True False indent_ hs (h : res)
convert _ _ inSnippet indent_ [] res =
[backticks_ indent_ | inSnippet] ++ dropEmpties res
stripMdc :: String -> String
stripMdc x = dropEnd (length mdcCloseSpace) (drop (length mdcOpenSpace) x)
squashEmpties :: [String] -> [String]
squashEmpties = ([""] ++) . dropEmpties
dropSpaces :: String -> String
dropSpaces = dropWhile (== ' ')
countSpaces :: String -> Int
countSpaces x = length $ takeWhile (== ' ') x
-- | Convert @Markdown@ to @Haskell@.
--
-- Multi-line comments are copied as text blocks and @Haskell@ code is copied as @Haskell@ snippets.
mdToHs :: ConfigHsMd -> String -> String
mdToHs ConfigHs2Md{..} = unlines . dropWhile (== "") . reverse . (\x -> convert False False False 0 x []) . lines
where
specialComments_ = specialCommentsDefault ++ specialComments
closeTextIf x = [mcClose | x]
convert :: Bool -> Bool -> Bool -> Int -> [String] -> [String] -> [String]
convert inText inSnippet inComments dedent_ (h : hs) res
| inComments =
-- enable
if h `startsWith` (_LIMA_ENABLE ++ mdcCloseSpace)
then -- split text
(convert inText inSnippet False dedent_ hs)
([mcOpenSpace ++ _LIMA_ENABLE ++ mcCloseSpace] ++ ["" | inText] ++ closeTextIf inText ++ res)
else -- copy lines
convert False False True dedent_ hs (h : res)
| not inSnippet =
if
| -- found comments
h `startsWith` (mdcOpenSpace ++ _LIMA_DISABLE) ->
(convert False False True dedent_ hs)
([mcOpenSpace ++ _LIMA_DISABLE ++ mcCloseSpace, ""] ++ closeTextIf inText ++ res)
| -- found a special comment
h `startsWithAnyOf` ((mdcOpenSpace ++) <$> specialComments_) ->
(convert False False False dedent_ hs)
([mcOpenSpace ++ stripMdc h ++ mcCloseSpace] ++ [""] ++ closeTextIf inText ++ dropEmpties res)
| -- start of a haskell snippet
(dropSpaces h) `startsWith` backticksHs ->
convert False True False (countSpaces h) hs ([""] ++ closeTextIf inText ++ dropEmpties res)
| not inText ->
if null h
then -- just a blank line
convert inText False False dedent_ hs res
else -- start of text
convert True False False dedent_ hs ([h, mcOpen, ""] ++ res)
| -- copy text line by line
otherwise ->
convert True False False dedent_ hs (h : res)
| otherwise =
if (dropSpaces h) == backticks
then -- end of a snippet
convert False False False 0 hs res
else -- in a snippet
convert False True False dedent_ hs ((drop dedent_ h) : res)
convert inText _ _ dedent_ [] res = [mcClose | inText] ++ res