packages feed

pandoc-lua-engine-0.1: src/Text/Pandoc/Lua/Writer/Scaffolding.hs

{-# LANGUAGE LambdaCase          #-}
{-# LANGUAGE OverloadedStrings   #-}
{- |
   Module      : Text.Pandoc.Lua.Writer.Scaffolding
   Copyright   : © 2022-2023 Albert Krewinkel
   License     : GPL-2.0-or-later
   Maintainer  : Albert Krewinkel <pandoc@tarleb.com>

Conversion of Pandoc documents using a custom Lua writer.
-}
module Text.Pandoc.Lua.Writer.Scaffolding
  ( pushWriterScaffolding
  ) where

import Control.Monad ((<$!>), void)
import Data.ByteString (ByteString)
import Data.Data (dataTypeConstrs, dataTypeOf, showConstr, toConstr)
import Data.Default (def)
import Data.List (intersperse)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.String (IsString (fromString))
import HsLua
import HsLua.Module.DocLayout (peekDoc, pushDoc)
import Text.DocLayout (Doc, blankline, render)
import Text.DocTemplates (Context)
import Text.Pandoc.Definition
import Text.Pandoc.Error (PandocError (..))
import Text.Pandoc.Options (WriterOptions (..), WrapOption(..))
import Text.Pandoc.Lua.PandocLua ()
import Text.Pandoc.Lua.Marshal.AST
import Text.Pandoc.Lua.Marshal.Context (peekContext)
import Text.Pandoc.Lua.Marshal.WriterOptions ( peekWriterOptions
                                             , pushWriterOptions)
import Text.Pandoc.Templates (renderTemplate)
import Text.Pandoc.Writers.Shared (metaToContext, setField)
import qualified Data.Text as T
import qualified Text.Pandoc.UTF8 as UTF8

-- | Convert Pandoc to custom markup.
pushWriterScaffolding :: LuaE PandocError NumResults
pushWriterScaffolding = do
  newtable
    *> pushWriterMT *> setmetatable (nth 2)
  writer <- toWriterTable top
  addField "Blocks"  $ pushDocumentedFunction (blocksFn writer)
  addField "Inlines" $ pushDocumentedFunction (inlinesFn writer)
  addField "Block"   $ newtable *> pushBlockMT  writer *> setmetatable (nth 2)
  addField "Inline"  $ newtable *> pushInlineMT writer *> setmetatable (nth 2)
  addField "Pandoc"  $ pushDocumentedFunction $ lambda
    ### (\(Pandoc _ blks) -> do
            pushWriterTable writer
            getfield' top "Blocks"
            pushBlocks blks
            callTrace 1 1
            pure (NumResults 1))
    <#> parameter peekPandoc "Pandoc" "doc" ""
    =?> "rendered doc"
  freeWriter writer
  return 1
 where
  blocksFn w = lambda
    ### (\blocks msep -> blockListToCustom w msep blocks)
    <#> parameter peekBlocks "Blocks" "blocks" ""
    <#> opt (parameter peekDocFuzzy "Doc" "sep" "")
    =#> functionResult pushDoc "Doc" ""
  inlinesFn w = lambda
    ### inlineListToCustom w
    <#> parameter peekInlines "Inlines" "inlines" ""
    =#> functionResult pushDoc "Doc" ""
  pushBlockMT writer = do
    newtable
    addField "__call" $ pushDocumentedFunction $ lambda
      ### blockToCustom
      <#> parameter peekWriter "table" "writer" ""
      <#> parameter peekBlockFuzzy "Block" "block" ""
      =#> functionResult pushDoc "Doc" "rendered blocks"
    addField "__index" $
      -- lookup missing fields in the main Writer table
      pushWriterTable writer
  pushInlineMT writer = do
    newtable
    addField "__call" $ pushDocumentedFunction $ lambda
      ### inlineToCustom
      <#> parameter peekWriter "table" "writer" ""
      <#> parameter peekInlineFuzzy "Inline" "inline" ""
      =#> functionResult pushDoc "Doc" "rendered inline"
    addField "__index" $ do
      -- lookup missing fields in the main Writer table
      pushWriterTable writer

pushWriterMT :: LuaE PandocError ()
pushWriterMT = do
  newtable
  addField "__call" $ pushDocumentedFunction $ lambda
    ### (\writer doc mopts -> runWriter writer doc mopts)
    <#> parameter peekWriter "table" "writer" ""
    <#> parameter peekPandoc "Pandoc" "doc" ""
    <#> opt (parameter peekWriterOptions "WriterOptions" "opts" "")
    =#> functionResult pushText "string" "rendered document"
  addField "__index" . pushDocumentedFunction $ lambda
    ### (\_writer key -> handleMissingField key)
    <#> parameter pure "table"  "writer" ""
    <#> parameter (liftLua . tostring') "string" "key" ""
    =#> functionResult (const pushnil) "string" ""


addField :: LuaError e => Name -> LuaE e a -> LuaE e ()
addField name action = do
  pushName name
  action
  rawset (nth 3)

getfield' :: LuaError e => StackIndex -> Name -> LuaE e HsLua.Type
getfield' idx name = do
  aidx <- absindex idx
  pushName name
  rawget aidx >>= \case
    TypeNil -> pop 1 *> getfield aidx name
    ty      -> pure ty

-- | A writer table is just an absolute stack index.
newtype WriterTable = WriterTable Reference

toWriterTable :: LuaError e => StackIndex -> LuaE e WriterTable
toWriterTable idx = WriterTable <$!> do
  pushvalue idx
  ref registryindex

peekWriter :: LuaError e => Peeker e WriterTable
peekWriter = liftLua . toWriterTable

pushWriterTable :: LuaError e => Pusher e WriterTable
pushWriterTable (WriterTable wref) = void $ getref registryindex wref

writerOptionsField :: Name
writerOptionsField = "Pandoc Writer WriterOptions"

freeWriter :: WriterTable -> LuaE e ()
freeWriter (WriterTable wref) = unref registryindex wref

pushOpts :: LuaE PandocError ()
pushOpts = void $ getfield' registryindex writerOptionsField

runWriter :: WriterTable -> Pandoc -> Maybe WriterOptions
          -> LuaE PandocError Text
runWriter writer doc@(Pandoc meta _blks) mopts = do
  let opts = fromMaybe def mopts
  pushWriterOptions opts *>
    setfield registryindex writerOptionsField

  (body, mcontext) <- runPeek (pandocToCustom writer doc) >>= force . \case
    Failure msg contexts -> Failure (cleanupTrace msg) contexts
    s -> s

  -- convert metavalues to a template context (variables)
  defaultContext <- metaToContext opts
                    (blockListToCustom writer Nothing)
                    (inlineListToCustom writer)
                    meta
  let context = setField "body" body
              $ fromMaybe defaultContext mcontext

  let colwidth = if writerWrapText opts == WrapAuto
                    then Just $ writerColumns opts
                    else Nothing

  return $ render colwidth $
    case writerTemplate opts of
       Nothing  -> body
       Just tpl -> renderTemplate tpl context

-- | Keep exactly one traceback and clean it up. This wouldn't be
-- necessary if the @pcallTrace@ function would do nothing whenever the
-- error already included a trace, but that would require some bigger
-- changes; removing the additional traces in this post-process step is
-- much easier (for now).
cleanupTrace :: ByteString -> ByteString
cleanupTrace msg = UTF8.fromText . T.intercalate "\n" $
  let tmsg = T.lines $ UTF8.toText msg
      traceStart = (== "stack traceback:")
  in case break traceStart tmsg of
        (x, t:traces) -> (x <>) . (t:) $
                         let (firstTrace, rest) = break traceStart traces
                             isPeekContext = ("\twhile " `T.isPrefixOf`)
                             isUnknownCFn = (== "\t[C]: in ?")
                         in filter (not . isUnknownCFn) firstTrace <>
                            filter isPeekContext rest
        _ -> tmsg

-- | Pushes the field in the writer table.
getWriterField :: LuaError e
               => WriterTable -> Name -> LuaE e HsLua.Type
getWriterField writer name = do
  pushWriterTable writer
  getfield' top name <* remove (nth 2)

-- | Looks up @Writer.subtable.field@; tries @Writer.field@ as a fallback if the
-- subtable field is @nil@.
getNestedWriterField :: LuaError e
                     => WriterTable -> Name -> Name -> LuaE e HsLua.Type
getNestedWriterField writer subtable field = do
  pushWriterTable writer
  getfield' top subtable >>= \case
    TypeNil -> TypeNil <$ remove (nth 2) -- remove Writer table
    _       -> getfield' top field
               -- remove Writer and subtable
               <* remove (nth 3) <* remove (nth 2)

pandocToCustom :: WriterTable -> Pandoc
               -> Peek PandocError (Doc Text, Maybe (Context Text))
pandocToCustom writer doc = withContext "rendering Pandoc" $ do
  callStatus <- liftLua $ do
    getWriterField writer "Pandoc"
    pushPandoc doc
    pushOpts
    pcallTrace 2 2
  case callStatus of
    OK -> ((,) <$> peekDocFuzzy (nth 2) <*> orNil peekContext top)
          `lastly` pop 2
    _  -> failPeek =<< liftLua (tostring' top)

blockToCustom :: WriterTable -> Block -> LuaE PandocError (Doc Text)
blockToCustom writer blk = forcePeek $ renderBlock writer blk

renderBlock :: WriterTable -> Block -> Peek PandocError (Doc Text)
renderBlock writer blk = do
  let constrName = fromString . showConstr . toConstr $ blk
  withContext ("rendering Block `" <> constrName <> "`") $
    liftLua (getNestedWriterField writer "Block" constrName) >>= \case
      TypeNil -> failPeek =<< typeMismatchMessage "function or Doc" top
      _       -> callOrDoc (pushBlock blk)

inlineToCustom :: WriterTable -> Inline -> LuaE PandocError (Doc Text)
inlineToCustom writer inln = forcePeek $ renderInline writer inln

renderInline :: WriterTable -> Inline -> Peek PandocError (Doc Text)
renderInline writer inln = do
  let constrName = fromString . showConstr . toConstr $ inln
  withContext ("rendering Inline `" <> constrName <> "`") $ do
    liftLua (getNestedWriterField writer "Inline" constrName) >>= \case
      TypeNil -> failPeek =<< typeMismatchMessage "function or Doc" top
      _       -> callOrDoc (pushInline inln)

-- | If the value at the top of the stack can be called as a function,
-- then push the element and writer options to the stack and call it;
-- otherwise treat it as a plain Doc value
callOrDoc :: LuaE PandocError ()
          -> Peek PandocError (Doc Text)
callOrDoc pushElement = do
  liftLua (ltype top) >>= \case
    TypeFunction -> peekCall
    _            ->
      liftLua (getmetafield top "__call") >>= \case
        TypeNil -> peekDocFuzzy top
        _       -> liftLua (pop 1) *> peekCall
 where
   peekCall :: Peek PandocError (Doc Text)
   peekCall =
     liftLua (pushElement *> pushOpts *> pcallTrace 2 1) >>= \case
       OK -> peekDocFuzzy top
       _  -> failPeek =<< liftLua (tostring' top)

blockListToCustom :: WriterTable -> Maybe (Doc Text) -> [Block]
                  -> LuaE PandocError (Doc Text)
blockListToCustom writer msep blocks = forcePeek $
  renderBlockList writer msep blocks

inlineListToCustom :: WriterTable -> [Inline] -> LuaE PandocError (Doc Text)
inlineListToCustom writer inlines = forcePeek $
  renderInlineList writer inlines

renderBlockList :: WriterTable -> Maybe (Doc Text) -> [Block]
                -> Peek PandocError (Doc Text)
renderBlockList writer msep blocks = withContext "rendering Blocks" $ do
  let addSeps = intersperse $ fromMaybe blankline msep
  mconcat . addSeps <$> mapM (renderBlock writer) blocks

renderInlineList :: WriterTable -> [Inline] -> Peek PandocError (Doc Text)
renderInlineList writer inlines = withContext "rendering Inlines" $ do
  mconcat <$> mapM (renderInline writer) inlines

orNil :: Peeker e a -> Peeker e (Maybe a)
orNil p idx = liftLua (ltype idx) >>= \case
  TypeNil  -> pure Nothing
  TypeNone -> pure Nothing
  _        -> Just <$> p idx

peekDocFuzzy :: LuaError e => Peeker e (Doc Text)
peekDocFuzzy idx = liftLua (ltype idx) >>= \case
  TypeTable -> mconcat <$!> peekList peekDoc idx
  _         -> peekDoc idx

handleMissingField :: LuaError e => ByteString -> LuaE e ()
handleMissingField key' =
  let key = UTF8.toString key'
      blockNames  = map (fromString . show) . dataTypeConstrs . dataTypeOf
                      $ HorizontalRule
      inlineNames = map (fromString . show) . dataTypeConstrs . dataTypeOf
                      $ Space
      mtypeName = case () of
       _ | key `elem` blockNames  -> Just "Block"
       _ | key `elem` inlineNames -> Just "Inline"
       _                          -> Nothing
  in case mtypeName of
       Just typeName  -> failLua $
                         "No render function for " <> typeName <> " value " <>
                         "'" <> key <> "';\ndefine a function `Writer." <>
                         typeName <> "." <> key <> "` that returns " <>
                         "a string or Doc."
       _ -> pure ()