diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgToDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgToDot.kt index 49f61afef..6f75b4b52 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgToDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/BlockCfgToDot.kt @@ -16,7 +16,15 @@ package org.jacodb.ets.utils +import org.jacodb.ets.model.BasicBlock +import org.jacodb.ets.model.EtsAssignStmt import org.jacodb.ets.model.EtsBlockCfg +import org.jacodb.ets.model.EtsCallExpr +import org.jacodb.ets.model.EtsCallStmt +import org.jacodb.ets.model.EtsStmt +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths private fun String.htmlEncode(): String = this .replace("&", "&") @@ -24,7 +32,9 @@ private fun String.htmlEncode(): String = this .replace(">", ">") .replace("\"", """) -fun EtsBlockCfg.toDot(useHtml: Boolean = true): String { +fun EtsBlockCfg.toDot( + useHtml: Boolean = true +): String { val lines = mutableListOf() lines += "digraph cfg {" lines += " node [shape=${if (useHtml) "none" else "rect"} fontname=\"monospace\"]" @@ -65,3 +75,187 @@ fun EtsBlockCfg.toDot(useHtml: Boolean = true): String { lines += "}" return lines.joinToString("\n") } + +/** + * An interprocedural CFG that contains: + * - the main control-flow graph (main) + * - all callee CFGs discovered so far at call sites (keyed by statement and its parent block id) + */ +data class InterproceduralCfg( + val main: EtsBlockCfg, + val callees: Map, EtsBlockCfg> +) + +/** + * Render the interprocedural CFG (main + callees) as a single Graphviz DOT document, + * highlighting the execution path and current statement, and drawing + * each callee in its own dashed subgraph connected back to the call site. + */ +fun InterproceduralCfg.toHighlightedDotWithCalls( + pathStmts: Set, + currentStmt: EtsStmt?, + useHtml: Boolean = true +): String { + val lines = mutableListOf() + lines += "digraph world {" + lines += " compound=true" + lines += " node [shape=${if (useHtml) "none" else "rect"} fontname=\"monospace\"]" + + // --- 1) Main CFG with ports on call statements --- + for (block in main.blocks) { + val nodeId = "M_${block.id}" + // compute all call hashes in this block + val callHashes = callees.keys + .filter { it.second == block.id } + .map { sanitize(it.first.hashCode()) } + .toSet() + + if (useHtml) { + // build HTML table rows, adding port attribute on call lines + val rows = block.statements.joinToString(separator = "") { stmt -> + val txt = stmt.toDotLabel().htmlEncode() + val bg = when (stmt) { + currentStmt -> "lightblue" + in pathStmts -> "yellow" + else -> "white" + } + val stmtHash = sanitize(stmt.hashCode()) + val portAttr = if (stmtHash in callHashes) " port=\"p$stmtHash\"" else "" + "$txt" + } + val table = buildString { + append("") + append("") + append(rows) + append("
Block #${block.id}
") + } + lines += " $nodeId [label=<$table>];" + } else { + val lbl = buildPlainLabel(block, pathStmts, currentStmt) + lines += " $nodeId [label=\"$lbl\"];" + } + } + // Main CFG edges + for ((bid, succs) in main.successors) { + val from = "M_$bid" + when (succs.size) { + 1 -> lines += " $from -> M_${succs[0]};" + 2 -> { + val (t, f) = succs + lines += " $from -> M_$t [label=\"true\"];" + lines += " $from -> M_$f [label=\"false\"];" + } + } + } + + // --- 2) Callee clusters with call-edge from port --- + for ((key, cfg) in callees) { + val (stmt, parentBlock) = key + val h = sanitize(stmt.hashCode()) + val clusterName = "cluster_${h}_B${parentBlock}" + // method signature label + val methodSig = cfg.entries.first().method.signature + // open subgraph + lines += " subgraph \"$clusterName\" {" + lines += " label=\"$methodSig\";" + lines += " style=dashed;" + + // render callee nodes + for (blk in cfg.blocks) { + val calleeNode = "C_${h}_${blk.id}" + if (useHtml) { + val table = buildHtmlTable(blk, pathStmts, currentStmt) + lines += " $calleeNode [label=<$table>];" + } else { + val lbl = buildPlainLabel(blk, pathStmts, currentStmt) + lines += " $calleeNode [label=\"$lbl\"];" + } + } + // render callee edges + for ((bid, succs) in cfg.successors) { + val from = "C_${h}_$bid" + when (succs.size) { + 1 -> lines += " $from -> C_${h}_${succs[0]};" + 2 -> { + val (t, f) = succs + lines += " $from -> C_${h}_$t [label=\"true\"];" + lines += " $from -> C_${h}_$f [label=\"false\"];" + } + } + } + lines += " }" + + // connect from the specific port on the caller block + // connect from the specific port on the caller block using tailport + val caller = "M_${parentBlock}" + val entryId = cfg.blocks.first().id + val calleeEntry = "C_${h}_$entryId" + val stmtHash = sanitize(stmt.hashCode()) + lines += " $caller:p$stmtHash -> $calleeEntry [tailport=\"p$stmtHash\" ltail=\"$clusterName\" lhead=\"$clusterName\" style=dotted label=\"call\"];" + } + + lines += "}" + return lines.joinToString("\n") +} + +private fun buildHtmlTable( + block: BasicBlock, + pathStmts: Set, + currentStmt: EtsStmt? +): String { + var i = 0 + val rows = block.statements.joinToString(separator = "") { stmt -> + val txt = stmt.toDotLabel().htmlEncode() + val stmtHash = sanitize(stmt.hashCode()) + val portAttr = if (stmt.callExpr != null) " port=\"p$stmtHash\"" else "" + + val bg = when (stmt) { + currentStmt -> "lightblue" + in pathStmts -> "yellow" + else -> "white" + } + i++ + "$txt" + } + return "" + + "" + rows + + "
Block #${block.id}
" +} + +private fun buildPlainLabel( + block: BasicBlock, + pathStmts: Set, + currentStmt: EtsStmt? +): String { + val body = block.statements.joinToString(separator = "") { stmt -> + val raw = stmt.toDotLabel() + val pfx = when (stmt) { + currentStmt -> "[▶] " + in pathStmts -> "[·] " + else -> "" + } + "$pfx$raw\\l" + } + return "Block #${block.id}\\n" + body +} + +fun renderDotOverwrite( + dot: String, + outputDir: Path = Paths.get("."), + baseName: String = "interproc_cfg", + dotCmd: String = "dot", + viewerCmd: String = when { + System.getProperty("os.name").startsWith("Mac") -> "open" + System.getProperty("os.name").startsWith("Win") -> "cmd /c start" + else -> "xdg-open" + } +) { + val dotFile = outputDir.resolve("$baseName.dot") + val outSvg = outputDir.resolve("$baseName.svg") + Files.write(dotFile, dot.toByteArray()) + Runtime.getRuntime().exec("$dotCmd -Tsvg -o $outSvg $dotFile").waitFor() + Runtime.getRuntime().exec("$viewerCmd $outSvg").waitFor() +} + +// helper to sanitize negative hash codes for Graphviz IDs +fun sanitize(id: Int): String = id.toString().let { if (it.startsWith("-")) "N${it.substring(1)}" else it }