packages feed

canontra-0.1.0.0: src/Canontra/Analysis/CallGraph.hs

{- |
Module      : Canontra.Analysis.CallGraph
Description : Static intra-module call graph extractor and topology analyzer.

This module extracts caller-to-callee invocation graphs from normalized IR.
It distinguishes local function calls, method dispatches, external module invocations,
and async await edges, and uses Tarjan's SCC algorithm to detect recursion cycles.
-}
module Canontra.Analysis.CallGraph
  ( CallerNode (..)
  , CalleeTarget (..)
  , CallEdge (..)
  , CallGraph (..)
  , buildCallGraph
  , formatCallGraph
  , findCallGraphSCCs
  ) where

import Control.DeepSeq (NFData)
import Data.Aeson (FromJSON, ToJSON)
import Data.List (sort, sortBy)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Ord (comparing)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)

import Canontra.IR.Declaration
import Canontra.IR.Dependency
import Canontra.IR.Expression
import Canontra.IR.Program

data CallerNode
  = CallTopLevel
  | CallFunction Text
  | CallMethod Text Text -- e.g. (Class name, Method name)
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (ToJSON, FromJSON, NFData)

data CalleeTarget
  = TargetLocal Text
  | TargetMethod Text Text
  | TargetImported Text Text -- e.g. (Module, Symbol)
  | TargetDynamic Expr
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (ToJSON, FromJSON, NFData)

data CallEdge = CallEdge
  { edgeCaller    :: CallerNode
  , edgeCallee    :: CalleeTarget
  , edgeCallCount :: Int
  , edgeIsAsync   :: Bool
  } deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (ToJSON, FromJSON, NFData)

data CallGraph = CallGraph
  { cgNodes :: [CallerNode]
  , cgEdges :: [CallEdge]
  } deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON, NFData)

-- | Extract the static intra-module call graph from a Program.
buildCallGraph :: Program -> CallGraph
buildCallGraph (Program modules _) =
  let allImports = concatMap modImports modules
      importMap = buildImportMap allImports
      (nodesList, edgesList) = foldMap (extractModuleCalls importMap) modules
      uniqueNodes = sort (Set.toList (Set.fromList (CallTopLevel : nodesList)))
      consolidatedEdges = consolidateEdges edgesList
  in CallGraph uniqueNodes consolidatedEdges

buildImportMap :: [ImportDecl] -> Map Text (Text, Text)
buildImportMap imps = Map.fromList (concatMap toEntry imps)
  where
    toEntry (ImportModule m alias) =
      let bound = maybe (lastPart m) id alias
      in [(bound, (m, ""))]
    toEntry (ImportFrom m target) = case target of
      ImportAll -> []
      ImportSymbols syms ->
        [ (maybe sym id alias, (m, sym))
        | (sym, alias) <- syms
        ]

    lastPart m = case T.splitOn "." m of
      [] -> m
      xs -> last xs

extractModuleCalls :: Map Text (Text, Text) -> Module -> ([CallerNode], [(CallerNode, CalleeTarget, Bool)])
extractModuleCalls impMap (Module _ _ decls stmts) =
  let topCalls = extractStmtCalls impMap CallTopLevel stmts
      (declNodes, declCalls) = foldMap (extractDeclCalls impMap) decls
  in (declNodes, topCalls ++ declCalls)

extractDeclCalls :: Map Text (Text, Text) -> Declaration -> ([CallerNode], [(CallerNode, CalleeTarget, Bool)])
extractDeclCalls impMap decl = case decl of
  DeclFunction fn ->
    let node = CallFunction (fnName fn)
        calls = extractStmtCalls impMap node (fnBody fn)
    in ([node], calls)

  DeclClass cls ->
    let (mNodes, mCalls) = foldMap (extractMethodCalls impMap (clsName cls)) (clsMethods cls)
    in (mNodes, mCalls)

  DeclStruct st ->
    let (mNodes, mCalls) = foldMap (extractMethodCalls impMap (stName st)) (stMethods st)
    in (mNodes, mCalls)

  DeclTrait tr ->
    let (mNodes, mCalls) = foldMap (extractMethodCalls impMap (trName tr)) (trMethods tr)
    in (mNodes, mCalls)

  DeclImpl imp ->
    let (mNodes, mCalls) = foldMap (extractMethodCalls impMap (impTarget imp)) (impMethods imp)
    in (mNodes, mCalls)

  DeclReceiver rc fn ->
    let node = CallMethod (rcTypeName rc) (fnName fn)
        calls = extractStmtCalls impMap node (fnBody fn)
    in ([node], calls)

  DeclVariable _ _ -> ([], [])
  DeclInterface _ -> ([], [])
  DeclTypeAlias _ _ -> ([], [])

extractMethodCalls :: Map Text (Text, Text) -> Text -> Function -> ([CallerNode], [(CallerNode, CalleeTarget, Bool)])
extractMethodCalls impMap className fn =
  let node = CallMethod className (fnName fn)
      calls = extractStmtCalls impMap node (fnBody fn)
  in ([node], calls)

extractStmtCalls :: Map Text (Text, Text) -> CallerNode -> [Stmt] -> [(CallerNode, CalleeTarget, Bool)]
extractStmtCalls impMap caller stmts =
  concatMap (extractSingleStmtCalls impMap caller) stmts

extractSingleStmtCalls :: Map Text (Text, Text) -> CallerNode -> Stmt -> [(CallerNode, CalleeTarget, Bool)]
extractSingleStmtCalls impMap caller stmt = case stmt of
  StmtAssign targets val     -> concatMap (extractExprCalls impMap caller False) (val : targets)
  StmtAnnAssign target ty v  -> concatMap (extractExprCalls impMap caller False) (target : ty : maybe [] pure v)
  StmtAugAssign t _ v        -> concatMap (extractExprCalls impMap caller False) [t, v]
  StmtExpr e                 -> extractExprCalls impMap caller False e
  StmtReturn me              -> maybe [] (extractExprCalls impMap caller False) me
  StmtIf c b e               -> extractExprCalls impMap caller False c ++ extractStmtCalls impMap caller b ++ extractStmtCalls impMap caller e
  StmtWhile c b e            -> extractExprCalls impMap caller False c ++ extractStmtCalls impMap caller b ++ extractStmtCalls impMap caller e
  StmtFor t i b e            -> extractExprCalls impMap caller False t ++ extractExprCalls impMap caller False i ++ extractStmtCalls impMap caller b ++ extractStmtCalls impMap caller e
  StmtAsyncFor t i b e       -> extractExprCalls impMap caller True t ++ extractExprCalls impMap caller True i ++ extractStmtCalls impMap caller b ++ extractStmtCalls impMap caller e
  StmtTry b h e f            ->
    extractStmtCalls impMap caller b ++
    concatMap (\(me, _, hb) -> maybe [] (extractExprCalls impMap caller False) me ++ extractStmtCalls impMap caller hb) h ++
    extractStmtCalls impMap caller e ++
    extractStmtCalls impMap caller f
  StmtWith items b           ->
    concatMap (\(e, ma) -> extractExprCalls impMap caller False e ++ maybe [] (extractExprCalls impMap caller False) ma) items ++
    extractStmtCalls impMap caller b
  StmtAsyncWith items b      ->
    concatMap (\(e, ma) -> extractExprCalls impMap caller True e ++ maybe [] (extractExprCalls impMap caller True) ma) items ++
    extractStmtCalls impMap caller b
  StmtAssert e me            -> extractExprCalls impMap caller False e ++ maybe [] (extractExprCalls impMap caller False) me
  StmtRaise me mc            -> maybe [] (extractExprCalls impMap caller False) me ++ maybe [] (extractExprCalls impMap caller False) mc
  StmtDelete es              -> concatMap (extractExprCalls impMap caller False) es
  StmtMatch s cs             ->
    extractExprCalls impMap caller False s ++
    concatMap (\mc -> extractExprCalls impMap caller False (mcPattern mc) ++ maybe [] (extractExprCalls impMap caller False) (mcGuard mc) ++ extractStmtCalls impMap caller (mcBody mc)) cs
  StmtGo e                   -> extractExprCalls impMap caller True e
  StmtDefer e                -> extractExprCalls impMap caller False e
  StmtChanSend ch val        -> extractExprCalls impMap caller False ch ++ extractExprCalls impMap caller False val
  StmtSelect cases           -> concatMap (\(sc, b) -> extractSelectCalls impMap caller sc ++ extractStmtCalls impMap caller b) cases
  _                          -> []
  where
    extractSelectCalls m c = \case
      SelectSend ch val -> extractExprCalls m c False ch ++ extractExprCalls m c False val
      SelectRecv _ ch   -> extractExprCalls m c False ch
      SelectDefault     -> []

extractExprCalls :: Map Text (Text, Text) -> CallerNode -> Bool -> Expr -> [(CallerNode, CalleeTarget, Bool)]
extractExprCalls impMap caller isAsync expr = case expr of
  ExprCall target args kwargs ->
    let targetCallee = resolveTarget impMap target
        thisEdge = (caller, targetCallee, isAsync)
        nestedTarget = extractExprCalls impMap caller isAsync target
        nestedArgs = concatMap (extractExprCalls impMap caller False) args
        nestedKwargs = concatMap (extractExprCalls impMap caller False . snd) kwargs
    in thisEdge : (nestedTarget ++ nestedArgs ++ nestedKwargs)

  ExprAwait inner ->
    extractExprCalls impMap caller True inner

  ExprBinary _ e1 e2 ->
    extractExprCalls impMap caller isAsync e1 ++ extractExprCalls impMap caller isAsync e2

  ExprUnary _ e ->
    extractExprCalls impMap caller isAsync e

  ExprAttr e _ ->
    extractExprCalls impMap caller isAsync e

  ExprSubscript e idx ->
    extractExprCalls impMap caller isAsync e ++ extractExprCalls impMap caller isAsync idx

  ExprSlice ms me mst ->
    concatMap (maybe [] (extractExprCalls impMap caller isAsync)) [ms, me, mst]

  ExprList es -> concatMap (extractExprCalls impMap caller isAsync) es
  ExprTuple es -> concatMap (extractExprCalls impMap caller isAsync) es
  ExprDict pairs -> concatMap (\(k, v) -> extractExprCalls impMap caller isAsync k ++ extractExprCalls impMap caller isAsync v) pairs
  ExprSet es -> concatMap (extractExprCalls impMap caller isAsync) es
  ExprLambda _ body -> extractExprCalls impMap caller isAsync body
  ExprTernary c t f -> extractExprCalls impMap caller isAsync c ++ extractExprCalls impMap caller isAsync t ++ extractExprCalls impMap caller isAsync f
  ExprListComp item comps -> extractExprCalls impMap caller isAsync item ++ concatMap (extractCompCalls impMap caller isAsync) comps
  ExprDictComp k v comps -> extractExprCalls impMap caller isAsync k ++ extractExprCalls impMap caller isAsync v ++ concatMap (extractCompCalls impMap caller isAsync) comps
  ExprSetComp item comps -> extractExprCalls impMap caller isAsync item ++ concatMap (extractCompCalls impMap caller isAsync) comps
  ExprGenerator item comps -> extractExprCalls impMap caller isAsync item ++ concatMap (extractCompCalls impMap caller isAsync) comps
  ExprWalrus _ val -> extractExprCalls impMap caller isAsync val
  ExprYield me -> maybe [] (extractExprCalls impMap caller isAsync) me
  ExprYieldFrom e -> extractExprCalls impMap caller isAsync e
  ExprStarred e -> extractExprCalls impMap caller isAsync e
  ExprKwStarred e -> extractExprCalls impMap caller isAsync e
  ExprOptChain e _ -> extractExprCalls impMap caller isAsync e
  ExprNullish e1 e2 -> extractExprCalls impMap caller isAsync e1 ++ extractExprCalls impMap caller isAsync e2
  ExprChanRecv ch -> extractExprCalls impMap caller isAsync ch
  ExprTryOp e -> extractExprCalls impMap caller isAsync e
  ExprMacroCall _ args -> concatMap (extractExprCalls impMap caller isAsync) args
  ExprJSX _ attrs children -> concatMap (extractExprCalls impMap caller isAsync . snd) attrs ++ concatMap (extractExprCalls impMap caller isAsync) children
  _ -> []
  where
    extractCompCalls m c a (CompFor t iter ifs) =
      extractExprCalls m c a t ++ extractExprCalls m c a iter ++ concatMap (extractExprCalls m c a) ifs

resolveTarget :: Map Text (Text, Text) -> Expr -> CalleeTarget
resolveTarget impMap expr = case expr of
  ExprId name ->
    case Map.lookup name impMap of
      Just (modName, symName) ->
        let actualSym = if T.null symName then name else symName
        in TargetImported modName actualSym
      Nothing -> TargetLocal name

  ExprAttr (ExprId obj) method ->
    case Map.lookup obj impMap of
      Just (modName, _) -> TargetImported modName method
      Nothing           -> TargetMethod obj method

  ExprAttr target method ->
    TargetMethod (T.pack (show target)) method

  _ -> TargetDynamic expr

consolidateEdges :: [(CallerNode, CalleeTarget, Bool)] -> [CallEdge]
consolidateEdges rawEdges =
  let grouped = Map.fromListWith (+) [ ((c, t, a), 1 :: Int) | (c, t, a) <- rawEdges ]
      edges = [ CallEdge c t cnt a | ((c, t, a), cnt) <- Map.toList grouped ]
  in sortBy (comparing (\e -> (edgeCaller e, edgeCallee e, edgeIsAsync e))) edges

-- | Find strongly connected components in the CallGraph using Tarjan's SCC algorithm.
findCallGraphSCCs :: CallGraph -> [[CallerNode]]
findCallGraphSCCs cg =
  let adj = buildAdjacency (cgEdges cg)
      nodes = cgNodes cg
  in sccTarjan nodes adj

buildAdjacency :: [CallEdge] -> Map CallerNode [CallerNode]
buildAdjacency edges =
  Map.fromListWith (++) [ (edgeCaller e, [calleeToCaller (edgeCallee e)]) | e <- edges ]
  where
    calleeToCaller (TargetLocal name) = CallFunction name
    calleeToCaller (TargetMethod cls m) = CallMethod cls m
    calleeToCaller _ = CallTopLevel

sccTarjan :: [CallerNode] -> Map CallerNode [CallerNode] -> [[CallerNode]]
sccTarjan nodes adj =
  let step (visited, sccs) node
        | Set.member node visited = (visited, sccs)
        | otherwise =
            let comp = dfs node visited []
                newVisited = Set.union visited (Set.fromList comp)
            in (newVisited, comp : sccs)
      (_, allSccs) = foldl step (Set.empty, []) nodes
  in filter (not . null) allSccs
  where
    dfs curr vis acc
      | Set.member curr vis = acc
      | otherwise =
          let neighbors = Map.findWithDefault [] curr adj
              newVis = Set.insert curr vis
          in foldl (\a n -> dfs n newVis a) (curr : acc) neighbors

formatCallGraph :: CallGraph -> Text
formatCallGraph cg =
  T.unlines $
    [ "Call Graph (" <> T.pack (show (length (cgNodes cg))) <> " nodes, " <> T.pack (show (length (cgEdges cg))) <> " edges)"
    , "---------------------------------------------------------"
    ] ++
    map formatEdge (cgEdges cg)
  where
    formatEdge (CallEdge caller callee cnt isAsync) =
      let asyncStr = if isAsync then " [async]" else ""
          countStr = if cnt > 1 then " (" <> T.pack (show cnt) <> "x)" else ""
      in "  " <> formatCaller caller <> " --> " <> formatCallee callee <> asyncStr <> countStr

    formatCaller CallTopLevel = "<top-level>"
    formatCaller (CallFunction fn) = "def " <> fn
    formatCaller (CallMethod cls m) = cls <> "." <> m

    formatCallee (TargetLocal name) = name
    formatCallee (TargetMethod cls m) = if T.null cls then m else cls <> "." <> m
    formatCallee (TargetImported m s) = m <> "." <> s
    formatCallee (TargetDynamic e) = "<dynamic: " <> T.pack (show e) <> ">"