morley-0.4.0: src/Michelson/Doc.hs
{-# LANGUAGE DerivingStrategies, TypeFamilyDependencies, UndecidableSuperClasses #-}
{-# OPTIONS_GHC -Wno-redundant-constraints #-}
-- | Renderable documentation injected to contract code.
module Michelson.Doc
( DocItem (..)
, docItemPosition
, DocItemId (..)
, DocItemPlacementKind (..)
, DocItemRef (..)
, DocSectionNameStyle (..)
, SomeDocItem (..)
, SomeDocDefinitionItem (..)
, DocBlock
, SubDoc (..)
, ContractDoc (..)
, DocGrouping
, cdContentsL
, cdDefinitionsL
, cdDefinitionsSetL
, cdDefinitionIdsL
, subDocToMarkdown
, someDocItemToBlock
, contractDocToMarkdown
, docGroupContent
, docDefinitionRef
, DName (..)
, DDescription (..)
, DVersion (..)
, DGitRevision (..)
, GitRepoSettings (..)
, mkDGitRevision
, morleyRepoSettings
, DComment (..)
) where
import qualified Data.Map as M
import qualified Data.Map.Merge.Strict as M
import qualified Data.Set as S
import qualified Data.Text as T
import qualified Data.Text.Lazy as LT
import Data.Typeable (cast, typeRep)
import Development.GitRev (gitCommitDate, gitHash)
import Fmt (Buildable, build, fmt, (+|), (+||), (|+), (||+))
import GHC.TypeNats (Nat)
import qualified Language.Haskell.TH as TH
import qualified Text.Show
import Util.Instances ()
import Util.Lens
import Util.Markdown
import Util.Type
import Util.Typeable
-- | A piece of documentation describing one property of a thing,
-- be it a name or description of a contract, or an error throwable
-- by given endpoint.
--
-- Items of the same type appear close to each other in a rendered documentation
-- and form a /section/.
--
-- Doc items are later injected into a contract code via a dedicated nop-like
-- instruction. Normally doc items which belong to one section appear in
-- resulting doc in the same order in which they appeared in the contract.
--
-- While documentation framework grows, this typeclass acquires more and more
-- methods for fine tuning of existing rendering logic because we don't want
-- to break backward compatibility, hope one day we will make everything
-- concise :(
-- E.g. all rendering and reording stuff could be merged in one method, and
-- we could have several template implementations for it which would allow
-- user to specify only stuff relevant to his case.
class (Typeable d, DOrd d, KnownNat (DocItemPosition d)) => DocItem d where
-- | Position of this item in the resulting documentation;
-- the smaller the value, the higher the section with this element
-- will be placed.
--
-- Documentation structure is not necessarily flat.
-- If some doc item consolidates a whole documentation block within it,
-- this block will have its own placement of items independent from outer parts
-- of the doc.
type DocItemPosition d = (pos :: Nat) | pos -> d
-- | When multiple items of the same type belong to one section, how
-- this section will be called.
--
-- If not provided, section will contain just untitled content.
docItemSectionName :: Maybe Text
-- | Description of a section.
--
-- Can be used to mention some common things about all elements of this section.
-- Markdown syntax is permitted here.
docItemSectionDescription :: Maybe Markdown
docItemSectionDescription = Nothing
-- | How to render section name.
--
-- Takes effect only if section name is set.
docItemSectionNameStyle :: DocSectionNameStyle
docItemSectionNameStyle = DocSectionNameBig
-- | Defines where given doc item should be put. There are two options:
-- 1. Inline right here (default behaviour);
-- 2. Put into definitions section.
--
-- Note that we require all doc items with "in definitions" placement to
-- have 'Eq' and 'Ord' instances which comply the following law:
-- if two documentation items describe the same entity or property, they
-- should be considered equal.
type DocItemPlacement d :: DocItemPlacementKind
type DocItemPlacement d = 'DocItemInlined
-- | Defines a function which constructs an unique identifier of given doc item,
-- if it has been decided to put the doc item into definitions section.
--
-- Identifier should be unique both among doc items of the same type and items
-- of other types. Thus, consider using "typeId-contentId" pattern.
docItemRef :: d -> DocItemRef (DocItemPlacement d)
default docItemRef
:: (DocItemPlacement d ~ 'DocItemInlined)
=> d -> DocItemRef (DocItemPlacement d)
docItemRef _ = DocItemNoRef
-- | Render given doc item to Markdown, preferably one line,
-- optionally with header.
--
-- Accepts the smallest allowed level of header.
-- (Using smaller value than provided one will interfere with existing
-- headers thus delivering mess).
docItemToMarkdown :: HeaderLevel -> d -> Markdown
-- | All doc items which this doc item refers to.
--
-- They will automatically be put to definitions as soon as given doc item
-- is detected.
docItemDependencies :: d -> [SomeDocDefinitionItem]
docItemDependencies _ = []
-- | This function accepts doc items put under the same section in the order
-- in which they appeared in the contract and returns their new desired order.
-- It's also fine to use this function for filtering or merging doc items.
--
-- Default implementation
-- * leaves inlined items as is;
-- * for items put to definitions, lexicographically sorts them by their id.
docItemsOrder :: [d] -> [d]
docItemsOrder = \case
[] -> []
docItems@(someDocItem : _) -> case docItemRef someDocItem of
DocItemNoRef -> docItems
DocItemRef _ -> docItemsOrderById docItems
-- | Get doc item position at term-level.
docItemPosition :: forall d. DocItem d => DocItemPos
docItemPosition = DocItemPos $ natVal (Proxy @(DocItemPosition d))
-- | Render an item into Markdown block with all required adjustments.
docItemToMarkdownFull :: DocItem d => HeaderLevel -> d -> Markdown
docItemToMarkdownFull l d =
manchor <> docItemToMarkdown l d <> "\n\n"
where
manchor = case docItemRef d of
DocItemRef (DocItemId docItemId) -> mdAnchor docItemId
DocItemNoRef -> ""
-- | Order items by their 'docItemId'.
docItemsOrderById
:: forall d. (DocItem d, DocItemPlacement d ~ 'DocItemInDefinitions)
=> [d] -> [d]
docItemsOrderById docItems =
let getDocItemId :: d -> DocItemId
getDocItemId d = case docItemRef d of { DocItemRef di -> di }
in sortOn getDocItemId docItems
-- | Make a reference to doc item in definitions.
docDefinitionRef
:: (DocItem d, DocItemPlacement d ~ 'DocItemInDefinitions)
=> Markdown -> d -> Markdown
docDefinitionRef refText d = case docItemRef d of
DocItemRef (DocItemId docItemId) -> mdLocalRef refText docItemId
-- | Some unique identifier of a doc item.
--
-- All doc items which should be refer-able need to have this identifier.
newtype DocItemId = DocItemId Text
deriving stock (Eq, Ord, Show)
-- | Position of all doc items of some type.
newtype DocItemPos = DocItemPos Natural
deriving stock (Eq, Ord, Show)
deriving newtype (Buildable)
-- | Where do we place given doc item.
data DocItemPlacementKind
= DocItemInlined
-- ^ Placed in the document content itself.
| DocItemInDefinitions
-- ^ Placed in dedicated definitions section; can later be referenced.
-- | Defines an identifier which given doc item can be referenced with.
data DocItemRef (p :: DocItemPlacementKind) where
DocItemRef :: DocItemId -> DocItemRef 'DocItemInDefinitions
DocItemNoRef :: DocItemRef 'DocItemInlined
-- | How to render section name.
data DocSectionNameStyle
= DocSectionNameBig
-- ^ Suitable for block name.
| DocSectionNameSmall
-- ^ Suitable for subsection title within block.
-- | Hides some documentation item.
data SomeDocItem where
SomeDocItem :: DocItem d => d -> SomeDocItem
-- | Hides some documentation item which is put to "definitions" section.
data SomeDocDefinitionItem where
SomeDocDefinitionItem
:: (DocItem d, DocItemPlacement d ~ 'DocItemInDefinitions)
=> d -> SomeDocDefinitionItem
instance Eq SomeDocDefinitionItem where
SomeDocDefinitionItem d1 == SomeDocDefinitionItem d2 =
d1 `eqExt` d2
instance Ord SomeDocDefinitionItem where
SomeDocDefinitionItem d1 `compare` SomeDocDefinitionItem d2 =
d1 `compareExt` d2
-- | To automatically derive @instance Show Michelson.Typed.Instr@ later.
instance Show SomeDocItem where
show _ = "<doc item>"
type family DOrd d :: Constraint where
DOrd d = If (DocItemPlacement d == 'DocItemInDefinitions)
(Ord d) (() :: Constraint)
-- | A map from positions to document elements.
--
-- Note that each value in this map keeps a list of doc items, all of which
-- have the same type (since each doc item type is forced to have unique position).
type DocBlock = Map DocItemPos (NonEmpty SomeDocItem)
-- | Make sure several 'SomeDocItem's have the same type inside.
unifyDocItemsUnsafe
:: HasCallStack
=> NonEmpty SomeDocItem
-> (forall d. DocItem d => NonEmpty d -> r)
-> r
unifyDocItemsUnsafe (SomeDocItem (firstDocItem :: d) :| docItems) cont =
let
dis = docItems <&> \(SomeDocItem di) ->
cast di ?: error (castErr di)
in cont $ firstDocItem :| dis
where
castErr :: Typeable d' => d' -> Text
castErr (_ :: d') =
"Non-homogenious doc block: found two doc items of different types: "
+|| typeRep (Proxy @d) ||+ " and " +|| typeRep (Proxy @d') ||+ ""
-- | Render a documentation block.
docBlockToMarkdown :: HeaderLevel -> DocBlock -> Markdown
docBlockToMarkdown hl block =
mconcat $ M.elems block <&> \items@(SomeDocItem (_ :: di) :| _) ->
let sectionName = docItemSectionName @di
sectionNameStyle = docItemSectionNameStyle @di
(sectionNameFull, headerLevelDelta) =
case sectionName of
Nothing -> ("", id)
Just sn ->
let sn' = build sn
in case sectionNameStyle of
DocSectionNameBig ->
(mdHeader hl sn', nextHeaderLevel)
DocSectionNameSmall ->
( mdSubsectionTitle sn' <> "\n"
, error $ "Using headers is not allowed when section name is set small\n\
\Make sure docItemToMarkdown @" <> show (typeRep $ Proxy @di) <>
"does not use its 'header level' argument"
)
sectionDesc = docItemSectionDescription @di
sectionDescFull =
case sectionDesc of
Nothing -> ""
Just sd -> sd <> "\n\n"
content =
unifyDocItemsUnsafe items $ \dis ->
mconcat $ docItemsOrder (toList dis) <&> \di ->
docItemToMarkdownFull (headerLevelDelta hl) di
in sectionNameFull <> sectionDescFull <> content
someDocItemToBlock :: SomeDocItem -> DocBlock
someDocItemToBlock sdi@(SomeDocItem (_ :: di)) =
one (docItemPosition @di, one sdi)
-- | A part of documentation to be grouped. Essentially incapsulates 'DocBlock'.
-- One day we may need to define 'Eq' instance for this thing, and probably
-- we can consider any two entities equal for efficiency.
newtype SubDoc = SubDoc DocBlock
-- | Render documentation for 'SubDoc'.
subDocToMarkdown :: HeaderLevel -> SubDoc -> Markdown
subDocToMarkdown hl (SubDoc d) = docBlockToMarkdown hl d
-- | Keeps documentation gathered for some piece of contract code.
--
-- Used for building documentation of a contract.
data ContractDoc = ContractDoc
{ cdContents :: DocBlock
-- ^ All inlined doc items.
, cdDefinitions :: DocBlock
-- ^ Definitions used in document.
--
-- Usually you put some large and repetitive descriptions here.
-- This differs from the document content in that
-- it contains sections which are always at top-level,
-- disregard the nesting.
--
-- All doc items which define 'docItemId' method go here, and only they.
, cdDefinitionsSet :: Set SomeDocDefinitionItem
-- ^ We remember all already declared entries to avoid cyclic dependencies
-- in documentation items discovery.
, cdDefinitionIds :: Set DocItemId
-- ^ We remember all already used identifiers.
-- (Documentation naturally should not declare multiple items with
-- the same identifier because that would make references to the respective
-- anchors ambiguous).
}
makeLensesWith postfixLFields ''ContractDoc
-- | Contract documentation assembly primarily relies on this instance.
instance Semigroup ContractDoc where
cd1 <> cd2 = ContractDoc
{ cdContents =
M.merge
M.preserveMissing M.preserveMissing
(M.zipWithMatched $ \_k l r -> l <> r)
(cdContents cd1) (cdContents cd2)
, cdDefinitions =
M.merge
M.preserveMissing M.preserveMissing
(M.zipWithMatched $ \_k (l :| ls) rs ->
let removeDups = filter $ not . (`isDefinedIn` cdDefinitionIds cd1)
in l :| ls <> removeDups (toList rs)
)
(cdDefinitions cd1) (cdDefinitions cd2)
, cdDefinitionsSet =
S.union (cdDefinitionsSet cd1) (cdDefinitionsSet cd2)
, cdDefinitionIds =
S.union (cdDefinitionIds cd1) (cdDefinitionIds cd2)
}
where
isDefinedIn :: SomeDocItem -> Set DocItemId -> Bool
isDefinedIn (SomeDocItem di) defs =
case docItemRef di of
DocItemNoRef -> False
DocItemRef docItemId -> docItemId `S.member` defs
instance Monoid ContractDoc where
mempty = ContractDoc
{ cdContents = M.empty
, cdDefinitions = M.empty
, cdDefinitionsSet = S.empty
, cdDefinitionIds = S.empty
}
-- | Render given contract documentation to markdown document.
contractDocToMarkdown :: ContractDoc -> LText
contractDocToMarkdown ContractDoc{..} =
let
contents =
docBlockToMarkdown (HeaderLevel 1) cdContents |+ "\n\n"
definitions
| null cdDefinitions = ""
| otherwise =
"# Definitions\n\n" +| docBlockToMarkdown (HeaderLevel 2) cdDefinitions
total = fmt (contents <> definitions)
in LT.strip total <> "\n"
-- | A function which groups a piece of doc under one doc item.
type DocGrouping = SubDoc -> SomeDocItem
instance Show DocGrouping where
show _ = "<doc grouping>"
-- | Apply given grouping to documentation being built.
docGroupContent :: DocGrouping -> ContractDoc -> ContractDoc
docGroupContent grouping doc =
doc
{ cdContents = someDocItemToBlock . grouping $ SubDoc (cdContents doc)
}
----------------------------------------------------------------------------
-- Basic doc items
----------------------------------------------------------------------------
-- | Give a name to document block.
data DName = DName Text SubDoc
instance DocItem DName where
type DocItemPosition DName = 1
docItemSectionName = Nothing
docItemToMarkdown lvl (DName name doc) =
mdHeader lvl (build name) <>
subDocToMarkdown (nextHeaderLevel lvl) doc
-- | Description of something.
data DDescription = DDescription Markdown
instance DocItem DDescription where
type DocItemPosition DDescription = 10
docItemSectionName = Nothing
docItemToMarkdown _ (DDescription txt) = build txt
-- | Specify version if given contract.
data DVersion = DVersion Natural
instance DocItem DVersion where
type DocItemPosition DVersion = 3
docItemSectionName = Nothing
docItemToMarkdown _ (DVersion ver) =
mdSubsection "Version" (build ver)
-- | Specify version if given contract.
data DGitRevision = DGitRevision
{ dgrRepoSettings :: GitRepoSettings
, dgrCommitSha :: Text
, dgrCommitDate :: Text
}
-- | Repository settings for 'DGitRevision'.
newtype GitRepoSettings = GitRepoSettings
{ grsMkGitRevision :: Text -> Text
-- ^ By commit sha make up a url to that commit in remote repository.
-- @martoon: I tried to get remote URL automatically, but failed to
-- find a way. Even "git-link" in emacs performs complex parsing.
}
morleyRepoSettings :: GitRepoSettings
morleyRepoSettings = GitRepoSettings $ \commit ->
"https://gitlab.com/morley-framework/morley/blob/" <> commit
-- | Make 'DGitRevision'.
--
-- >>> :t $mkDGitRevision
-- GitRepoSettings -> DGitRevision
mkDGitRevision :: TH.Q TH.Exp
mkDGitRevision = [e| \dgrRepoSettings ->
DGitRevision
{ dgrRepoSettings
, dgrCommitSha = $gitHash
, dgrCommitDate = $gitCommitDate
}
|]
instance DocItem DGitRevision where
type DocItemPosition DGitRevision = 7
docItemSectionName = Nothing
docItemToMarkdown _ DGitRevision{..} =
mconcat $
[ mdSubsection "Code revision" $
let link = grsMkGitRevision dgrRepoSettings dgrCommitSha
in mconcat
[ mdRef (build $ T.take 7 dgrCommitSha) (build link)
, " "
, mdItalic $ "(" <> build dgrCommitDate <> ")"
]
]
-- | Comment in the doc (mostly used for licenses)
data DComment = DComment Text
instance DocItem DComment where
type DocItemPosition DComment = 0
docItemSectionName = Nothing
docItemToMarkdown _ (DComment commentText) =
"<!---\n" +| commentText |+ "\n-->"