packages feed

swarm-0.5.0.0: src/Swarm/Doc/Schema/Render.hs

{-# LANGUAGE OverloadedStrings #-}

-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Render a markdown document fragment
-- from the Scenario JSON schema files.
module Swarm.Doc.Schema.Render where

import Control.Arrow (left, (&&&))
import Control.Monad.Except (runExceptT)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (except)
import Data.Aeson
import Data.List (intersperse)
import Data.Map (Map)
import Data.Map.Strict qualified as M
import Data.Maybe (fromMaybe)
import Data.Scientific (FPFormat (..), Scientific, formatScientific)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Vector qualified as V
import Swarm.Doc.Schema.Arrangement
import Swarm.Doc.Schema.Parse
import Swarm.Doc.Schema.Refined
import Swarm.Doc.Schema.SchemaType
import Swarm.Doc.Util
import Swarm.Util (applyWhen, brackets, quote, showT)
import System.Directory (listDirectory)
import System.FilePath (splitExtension, (<.>), (</>))
import Text.Pandoc
import Text.Pandoc.Builder
import Text.Pandoc.Walk (query)

scenariosDir :: FilePath
scenariosDir = "data/scenarios"

docFragmentsDir :: FilePath
docFragmentsDir = scenariosDir </> "doc-fragments"

schemasDir :: FilePath
schemasDir = "data/schema"

schemaExtension :: String
schemaExtension = ".json"

propertyColumnHeadings :: [T.Text]
propertyColumnHeadings =
  [ "Key"
  , "Default?"
  , "Type"
  , "Description"
  ]

listColumnHeadings :: [T.Text]
listColumnHeadings =
  [ "Index"
  , "Type"
  , "Description"
  ]

makeTitleMap :: [SchemaData] -> Map SchemaIdReference T.Text
makeTitleMap = M.fromList . map (fromFilePath . schemaPath &&& title . schemaContent)

makePandocTable :: Map SchemaIdReference T.Text -> SchemaData -> Pandoc
makePandocTable titleMap (SchemaData _ (ToplevelSchema theTitle theDescription _schema theMembers _) parsedFooters) =
  setTitle (text "JSON Schema for Scenarios") $
    doc (header 3 (text theTitle))
      <> fromMaybe mempty theDescription
      <> maybe mempty mkTable theMembers
      <> mconcat parsedFooters
 where
  renderItems someStuff = case someStuff of
    ItemType x -> plain $ text "List of " <> listToText titleMap (schemaType x)
    ItemList xs ->
      makePropsTable False listColumnHeadings titleMap
        . M.fromList
        $ zip (map tshow [0 :: Int ..]) xs

  mkTable x = doc $ case x of
    ObjectProperties props -> makePropsTable True propertyColumnHeadings titleMap props
    ListMembers someStuff -> renderItems someStuff

genPropsRow :: Bool -> Map SchemaIdReference T.Text -> (T.Text, SwarmSchema) -> [Blocks]
genPropsRow includeDefaultColumn titleMap (k, x) =
  firstColumn : applyWhen includeDefaultColumn (defaultColumn :) tailColumns
 where
  firstColumn = plain $ code k
  defaultColumn = maybe mempty (plain . code . renderValue) $ defaultValue x
  tailColumns =
    [ plain . listToText titleMap $ schemaType x
    , fromList $ maybe [] (query id) $ objectDescription x
    ]

makePropsTable ::
  Bool ->
  [T.Text] ->
  Map SchemaIdReference T.Text ->
  Map T.Text SwarmSchema ->
  Blocks
makePropsTable includeDefaultColumn headingsList titleMap =
  simpleTable headerRow . map (genPropsRow includeDefaultColumn titleMap) . M.toList
 where
  headerRow = map (plain . text) headingsList

type FileStemAndExtension = (FilePath, String)

recombineExtension :: FileStemAndExtension -> FilePath
recombineExtension (filenameStem, fileExtension) =
  filenameStem <.> fileExtension

genMarkdown :: [SchemaData] -> Either T.Text T.Text
genMarkdown schemaThings =
  left renderError $
    runPure $
      writeMarkdown (def {writerExtensions = extensionsFromList [Ext_pipe_tables]}) pd
 where
  titleMap = makeTitleMap schemaThings
  pd =
    mconcat $
      map (makePandocTable titleMap) $
        sortAndPruneSchemas (fromFilePath "scenario") schemaThings

parseSchemaFile :: FileStemAndExtension -> IO (Either T.Text ToplevelSchema)
parseSchemaFile stemAndExtension =
  left (prependPath . T.pack) <$> eitherDecodeFileStrict fullPath
 where
  prependPath = ((T.unwords ["in", quote (T.pack filename)] <> ": ") <>)
  filename = recombineExtension stemAndExtension
  fullPath = schemasDir </> filename

loadFooterContent :: (FilePath, ToplevelSchema) -> IO SchemaData
loadFooterContent (fp, schem) = do
  xs <- mapM (TIO.readFile . (scenariosDir </>)) $ footerPaths schem
  parsedFooters <- mapM getMarkdown xs
  return $
    SchemaData
      fp
      schem
      parsedFooters

genScenarioSchemaDocs :: IO ()
genScenarioSchemaDocs = do
  dirContents <- listDirectory schemasDir
  let inputFiles = filter ((== schemaExtension) . snd) $ map splitExtension dirContents
  xs <- mapM (sequenceA . (recombineExtension &&& parseSchemaFile)) inputFiles

  result <- runExceptT $ do
    schemaTuples <- except $ traverse sequenceA xs
    things <- liftIO $ mapM loadFooterContent schemaTuples
    myMarkdown <- except $ genMarkdown things
    docHeader <- liftIO $ TIO.readFile "data/scenarios/doc-fragments/header.md"
    liftIO . writeFile (docFragmentsDir </> "SCHEMA.md") . T.unpack $ docHeader <> myMarkdown

  case result of
    Left e -> print $ unwords ["Failed:", T.unpack e]
    Right _ -> return ()

renderValue :: Value -> T.Text
renderValue = \case
  Object obj -> showT obj
  Array arr -> brackets . T.intercalate ", " . map renderValue $ V.toList arr
  String t -> quote t
  Number num -> T.pack $ formatNumberCompact num
  Bool b -> showT b
  Null -> "null"

fragmentHref :: Map SchemaIdReference T.Text -> SchemaIdReference -> T.Text
fragmentHref titleMap r@(SchemaIdReference ref) =
  T.cons '#' . T.toLower . T.replace " " "-" $ x
 where
  x = M.findWithDefault ref r titleMap

listToText :: Map SchemaIdReference T.Text -> SchemaType -> Inlines
listToText titleMap = \case
  Simple xs -> renderAlternatives $ map code $ getList xs
  Alternatives xs -> renderAlternatives $ map (listToText titleMap) xs
  Reference r@(SchemaIdReference x) -> schemaLink r x
  ListOf x -> listToText titleMap x <> text " list"
 where
  renderAlternatives = mconcat . intersperse (text " or ")
  schemaLink r = link (fragmentHref titleMap r) "Link to object properties" . text

-- |
-- Strips trailing zeros and decimal point from a floating-point number
-- when possible.
--
-- Obtained from here: https://stackoverflow.com/a/35980995/105137
formatNumberCompact :: Scientific -> String
formatNumberCompact v
  | v == 0 = "0"
  | abs v < 1e-5 || abs v > 1e10 = formatScientific Exponent Nothing v
  | v - fromIntegral (floor v :: Integer) == 0 = formatScientific Fixed (Just 0) v
  | otherwise = formatScientific Generic Nothing v