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 6dc847895..52730bcdd 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,19 +16,7 @@ package org.jacodb.ets.utils -import org.jacodb.ets.model.BasicBlock import org.jacodb.ets.model.EtsBlockCfg -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("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\\\"", """) - .replace("\"", """) fun EtsBlockCfg.toDot( useHtml: Boolean = true, @@ -73,187 +61,3 @@ fun EtsBlockCfg.toDot( 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().location.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 } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/DotUtils.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/DotUtils.kt new file mode 100644 index 000000000..721740c11 --- /dev/null +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/DotUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jacodb.ets.utils + +/** + * Encode a string for use in HTML, replacing special characters with their HTML entities. + * This is needed for rendering text in Graphviz nodes with HTML labels. + * + * For example, `"Hello & "` becomes `"Hello & <world>"`. + */ +internal fun String.htmlEncode(): String = this + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\\\"", """) + .replace("\"", """) + +/** + * Sanitize an integer ID for use as a Graphviz node ID. + * Negative IDs are prefixed with "N" to avoid issues with Graphviz. + * + * For example, `-123` becomes `"N123"`, while `456` remains `"456"`. + */ +internal fun sanitize(id: Int): String { + val s = id.toString() + return if (s.startsWith("-")) "N${s.substring(1)}" else s +} diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt new file mode 100644 index 000000000..9694c720c --- /dev/null +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/InterproceduralCfg.kt @@ -0,0 +1,205 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jacodb.ets.utils + +import org.jacodb.ets.model.BasicBlock +import org.jacodb.ets.model.EtsBlockCfg +import org.jacodb.ets.model.EtsStmt +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +/** + * 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().location.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() +} diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LinearCfgExt.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LinearCfgExt.kt deleted file mode 100644 index 1feecae24..000000000 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LinearCfgExt.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright 2022 UnitTestBot contributors (utbot.org) - *

- * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - *

- * http://www.apache.org/licenses/LICENSE-2.0 - *

- * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.jacodb.ets.utils - -import info.leadinglight.jdot.Edge -import info.leadinglight.jdot.Graph -import info.leadinglight.jdot.Node -import info.leadinglight.jdot.enums.Color -import info.leadinglight.jdot.enums.Shape -import info.leadinglight.jdot.impl.Util -import org.jacodb.ets.model.EtsIfStmt -import org.jacodb.ets.model.EtsLinearCfg -import org.jacodb.ets.model.EtsStmt -import java.io.File -import java.nio.file.Files -import java.nio.file.Path - -private const val DEFAULT_DOT_CMD = "dot" - -fun EtsLinearCfg.view( - viewerCmd: String = if (System.getProperty("os.name").startsWith("Windows")) "start" else "xdg-open", - dotCmd: String = DEFAULT_DOT_CMD, - viewCatchConnections: Boolean = true, -) { - val path = toFile(null, dotCmd, viewCatchConnections) - Util.sh(arrayOf(viewerCmd, "file://$path")) -} - -fun EtsLinearCfg.toFile( - file: File? = null, - dotCmd: String = DEFAULT_DOT_CMD, - viewCatchConnections: Boolean = true, -): Path { - Graph.setDefaultCmd(dotCmd) - - val graph = Graph("etsCfg") - .setBgColor(Color.X11.transparent) - .setFontSize(12.0) - .setFontName("Fira Mono") - - val nodes = mutableMapOf() - for ((index, inst) in instructions.withIndex()) { - val label = inst.toString().replace("\"", "\\\"") - val node = Node("$index") - .setShape(Shape.box) - .setLabel(label) - .setFontSize(12.0) - nodes[inst] = node - graph.addNode(node) - } - - for ((inst, node) in nodes) { - when (inst) { - is EtsIfStmt -> { - val successors = successors(inst).toList() - // check(successors.size == 2) - check(successors.size <= 2) - graph.addEdge( - Edge(node.name, nodes[successors[0]]!!.name) - .also { - it.setLabel("false") - } - ) - if (successors.size == 2) { - graph.addEdge( - Edge(node.name, nodes[successors[1]]!!.name) - .also { - it.setLabel("true") - } - ) - } - } - - else -> for (successor in successors(inst)) { - graph.addEdge(Edge(node.name, nodes[successor]!!.name)) - } - } - if (viewCatchConnections) { - // TODO: uncomment when `catchers` are properly implemented - // for (catcher in catchers(inst)) { - // graph.addEdge(Edge(node.name, nodes[catcher]!!.name).also { - // // it.setLabel("catch ${catcher.throwable.type}") - // it.setLabel("catch") - // it.setStyle(Style.Edge.dashed) - // }) - // } - } - } - - val outFile = graph.dot2file("svg") - val newFile = "${outFile.removeSuffix(".out")}.svg" - val resultingFile = file?.toPath() ?: File(newFile).toPath() - Files.move(File(outFile).toPath(), resultingFile) - return resultingFile -} diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ViewDot.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ViewDot.kt index cd099d221..8edb162b4 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ViewDot.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/ViewDot.kt @@ -16,6 +16,10 @@ package org.jacodb.ets.utils +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.model.EtsBlockCfg +import org.jacodb.ets.model.EtsFile +import org.jacodb.ets.model.EtsLinearCfg import java.nio.file.Path import kotlin.io.path.createTempDirectory import kotlin.io.path.div @@ -46,3 +50,19 @@ fun view( fun T.view(dot: (T) -> String) { view(dot(this)) } + +fun EtsFile.view() { + view(toDot()) +} + +fun EtsFileDto.view() { + view(toDot()) +} + +fun EtsBlockCfg.view() { + view(toDot()) +} + +fun EtsLinearCfg.view() { + view(toDot()) +}