packages feed

hyperbole-0.7.1: docs/Docgen.hs

module Main where

import Control.Exception (SomeException, try)
import Control.Monad (when)
import Data.Char (isAlpha, isSpace)
import Data.String.Conversions (cs)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Effectful
import Effectful.State.Static.Local
import System.Directory
import System.Environment (getArgs)
import System.FilePath


-- Manually run this on the entire repository
-- main :: IO ()
-- main = do
--   args <- getArgs
--   case args of
--     [original, input, output] -> do
--         src <- readSource input
--         expanded <- expandFile src
--         let linePragma = T.pack $ "{-# LINE 1 \"" ++ original ++ "\" #-}"
--         let final = SourceCode [linePragma] <> expanded
--         T.writeFile output $ T.unlines final.lines
--     _ -> error "Usage (Hyperbole Internal Only): docgen src/MyModule.hs /tmp/input/MyModule.hs /build/output/Original.hs"

main :: IO ()
main = do
  args <- getArgs
  files <- case args of
    [] -> do
      putStrLn "Docgen: all sources"
      relativeSourceFiles "./src"
    fs -> do
      pure fs

  mapM_ processFile files
 where
  processFile :: FilePath -> IO ()
  processFile file = do
    putStrLn $ "Docgen: " <> file
    src <- readSourceCode file
    src' <- expandFile src
    T.writeFile file src'.contents
    pure ()


relativeSourceFiles :: FilePath -> IO [FilePath]
relativeSourceFiles dir = do
  contents <- tryDirectory dir
  let folders = filter isFolder contents
  let files = filter isSourceFile contents

  files' <- mapM (relativeSourceFiles . addDir) folders

  pure $ fmap addDir files <> mconcat files'
 where
  isSourceFile pth = takeExtension pth == ".hs"
  isFolder pth = takeExtension pth == ""
  addDir = (dir </>)
  tryDirectory pth = do
    res <- try $ listDirectory pth
    case res of
      Left (_ :: SomeException) -> do
        putStrLn $ "SKIPPED" <> pth
        pure []
      Right files -> pure files


srcDirectory :: FilePath
srcDirectory = "./src"


demoDirectory :: FilePath
demoDirectory = "./demo"


-- expandFile :: SourceCode -> IO SourceCode
-- expandFile (SourceCode src) =
--   case T.breakOn "{-$" src of
--     (end, "") -> pure $ SourceCode end
--     (start, rest) -> do
--       let (commentFragment, restInner) = T.breakOn "-}" rest
--       let next = T.drop 2 restInner -- drop the comment chars
--       let comment = commentFragment <> "-}"
--       pure SourceCode start <> next

expandFile :: SourceCode -> IO SourceCode
expandFile src = do
  parsed <- parseSource src

  -- putStrLn "\nPARSED"
  -- putStrLn "============"
  -- mapM_ print parsed

  expanded <- mconcat <$> mapM expandBlock parsed

  -- putStrLn "\nEXPANDED"
  -- putStrLn "============"
  -- mapM_ print expanded

  -- let final = dropOldHaddockBlocks expanded
  -- putStrLn "\nFINAL"
  -- putStrLn "============"
  -- mapM_ print final

  -- pure $ allToSourceCode $ dropOldHaddockBlocks final
  pure $ allToSourceCode expanded


-- dropOldHaddockBlocks :: [ParsedSource] -> [ParsedSource]
-- dropOldHaddockBlocks ps =
--   case ps of
--     [] -> []
--     (CommentTarget t : OtherCode newlines : HaddockBlock h1 : OtherCode _ : HaddockBlock _ : rest) ->
--       CommentTarget t : OtherCode newlines : HaddockBlock h1 : rest
--     (ps1 : rest) -> ps1 : dropOldHaddockBlocks rest

expandBlock :: ParsedSource -> IO [ParsedSource]
expandBlock p =
  case p of
    OtherCode t -> pure [OtherCode t]
    HaddockBlock t -> pure [HaddockBlock t]
    CommentTarget t -> do
      haddock <- haddockBlock t
      pure [CommentTarget t, OtherCode "\n\n", haddock]
 where
  haddockBlock :: Text -> IO ParsedSource
  haddockBlock t = do
    let lns = T.lines t
    out <- mconcat <$> mapM expandLine lns
    pure $ HaddockBlock $ "{- |" <> T.drop 4 (T.dropWhileEnd (== '\n') (T.unlines out)) <> "\n"


allToSourceCode :: [ParsedSource] -> SourceCode
allToSourceCode = mconcat . fmap toSourceCode


data ParsedSource
  = OtherCode Text
  | CommentTarget Text
  | HaddockBlock Text
  deriving (Show, Eq)


toSourceCode :: ParsedSource -> SourceCode
toSourceCode p =
  case p of
    OtherCode t -> SourceCode t
    CommentTarget t -> SourceCode t
    HaddockBlock t -> SourceCode t


parseSource :: SourceCode -> IO [ParsedSource]
parseSource sourceCode =
  runParser sourceCode parseFile
 where
  runParser :: SourceCode -> Eff [State SourceCode, IOE] [ParsedSource] -> IO [ParsedSource]
  runParser src = runEff . evalState src

  parseFile :: (State SourceCode :> es) => Eff es [ParsedSource]
  parseFile = do
    ocs <- parseOtherCode
    aps <- parseAllConcat $ do
      cmts <- parseCommentTarget
      next <- parseOtherCode
      pure $ cmts <> next
    pure $ ocs <> aps

  parseOtherCode :: (State SourceCode :> es) => Eff es [ParsedSource]
  parseOtherCode = do
    code <- takeUntil commentTargetMarker
    case code of
      "" -> pure []
      _ -> pure [OtherCode code]

  parseAllConcat :: (State SourceCode :> es) => Eff es [a] -> Eff es [a]
  parseAllConcat parser = do
    empty <- isEmpty
    if empty
      then pure []
      else do
        as <- parser
        next <- parseAllConcat parser
        pure $ as <> next

  -- only when we know a comment target is there?
  parseCommentTarget :: (State SourceCode :> es) => Eff es [ParsedSource]
  parseCommentTarget = do
    comment <- takeIncluding commentEnd
    skipWhitespace
    skipHaddock -- skip any haddock immediately following this
    skipWhitespace
    pure [CommentTarget comment]

  isEmpty :: (State SourceCode :> es) => Eff es Bool
  isEmpty = do
    SourceCode src <- get
    pure $ T.null src

  takeUntil :: (State SourceCode :> es) => Text -> Eff es Text
  takeUntil match = do
    SourceCode input <- get
    let (before, after) = T.breakOn match input
    put $ SourceCode after
    pure before

  takeIncluding :: (State SourceCode :> es) => Text -> Eff es Text
  takeIncluding match = do
    SourceCode input <- get
    let (before, after) = T.breakOn match input
    put $ SourceCode $ T.drop (T.length match) after
    -- only add it back to the before if we had a match
    case after of
      "" -> pure before
      _ -> pure $ before <> match

  skipWhitespace :: (State SourceCode :> es) => Eff es ()
  skipWhitespace = do
    modify (\(SourceCode t) -> SourceCode $ T.stripStart t)

  skipHaddock :: (State SourceCode :> es) => Eff es ()
  skipHaddock = do
    SourceCode src <- get
    when (haddockMarker `T.isPrefixOf` src) $ do
      _ <- takeIncluding commentEnd
      pure ()


commentTargetMarker :: Text
commentTargetMarker = "{- !"


commentEnd :: Text
commentEnd = "-}"


haddockMarker :: Text
haddockMarker = "{- |"


readSource :: FilePath -> IO SourceCode
readSource pth = do
  inp <- T.readFile pth
  pure $ SourceCode inp


writeSource :: FilePath -> FilePath -> SourceCode -> IO ()
writeSource tmpDir relPath src = do
  let pth = tmpDir </> cleanRelativeDir relPath
  -- putStrLn $ "WRITE " <> pth <> " " <> show (length src.lines)
  createDirectoryIfMissing True $ takeDirectory pth
  T.writeFile pth src.contents
 where
  cleanRelativeDir =
    dropWhile (== '/') . dropWhile (== '.')


data Macro
  = Embed
  { moduleName :: ModuleName
  , definition :: TopLevelDefinition
  }
  deriving (Eq)
newtype SourceCode = SourceCode {contents :: Text}
  deriving newtype (Monoid, Semigroup)
instance Show Macro where
  -- show (Example p) = "Example " <> show p
  show (Embed mn def) = "Embed " <> show mn <> " " <> show def


newtype ModuleName = ModuleName Text
  deriving newtype (Eq, Show)


newtype TopLevelDefinition = TopLevelDefinition Text
  deriving newtype (Show, Eq)


-- > EMBED Example/Docs/BasicPage.hs page
expandLine :: Text -> IO [Text]
expandLine line = do
  case parseMacro line of
    Nothing -> do
      pure [line]
    Just (pre, Embed src def) -> do
      exists <- doesDirectoryExist demoDirectory
      if exists
        then expandEmbed src pre def
        else pure []
 where
  parseEmbed l = do
    case T.splitOn "#EMBED " l of
      [prefix, info] -> do
        (mn, definition) <- splitSrcDef $ T.dropWhile (== ' ') info
        pure (prefix, Embed mn definition)
      _ -> Nothing

  splitSrcDef inp =
    let (mn, def) = T.breakOn " " inp
     in pure (ModuleName mn, TopLevelDefinition $ T.drop 1 def)

  parseMacro :: Text -> Maybe (Text, Macro)
  parseMacro inp = do
    parseEmbed inp -- <|> parseExample inp


modulePath :: ModuleName -> FilePath
modulePath (ModuleName mn) = cs $ T.replace "." "/" mn <> ".hs"


readSourceCode :: FilePath -> IO SourceCode
readSourceCode p = do
  src <- T.readFile p
  pure $ SourceCode src


expandEmbed :: ModuleName -> Text -> TopLevelDefinition -> IO [Text]
expandEmbed mn pfx def = do
  let src = modulePath mn
  -- putStrLn $ "  embed: " <> src
  source <- readSourceCode $ demoDirectory </> src
  expanded <- requireTopLevel def source
  pure $ fmap markupLine expanded
 where
  requireTopLevel :: TopLevelDefinition -> SourceCode -> IO [Text]
  requireTopLevel tld sc =
    case findTopLevel tld sc of
      [] -> fail $ "Could not find: " <> show (Embed mn def) <> " " <> show def
      lns -> pure lns

  -- addPrefix line = embed.prefix <> line
  markupLine :: Text -> Text
  markupLine line =
    case pfx of
      "" -> markupLineAt line
      _ -> markupLinePrefix line
  markupLineAt =
    T.replace "\"" "\\\"" . highlightTermsLine
  markupLinePrefix line =
    pfx <> line


highlightTermsLine :: Text -> Text
highlightTermsLine ln = mconcat $ fmap highlightWord $ T.groupBy isSameTerm ln
 where
  isSameTerm :: Char -> Char -> Bool
  isSameTerm c1 c2 =
    (isAlpha c1 && isAlpha c2)
      || (isSpace c1 && isSpace c2)

  highlightWord :: Text -> Text
  highlightWord w =
    if w `elem` terms
      then "'" <> w <> "'"
      else w

  terms :: [Text]
  terms =
    [ "HyperView"
    , "View"
    , "Action"
    , "update"
    , "hyper"
    , "Page"
    , "liveApp"
    , "quickStartDocument"
    , "runPage"
    , "run"
    , "ViewId"
    , "viewId"
    , "ViewAction"
    , "Eff"
    , "button"
    , "el"
    , "el_"
    , "Hyperbole"
    , "Route"
    , "routeRequest"
    , "route"
    , "layout"
    , "Response"
    , "ToParam"
    , "FromParam"
    , "Session"
    , "FromQuery"
    , "ToQuery"
    , "lookupParam"
    , "setParam"
    , "DefaultParam"
    , "Client"
    ]


-- returns lines of a top-level definition
findTopLevel :: TopLevelDefinition -> SourceCode -> [Text]
findTopLevel (TopLevelDefinition definition) source =
  let rest = dropWhile (not . isTopLevel) $ T.lines source.contents
   in dropWhileEnd isEmpty $ takeWhile isCurrentDefinition rest
 where
  isTopLevel = T.isPrefixOf definition
  isEmpty = T.null
  -- isBlankLine line = T.null $ T.strip line
  isCurrentDefinition line =
    isTopLevel line || not (isFullyOutdented line)
  dropWhileEnd p as =
    reverse $ dropWhile p $ reverse as


isFullyOutdented :: Text -> Bool
isFullyOutdented line =
  case cs (T.take 1 line) of
    "" -> False
    [c] -> not $ isSpace c
    _ -> False