packages feed

plexus-synapse-3.15.0: src/Synapse/Algebra/Render.hs

-- | Render algebra for PluginSchema
module Synapse.Algebra.Render
  ( -- * Rendering Functions
    renderSchema
  , renderSchemaWith
  , renderSchemaFor
  , renderListing
  , Listing(..)
  , summarize
  , fitText
  , quoteMask
  , renderMethod
  , renderMethodFull
  , renderChild
  , renderParams

    -- * Configuration
  , RenderStyle(..)
  , defaultStyle
  , compactStyle

    -- * Deprecation Rendering (IR-6)
  , deprecationMarker
  , formatDeprecationLine
  ) where

import Data.Text (Text)
import qualified Data.Text as T
import Data.Aeson (Value(..))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V
import Control.Applicative ((<|>))
import Data.Char (isAlphaNum, toLower)
import Data.List (sortOn)
import Data.Maybe (fromMaybe, isJust, isNothing)

import Synapse.Schema.Types
import Plexus.Schema.Recursive (DeprecationInfo(..), ParamSchema(..))

-- | Rendering style configuration
data RenderStyle = RenderStyle
  { rsIndent    :: !Int   -- ^ Spaces per indent level
  , rsShowHash  :: !Bool  -- ^ Show plugin/method hashes
  , rsShowTypes :: !Bool  -- ^ Show parameter types
  , rsCompact   :: !Bool  -- ^ Compact single-line format
  , rsWidth     :: !Int   -- ^ Columns to wrap descriptions at (SYNAPSE-12)
  }
  deriving stock (Show, Eq)

-- | Default style: readable multi-line output
defaultStyle :: RenderStyle
defaultStyle = RenderStyle
  { rsIndent    = 2
  , rsShowHash  = False
  , rsShowTypes = True
  , rsCompact   = False
  , rsWidth     = 80
  }

-- | Compact style: single-line descriptions
compactStyle :: RenderStyle
compactStyle = defaultStyle { rsCompact = True }

-- | Render a PluginSchema
renderSchema :: PluginSchema -> Text
renderSchema = renderSchemaWith defaultStyle

-- | Render with custom style
renderSchemaWith :: RenderStyle -> PluginSchema -> Text
renderSchemaWith style = renderSchemaFor style Nothing

-- | AUTH-8: render for a caller — the header line saying who it is
-- (@you are … · editor on MENGER@) under the description, when given.
-- SYNAPSE-13: the listing as 'renderListing' lays it out, reached by
-- @synapse <namespace>@.
renderSchemaFor :: RenderStyle -> Maybe Text -> PluginSchema -> Text
renderSchemaFor style callerLine ps =
  renderListing style (Listing ("synapse " <> psNamespace ps) callerLine) ps

-- | SYNAPSE-13: what a listing needs beside the schema.
data Listing = Listing
  { lsInvoke :: Text        -- ^ the command line that reaches this plugin (@synapse -P 4455 menger tickets@)
  , lsCaller :: Maybe Text  -- ^ AUTH-8: who the caller is, in a caller's view
  }
  deriving stock (Show, Eq)

-- | SYNAPSE-13: a plugin's listing in gh's shape — the name, version and
-- description; a usage line; then one line per activation and per method
-- (its name and summary, the first sentence of its description), the
-- methods in their groups (the schema's @group@, in the order they first
-- appear; alphabetical within each; one METHODS section when there are
-- none), and how to see a method's options. In a caller's view (AUTH-8)
-- who the caller is sits under the description, and each method's line
-- ends with its grant and where it is held, in two aligned columns.
renderListing :: RenderStyle -> Listing -> PluginSchema -> Text
renderListing style Listing{..} PluginSchema{..} = T.unlines (dropTrailingBlanks body)
  where
    width = rsWidth style
    authCols = isJust lsCaller || any (isJust . methodHeldOn) psMethods

    body = concat
      [ [heading]
      , deprecationNotice
      , map ("  " <>) (wrap (width - 2) psDescription)
      , maybe [] (\l -> ["  " <> l]) lsCaller
      , section "USAGE" (map ("  " <>) usage)
      , section "ACTIVATIONS" (map childRow children)
      , concat [ section title (concatMap methodRows ms) | (title, ms) <- methodSections ]
      , if null footer then [] else "" : footer
      ]

    heading = case psDeprecation of
      Just _  -> deprecationMarker <> " " <> psNamespace <> " v" <> psVersion
      Nothing -> psNamespace <> " v" <> psVersion

    deprecationNotice = case psDeprecation of
      Just di -> ["  " <> formatDeprecationLine di]
      Nothing -> []

    section _ [] = []
    section title rows = "" : title : rows

    children = maybe [] (sortOn csNamespace) psChildren

    usage = concat
      [ [lsInvoke <> " <activation> <method> [flags]" | not (null children)]
      , [lsInvoke <> " <method> [flags]" | not (null psMethods)]
      ]

    footer = concat
      [ ["Run '" <> lsInvoke <> " <activation>' for its methods." | not (null children)]
      , ["Run '" <> lsInvoke <> " <method> --help' for its options." | not (null psMethods)]
      ]

    -- The groups in the order they first appear, each alphabetical; the
    -- ungrouped last (as OTHER), or all of them as METHODS when no method
    -- says a group.
    methodSections =
      let groups = dedupe [g | Just g <- map methodGroup psMethods]
          inGroup g = sortOn methodName [m | m <- psMethods, methodGroup m == Just g]
          ungrouped = sortOn methodName [m | m <- psMethods, isNothing (methodGroup m)]
      in if null groups
           then [("METHODS", ungrouped) | not (null ungrouped)]
           else [(T.toUpper g, inGroup g) | g <- groups]
                  ++ [("OTHER", ungrouped) | not (null ungrouped)]
    dedupe = foldl (\acc x -> if x `elem` acc then acc else acc ++ [x]) []

    decoratedName m = case methodDeprecation m of
      Just _  -> deprecationMarker <> " " <> methodName m
      Nothing -> methodName m

    -- The name column: wide enough for the longest name (capped), shared by
    -- every section so the summaries start in one column.
    nameW = min 24 $ maximum $ 8 : map T.length (map csNamespace children ++ map decoratedName psMethods)
    descCol = 2 + nameW + 2

    -- AUTH-8: the grant and where it is held, after the summary.
    grantOf m = fromMaybe "" (methodGrant m)
    heldOf m = maybe "" (T.intercalate ", ") (methodHeldOn m)
    grantW = maximum (0 : map (T.length . grantOf) psMethods)
    heldW = maximum (0 : map (T.length . heldOf) psMethods)
    summaryW
      | authCols = max 20 (width - descCol - 2 - grantW - 2 - heldW)
      | otherwise = width - descCol

    row name summary =
      T.stripEnd ("  " <> T.justifyLeft nameW ' ' name <> "  " <> summary)

    childRow c = row (csNamespace c) (fitText (width - descCol) (summarize (csDescription c)))

    methodRows m =
      let s = fitText summaryW (summarize (methodDescription m))
          line
            | authCols = row (decoratedName m)
                (T.justifyLeft summaryW ' ' s <> "  " <> T.justifyLeft grantW ' ' (grantOf m) <> "  " <> heldOf m)
            | otherwise = row (decoratedName m) s
      in line : maybe [] (\di -> [T.replicate descCol " " <> formatDeprecationLine di]) (methodDeprecation m)

-- | SYNAPSE-13: a description's summary — its first sentence: up to the
-- first @. @, @; @ or @: @ outside brackets and quotes (an @e.g.@ or
-- @i.e.@ is not an end), without the stop.
summarize :: Text -> Text
summarize desc = T.strip (go (0 :: Int) "" (zip str (quoteMask str)))
  where
    str = T.unpack (T.strip desc)
    go _ acc [] = T.pack (reverse acc)
    go d acc ((c, q) : rest)
      | q = go d (c : acc) rest
      | c `elem` ("([{" :: String) = go (d + 1) (c : acc) rest
      | c `elem` (")]}" :: String) = go (max 0 (d - 1)) (c : acc) rest
      | d == 0, c `elem` (".;:" :: String), startsSpace rest, not (abbrev c acc) = T.pack (reverse acc)
      | otherwise = go d (c : acc) rest
    startsSpace ((' ', _) : _) = True
    startsSpace _ = False
    abbrev '.' acc = map toLower (reverse (takeWhile (/= ' ') acc)) `elem` ["e.g", "i.e", "etc", "vs", "cf"]
    abbrev _ _ = False

-- | Which characters are inside a quoted span — @'…'@ or @`…`@, the quotes
-- included. A @'@ opens a quote after a non-letter and before a non-space
-- (so @ticket's@ is an apostrophe), and closes before a non-letter; a quote
-- never closed is no quote.
quoteMask :: String -> [Bool]
quoteMask str = case openAt of
  Just j -> take j marks ++ map (const False) (drop j marks)
  Nothing -> marks
  where
    (marks, openAt) = go Nothing Nothing (0 :: Int) str
    alnum = maybe False isAlphaNum
    go open _ _ [] = ([], fmap snd open)
    go open prev i (c : rest) =
      let next = case rest of { (x : _) -> Just x; [] -> Nothing }
          (m, open') = case open of
            Nothing
              | c == '`' -> (True, Just ('`', i))
              | c == '\'', not (alnum prev), maybe False (/= ' ') next -> (True, Just ('\'', i))
              | otherwise -> (False, Nothing)
            Just (q, _)
              | c == q, q == '`' || not (alnum next) -> (True, Nothing)
              | otherwise -> (True, open)
          (ms, o) = go open' (Just c) (i + 1) rest
      in (m : ms, o)

-- | SYNAPSE-13: a line's text in at most @w@ columns: whole when it fits;
-- else without its parentheticals; else up to the last @ — @ that fits;
-- else cut after a comma that keeps most of it, or at a word, ending in @…@.
-- SYNAPSE-17: a quoted span (@'…'@, @`…`@) is never cut or changed inside:
-- its brackets stay, and a cut ends before it (or keeps it whole when it
-- leads the line).
fitText :: Int -> Text -> Text
fitText w t
  | T.length t <= w = t
  | T.length bare <= w = bare
  | Just c <- lastFitting 0 " \x2014 " = c
  | Just c <- lastFitting 1 ", ", T.length c * 10 >= w * 6 = c <> "\x2026"
  | otherwise = wordCut atoms
  where
    chars = T.unpack t
    (bareCs, bareMask) = unzip (tidy (dropParens (zip chars (quoteMask chars))))
    bare = T.pack bareCs
    -- the longest prefix ending before @sep@ (outside a quote) that fits
    -- (with @room@ for an …)
    lastFitting room sep =
      case [ p | (p, _) <- T.breakOnAll sep bare, T.length p + room <= w
               , not (T.null (T.strip p)), not (quotedAt (T.length p)) ] of
        [] -> Nothing
        ps -> Just (T.stripEnd (last ps))
    quotedAt i = i < length bareMask && bareMask !! i
    -- words, a quoted span with its spaces one of them
    atoms = map (T.pack . map fst) (splitAtoms (zip bareCs bareMask))
    splitAtoms cs = case dropWhile (\(c, q) -> c == ' ' && not q) cs of
      [] -> []
      cs' -> let (a, rest) = break (\(c, q) -> c == ' ' && not q) cs' in a : splitAtoms rest
    quotedAtom x = T.any (`elem` ("'`" :: String)) x
    wordCut ws = go "" ws
      where
        go acc [] = acc
        go acc (x : xs)
          | T.length (join acc x) + 1 <= w = go (join acc x) xs
          | T.null acc = if quotedAtom x then x else T.take (max 1 (w - 1)) x <> "\x2026"
          | otherwise = T.dropWhileEnd (`elem` (",;:" :: String)) acc <> "\x2026"
        join acc x = if T.null acc then x else acc <> " " <> x
    -- whitespace runs outside quotes made one space, none left before a
    -- comma or a stop, none at the ends
    tidy = trimEnds . noSpaceBefore . collapse
      where
        collapse [] = []
        collapse ((c, False) : rest)
          | isSpaceC c = (' ', False) : collapse (dropWhile (\(x, q) -> isSpaceC x && not q) rest)
        collapse (x : rest) = x : collapse rest
        noSpaceBefore ((' ', False) : x@(c, False) : rest)
          | c `elem` (",.;:" :: String) = noSpaceBefore (x : rest)
        noSpaceBefore (x : rest) = x : noSpaceBefore rest
        noSpaceBefore [] = []
        trimEnds = reverse . dropWhile blank . reverse . dropWhile blank
        blank (c, q) = isSpaceC c && not q
        isSpaceC x = x == ' ' || x == '\n' || x == '\t'
    -- parentheticals outside quotes dropped (a quote inside one goes with it)
    dropParens = strip (0 :: Int)
      where
        strip _ [] = []
        strip d (x@(c, q) : rest)
          | not q, c == '(' = strip (d + 1) rest
          | not q, c == ')', d > 0 = strip (d - 1) rest
          | d > 0 = strip d rest
          | otherwise = x : strip d rest

-- | Words wrapped greedily to a width (at least 20); a word longer than the
-- width stands on its own line.
wrap :: Int -> Text -> [Text]
wrap w0 txt = go (T.words txt)
  where
    w = max 20 w0
    go [] = []
    go (x : xs) = line x xs
    line acc [] = [acc]
    line acc (y : ys)
      | T.length acc + 1 + T.length y <= w = line (acc <> " " <> y) ys
      | otherwise = acc : line y ys

-- | A lead and lines that start in column @col@: the first beside the lead
-- when it fits (two spaces between), else all of them under it.
hang :: Int -> Text -> [Text] -> [Text]
hang col lead ds = case ds of
  (d : rest) | T.length lead <= col - 2 -> (T.justifyLeft col ' ' lead <> d) : map (pad <>) rest
  _ -> lead : map (pad <>) ds
  where pad = T.replicate col " "

dropTrailingBlanks :: [Text] -> [Text]
dropTrailingBlanks = reverse . dropWhile (T.null . T.strip) . reverse

-- | Render a method (short form)
renderMethod :: MethodSchema -> Text
renderMethod = renderMethodWith defaultStyle

-- | Render a method with style
renderMethodWith :: RenderStyle -> MethodSchema -> Text
renderMethodWith RenderStyle{..} m =
  "  " <> padRight 16 (methodName m) <> methodDescription m
    <> if rsShowTypes then renderParams (methodParams m) else ""

-- | Render a method (full form with all params).
--
-- IR-14: per-parameter deprecation info carried on @methodParamSchemas@
-- is threaded into the param rendering so each deprecated parameter
-- receives the ⚠ marker and the formatted @DEPRECATED since …@ line.
-- Non-deprecated parameters render exactly as before.
renderMethodFull :: MethodSchema -> Text
renderMethodFull m = T.unlines $
  [ methodName m <> " - " <> methodDescription m
  , ""
  ] <> paramLines
  where
    paramLines = case methodParams m of
      Nothing -> ["  (no parameters)"]
      Just schema -> renderParamsFull (methodParamSchemas m) schema

-- | Render a child summary
renderChild :: ChildSummary -> Text
renderChild child =
  "  " <> padRight 16 (csNamespace child) <> csDescription child

-- | Render parameters inline
renderParams :: Maybe Value -> Text
renderParams Nothing = ""
renderParams (Just (Object o)) = case KM.lookup "properties" o of
  Just (Object props) ->
    let reqList = case KM.lookup "required" o of
          Just (Array arr) -> [t | String t <- foldr (:) [] arr]
          _ -> []
        propList = KM.toList props
        sorted = sortOn (\(k, _) -> (K.toText k `notElem` reqList, K.toText k)) propList
        rendered = map (renderParam reqList) sorted
    in if null rendered then "" else "\n" <> T.intercalate "\n" rendered
  _ -> ""
renderParams _ = ""

-- | Render a single parameter
renderParam :: [Text] -> (K.Key, Value) -> Text
renderParam required (name, propSchema) =
  let nameText = K.toText name
      isReq = nameText `elem` required
      flagName = T.replace "_" "-" nameText
      (typ, desc) = extractTypeDesc propSchema
      reqMarker = if isReq then "" else "?"
  in "      --" <> flagName <> " <" <> typ <> ">" <> reqMarker <> "  " <> desc

-- | Render parameters in full (for method help).
--
-- IR-14: the first argument carries optional per-parameter metadata
-- ('ParamSchema') advertised by IR-5 producers. Any parameter whose
-- 'paramDeprecation' is @Just@ is decorated with the ⚠ marker and a
-- trailing @DEPRECATED since … removed in … — …@ line. Parameters
-- without a matching entry render identically to pre-ticket output.
renderParamsFull :: Maybe [ParamSchema] -> Value -> [Text]
renderParamsFull paramSchemas (Object o) = case KM.lookup "properties" o of
  Just (Object props) ->
    let reqList = case KM.lookup "required" o of
          Just (Array arr) -> [t | String t <- foldr (:) [] arr]
          _ -> []
        propList = KM.toList props
        sorted = sortOn (\(k, _) -> (K.toText k `notElem` reqList, K.toText k)) propList
    in map (renderParamFull paramSchemas reqList) sorted
  _ -> []
renderParamsFull _ _ = []

-- | Look up per-parameter deprecation info by name, scanning the
--   optional ParamSchema list attached to the method.
lookupParamDeprecation :: Maybe [ParamSchema] -> Text -> Maybe DeprecationInfo
lookupParamDeprecation Nothing     _     = Nothing
lookupParamDeprecation (Just pss) pname =
  case filter ((== pname) . paramName) pss of
    (ps:_) -> paramDeprecation ps
    []     -> Nothing

renderParamFull :: Maybe [ParamSchema] -> [Text] -> (K.Key, Value) -> Text
renderParamFull paramSchemas required (name, propSchema) =
  let nameText = K.toText name
      isReq = nameText `elem` required
      (typ, desc) = extractTypeDesc propSchema
      reqText = if isReq then " (required)" else " (optional)"
      -- IR-14: deprecation decoration.  Marker prepended to the flag
      -- name, detail line appended after the description.  Pre-ticket
      -- non-deprecated output is preserved exactly when depInfo is
      -- Nothing.
      depInfo = lookupParamDeprecation paramSchemas nameText
      decoratedName = case depInfo of
        Just _  -> deprecationMarker <> " --" <> nameText
        Nothing -> "--" <> nameText
      depLine = case depInfo of
        Just di -> "\n      " <> formatDeprecationLine di
        Nothing -> ""
  in "  " <> decoratedName <> " <" <> typ <> ">" <> reqText
     <> "\n      " <> desc <> depLine

-- | Extract type and description from property schema.
--
-- SYNAPSE-12: the type a flag takes, as the schema says it — a nullable
-- type is its non-null part (@["string", "null"]@ → @string@), an array
-- its element's (@string[]@), a @$ref@ the type it names, a union its
-- alternatives (@a|b@); @any@ only when the schema says nothing.
extractTypeDesc :: Value -> (Text, Text)
extractTypeDesc v@(Object po) =
  ( schemaTypeName v
  , case KM.lookup "description" po of { Just (String d) -> d; _ -> "" }
  )
extractTypeDesc _ = ("any", "")

schemaTypeName :: Value -> Text
schemaTypeName (Object po)
  | Just (String ref) <- KM.lookup "$ref" po = last (T.splitOn "/" ref)
  | Just (String "array") <- KM.lookup "type" po = itemsName <> "[]"
  | Just (String t) <- KM.lookup "type" po = t
  | Just (Array ts) <- KM.lookup "type" po =
      case [t | String t <- V.toList ts, t /= "null"] of
        ["array"] -> itemsName <> "[]"
        [] -> "any"
        ts' -> T.intercalate "|" ts'
  | Just (Array alts) <- KM.lookup "anyOf" po <|> KM.lookup "oneOf" po =
      case filter (/= "null") (map schemaTypeName (V.toList alts)) of
        [] -> "any"
        ns -> T.intercalate "|" (dedupe ns)
  | Just (Array _) <- KM.lookup "enum" po = "string"
  | otherwise = "any"
  where
    itemsName = maybe "any" schemaTypeName (KM.lookup "items" po)
    dedupe = foldr (\x acc -> if x `elem` acc then acc else x : acc) []
schemaTypeName _ = "any"

-- | Pad text to a minimum width
padRight :: Int -> Text -> Text
padRight n t
  | T.length t >= n = t <> " "
  | otherwise = t <> T.replicate (n - T.length t) " "

-- ============================================================================
-- Deprecation Rendering (IR-6)
-- ============================================================================

-- | Visible marker for deprecated surfaces.
--
--   Plain UTF-8 — a TTY/color-aware renderer lives at the CLI boundary;
--   this module only emits text markers.
deprecationMarker :: Text
deprecationMarker = "\x26A0"  -- ⚠

-- | Format the one-line deprecation detail, e.g.
--   @DEPRECATED since 0.5, removed in 0.7 — use move_doc@.
formatDeprecationLine :: DeprecationInfo -> Text
formatDeprecationLine di =
  "DEPRECATED since " <> depSince di
    <> ", removed in " <> depRemovedIn di
    <> " \x2014 " <> depMessage di   -- em-dash