packages feed

mdoc-0.1.1.4: 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
--
-- A small, semi-representative, not type-checked example:
--
-- @
-- data Person = Person
--   { name :: Text
--   , age :: Int
--   }
--
-- instance HasCodec Person where
--   codec = object \"Person\" $ Person
--     \<$> requiredField "name" "Their name" .= (.name)
--     \<*> requiredField "age" "Their age" .= (.age)
--
-- schema :: JSONSchema
-- schema = jsonSchemaViaCodec @Person
-- @
--
-- @
-- .\" Generates this mandoc source
-- .Sh DESCIPTION
-- .Bl -tag -width indent
-- .It Cm age : number
-- Their age
-- .It Cm name : string
-- Their name
-- .El
-- @
--
-- Which renders something like:
--
-- @
-- DESCRIPTION
--
--   age : object      Their age
--
--   name : string      Their name
--
-- @
module Autodocodec.Schema.Mdoc
  ( addToMan5
  , addToMan5ViaCodec
  , 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 Data.List (intercalate)
import Data.Text qualified as T
import Mdoc.Gen.Config
import Mdoc.Gen.Described
import Mdoc.Gen.Man5
import Mdoc.Gen.Optionality
import Mdoc.Optics

-- | Add configuration values to a 'Man5'
--
-- This uses 'getConfigs' without prefix, resulting in output like:
--
-- @
-- foo : string
-- A foo field that is string
--
-- bar : number
-- A bar field that is a number
-- @
--
-- for top-level object, or
--
-- @
-- : string
-- @
--
-- for a top-level primitive
--
-- If the 'JSONSchema' has a top-level @$comment@, and the 'Man5' doesn't yet
-- have a description, that will also be set.
addToMan5 :: JSONSchema -> Man5 -> Man5
addToMan5 js m =
  m
    & field @"name" % field @"description" %~ getComment js
    & field @"configs" <>~ getConfigs Nothing js

-- | Add to a 'Man5' from the schema of any @a@ with 'HasCodec'
addToMan5ViaCodec :: forall a. HasCodec a => Man5 -> Man5
addToMan5ViaCodec = addToMan5 $ jsonSchemaViaCodec @a

getComment :: JSONSchema -> String -> String
getComment js existing = case js of
  CommentSchema comment _
    | null existing
    , not $ T.null comment ->
        unpack comment
  _ -> existing

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
            , exampleLines = Nothing
            }

    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 {}) -> onLast (<> "[]") p <> member.item.keys
    (Just p, _) -> p <> member.item.keys

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

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
            , helpLines = nonEmpty . lines . unpack =<< 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}@; e.g. it must match the
-- @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