packages feed

fragr-0.1.0.0: src/Fragr/Snapshot/JSON.hs

{-|
JSON exporter for the interactive viewer: https://skaarj1989.github.io/FrameGraph/

@
FG.compile graph
jsonSource <- Json.dump graph
@

'fromSnapshot' projects a compiled graph's 'Snapshot' onto the data model a
browser-based viewer consumes, and 'render' serializes it — hand-rolled, so
the library stays free of an aeson dependency. The types keep 'Generic'
instances for consumers who prefer deriving their own.

Unlike the Graphviz writer ("Fragr.Snapshot.Dot"), which draws one vertex
per resource /version/, this form collapses a resource's whole rename chain
(§3.4) into a single record keyed by resource /entry/ id, aggregating the
readers and writers of every version. 'fromSnapshot' accumulates in
version-major, then registration order, holding one id per declared access
with no deduplication.
-}
module Fragr.Snapshot.JSON
  ( dump
  , render
  , fromSnapshot
  , ViewerGraph (..)
  , ViewerPass (..)
  , ViewerResource (..)
  ) where

import Control.Monad (guard)
import Data.IntMap.Strict (IntMap)
import Data.IntMap.Strict qualified as IntMap
import Data.List (intersperse)
import Data.Maybe (listToMaybe, maybeToList)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Lazy qualified as Text.Lazy
import Data.Text.Lazy.Builder (Builder)
import Data.Text.Lazy.Builder qualified as Builder
import Data.Text.Lazy.Builder.Int qualified as Builder
import GHC.Generics (Generic)

import Fragr (EntryInfo (..), FrameGraph, NodeInfo (..), PassInfo (..), Snapshot (..), accessId, snapshot, someHandleId)

dump :: FrameGraph ctx alloc -> IO Text
dump g = render . fromSnapshot <$> snapshot g

{- | Serialize onto the viewer document (SPEC §8.3), compact. A resource's
empty @readers@/@writers@ and absent @createdBy@ are omitted, as the schema
allows; a pass keeps its @reads@/@writes@ even when empty.
-}
render :: ViewerGraph -> Text
render vg =
  Text.Lazy.toStrict . Builder.toLazyText $
    object
      [ ("passes", array (map passJson vg.passes))
      , ("resources", array (map resourceJson vg.resources))
      ]
  where
    passJson p =
      object
        [ ("id", int p.id)
        , ("name", string p.name)
        , ("culled", bool p.culled)
        , ("reads", ints p.reads)
        , ("writes", ints p.writes)
        ]
    resourceJson r =
      object $
        concat
          [
            [ ("id", int r.id)
            , ("name", string r.name)
            , ("description", string r.description)
            , ("transient", bool r.transient)
            ]
          , do
              c <- maybeToList r.createdBy
              pure ("createdBy", int c)
          , do
              guard (not (null r.readers))
              pure ("readers", ints r.readers)
          , do
              guard (not (null r.writers))
              pure ("writers", ints r.writers)
          ]

-- | The whole compiled graph: passes and resources as arrays indexed by id.
data ViewerGraph = ViewerGraph
  { passes :: [ViewerPass]
  , resources :: [ViewerResource]
  }
  deriving stock (Eq, Show, Generic)

{- | One pass. @reads@ / @writes@ are resource /entry/ ids (one per declared
access, in access order — not handles or version node ids), and @culled@ is
@not canExecute@.
-}
data ViewerPass = ViewerPass
  { id :: Int
  , name :: Text
  , culled :: Bool
  , reads :: [Int]
  , writes :: [Int]
  }
  deriving stock (Eq, Show, Generic)

{- | One resource /entry/, with its whole rename chain collapsed into a
single record. @readers@ / @writers@ are pass ids aggregated across every
version; @createdBy@ is the creating pass id, present only for transient
resources (imported resources have no creating pass).
-}
data ViewerResource = ViewerResource
  { id :: Int
  , name :: Text
  , description :: Text
  , transient :: Bool
  , createdBy :: Maybe Int
  , readers :: [Int]
  , writers :: [Int]
  }
  deriving stock (Eq, Show, Generic)

-- | Project a graph 'Snapshot' onto the viewer data model.
fromSnapshot :: Snapshot -> ViewerGraph
fromSnapshot s =
  ViewerGraph
    { passes = map mkPass s.passes
    , resources = map mkResource s.entries
    }
  where
    -- Resource-node id -> underlying entry id.
    entryOfNode :: IntMap Int
    entryOfNode = IntMap.fromList do
      n <- s.nodes
      pure (n.nodeId, n.resourceId)

    entryOf :: Int -> Int
    entryOf i = IntMap.findWithDefault (-1) i entryOfNode

    -- Entry id -> its version nodes, in node (version) order.
    nodesByEntry :: IntMap [NodeInfo]
    nodesByEntry = IntMap.fromListWith (flip (<>)) do
      n <- s.nodes
      pure (n.resourceId, [n])

    mkPass p =
      ViewerPass
        { id = p.passId
        , name = p.name
        , culled = not p.canExecute
        , reads = map (entryOf . accessId) p.reads
        , writes = map (entryOf . accessId) p.writes
        }

    -- Node id -> accessing pass ids, one per declared access, in
    -- registration order; concatenated per entry in node (version) order —
    -- the accumulation order noted above.
    readersOfNode = accessesOfNode do
      p <- s.passes
      i <- map accessId p.reads
      pure (i, p.passId)
    writersOfNode = accessesOfNode do
      p <- s.passes
      i <- map accessId p.writes
      pure (i, p.passId)
    creatorOfNode = IntMap.fromList do
      p <- s.passes
      i <- map someHandleId p.creates
      pure (i, p.passId)

    accessesOfNode :: [(Int, Int)] -> IntMap [Int]
    accessesOfNode accesses = IntMap.fromListWith (flip (<>)) do
      (i, pid) <- accesses
      pure (i, [pid])

    mkResource e =
      ViewerResource
        { id = e.entryId
        , name = maybe "" (.name) (listToMaybe nodes)
        , description = e.description
        , transient = not e.imported
        , createdBy = listToMaybe do
            n <- nodes
            maybeToList (IntMap.lookup n.nodeId creatorOfNode)
        , readers = accessesOf readersOfNode
        , writers = accessesOf writersOfNode
        }
      where
        nodes = IntMap.findWithDefault [] e.entryId nodesByEntry
        accessesOf m = concatMap (\n -> IntMap.findWithDefault [] n.nodeId m) nodes

-- tiny json encoder to avoid aeson deps

object :: [(Text, Builder)] -> Builder
object kvs = "{" <> mconcat (intersperse "," (map kv kvs)) <> "}"
  where
    kv (k, v) = string k <> ":" <> v

array :: [Builder] -> Builder
array vs = "[" <> mconcat (intersperse "," vs) <> "]"

ints :: [Int] -> Builder
ints = array . map int

int :: Int -> Builder
int = Builder.decimal

bool :: Bool -> Builder
bool b = if b then "true" else "false"

string :: Text -> Builder
string t = "\"" <> escape t <> "\""

escape :: Text -> Builder
escape t = case Text.break needsEscape t of
  (safe, rest) -> case Text.uncons rest of
    Nothing -> Builder.fromText safe
    Just (c, more) -> Builder.fromText safe <> escaped c <> escape more
  where
    needsEscape c = c == '"' || c == '\\' || c < ' '
    escaped = \case
      '"' -> "\\\""
      '\\' -> "\\\\"
      c -> unicode (fromEnum c) -- a control char, two hex digits suffice
    unicode n = "\\u00" <> (if n < 0x10 then "0" else "") <> Builder.hexadecimal n