packages feed

mdoc-0.2.0.0: src/Autodocodec/Schema/Mdoc.hs

{-# LANGUAGE AllowAmbiguousTypes #-}

-- |
--
-- Module      : Autodocodec.Schema.Mdoc
-- Copyright   : (c) 2026 Patrick Brisbin
-- License     : AGPL-3
-- Maintainer  : pbrisbin@gmail.com
-- Stability   : experimental
-- Portability : POSIX
--
-- See "Mdoc.Examples.Person".
module Autodocodec.Schema.Mdoc
  ( getPage
  , getPageViaCodec

    -- * Re-used by "OptEnvConf.Mdoc"
  , getConfigs
  ) where

import Mdoc.Prelude

import Autodocodec (HasCodec)
import Autodocodec.Schema
  ( JSONSchema (..)
  , ObjectSchema (..)
  , jsonSchemaViaCodec
  )
import Autodocodec.Schema qualified as JSONSchema
import Data.Aeson (Value)
import Data.Aeson qualified as Aeson
import Mdoc.Data.Config
import Mdoc.Data.Described
import Mdoc.Data.Optionality
import Mdoc.Data.Page
import Mdoc.Optics

getPage :: JSONSchema -> Page
getPage js = foldr addConfig mempty $ getConfigs Nothing js

getPageViaCodec :: forall a. HasCodec a => Page
getPageViaCodec = getPage $ jsonSchemaViaCodec @a

getConfigs :: Maybe (NonEmpty String) -> JSONSchema -> [Described Config]
getConfigs mPrefix = uncurry (getConfigs1 mPrefix) . simplifyJSONSchema

getConfigs1
  :: Maybe (NonEmpty String)
  -> Described Schema
  -> [Described ObjectMember]
  -> [Described Config]
getConfigs1 mPrefix schema members =
  maybe id (:) mParent
    $ concatMap (describeObjectMember schema.item mPrefix) members
 where
  mParent = do
    -- This makes sure the no-prefix + complex case doesn't look like:
    --
    --   : object
    --
    --   bar : this
    --   baz : that
    --
    -- But instead is,
    --
    --   bar : this
    --   baz : that
    --
    guard $ isJust mPrefix || null members

    let config =
          Config
            { name = renderPrefix <$> mPrefix
            , schema = schema.item
            }

    pure $ config <$ schema

data ObjectMember = ObjectMember
  { keys :: NonEmpty String
  , schema :: Schema
  , members :: [Described ObjectMember]
  }

describeObjectMember
  :: Schema
  -- ^ Parent schema
  -> Maybe (NonEmpty String)
  -> Described ObjectMember
  -> [Described Config]
describeObjectMember pschema mPrefix member =
  (config <$ member)
    : concatMap
      (describeObjectMember member.item.schema $ Just prefix)
      member.item.members
 where
  prefix = case (mPrefix, pschema) of
    (Nothing, ListOf {}) -> pure "[]" <> member.item.keys
    (Nothing, _) -> member.item.keys
    (Just p, ListOf {}) -> (p & last1 <>~ "[]") <> member.item.keys
    (Just p, _) -> p <> member.item.keys

  config =
    Config
      { name = Just $ renderPrefix prefix
      , schema = member.item.schema
      }

simplifyJSONSchema :: JSONSchema -> (Described Schema, [Described ObjectMember])
simplifyJSONSchema = \case
  AnySchema -> primitive "any"
  NullSchema -> primitive "null"
  BoolSchema -> primitive "boolean"
  StringSchema {} -> primitive "string"
  IntegerSchema {} -> primitive "number"
  NumberSchema {} -> primitive "number"
  MapSchema s -> simplifyJSONSchema s
  ArraySchema s -> first (fmap ListOf) $ simplifyJSONSchema s
  ObjectSchema ObjectAnySchema -> (required "any", [])
  ObjectSchema os -> (required "object", simplifyObjectSchema os)
  ValueSchema v -> primitive $ constSchema v
  AnyOfSchema ss -> anyOf ss
  OneOfSchema ss -> anyOf ss
  CommentSchema c s -> first (setHelpText c) $ simplifyJSONSchema s
  RefSchema t -> primitive $ Simple t
  WithDefSchema _ s -> simplifyJSONSchema s

simplifyObjectSchema :: ObjectSchema -> [Described ObjectMember]
simplifyObjectSchema = \case
  ObjectKeySchema k reqd s mcomment ->
    let (Described {item = schema}, members) = simplifyJSONSchema s
    in  [ Described
            { item = ObjectMember {keys = pure $ unpack k, schema, members}
            , optionality = case reqd of
                JSONSchema.Required -> Required
                _ -> Optional
            , multiple = False
            , help = textToMdoc =<< mcomment
            }
        ]
  ObjectAllOfSchema os -> concatMap simplifyObjectSchema $ toList os
  ObjectAnyOfSchema os -> concatMap simplifyObjectSchema $ toList os
  ObjectOneOfSchema os -> concatMap simplifyObjectSchema $ toList os
  ObjectAnySchema -> error "panic! we should not have hit this case"

-- Work around bug in opt-env-conf where all configs are null|x
anyOf :: NonEmpty JSONSchema -> (Described Schema, [Described ObjectMember])
anyOf (NullSchema :| [s]) = simplifyJSONSchema s
anyOf ss = bimap (redescribe AnyOf) concat $ unzip $ map simplifyJSONSchema $ toList ss

primitive :: Schema -> (Described Schema, [a])
primitive schema = (required schema, [])

-- | 'ValueSchema' is only used for @const {value}@; i.e. it must match the
-- given @Value@ literally. We don't render complex values, but rendering simple
-- types is how an enum, defined as @[{const:error, const:warning}]@, is
-- correctly rendered as @error|warning@.
constSchema :: Value -> Schema
constSchema = \case
  Aeson.Object {} -> "json"
  Aeson.Array {} -> "json"
  Aeson.String t -> Simple t
  Aeson.Number n -> Simple $ pack $ show n
  Aeson.Bool b -> Simple $ pack $ show b
  Aeson.Null -> "null"

renderPrefix :: NonEmpty String -> String
renderPrefix = intercalate "." . toList