├── .gitignore ├── README.md ├── build.gradle ├── gradle.properties.example ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src └── main ├── kotlin └── callgraph │ ├── CallGraphToolWindow.form │ ├── CallGraphToolWindow.kt │ ├── CallGraphToolWindowFactory.kt │ ├── CallGraphToolWindowProjectService.kt │ ├── Canvas.kt │ ├── CanvasBuilder.kt │ ├── CanvasConfig.kt │ ├── Colors.kt │ ├── ComboBoxOptions.kt │ ├── Dependency.kt │ ├── Edge.kt │ ├── Graph.kt │ ├── MouseEventHandler.kt │ ├── Node.kt │ ├── Utils.kt │ ├── ViewDownstreamAction.kt │ ├── ViewUpstreamAction.kt │ └── ViewUpstreamDownstreamAction.kt └── resources ├── META-INF ├── plugin.xml ├── pluginIcon.svg └── pluginIcon_dark.svg └── icons ├── build.png ├── color.png ├── compress-x.png ├── compress-y.png ├── downstream.png ├── expand-x.png ├── expand-y.png ├── filter.png ├── fit-ratio.png ├── fit-viewport.png ├── icon.svg ├── navigation.png ├── node-selection.png ├── play.png ├── search.png ├── statistics.png ├── upstream-downstream.png ├── upstream.png ├── view-source-code.png └── view.png /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | .idea 3 | /build/ 4 | .DS_Store 5 | 6 | # Ignore Gradle GUI config 7 | gradle-app.setting 8 | 9 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 10 | !gradle-wrapper.jar 11 | 12 | # Cache of project 13 | .gradletasknamecache 14 | 15 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 16 | # gradle/wrapper/gradle-wrapper.properties 17 | 18 | # JetBrains account credentials 19 | gradle.properties 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Call Graph Intellij Plugin 2 | 3 | This is the open-sourced repo for the [IntelliJ Call Graph plugin](https://plugins.jetbrains.com/plugin/12304-call-graph). Please feel free to leave comments, feedback, and bug reports (on the [Issues tab](https://github.com/Chentai-Kao/call-graph-plugin/issues)). 4 | 5 | Pull requests are welcome! 6 | 7 | ## How to build the plugin (using IntelliJ) 8 | 1. Install IntelliJ from the [official website](https://www.jetbrains.com/idea/download/) or whatever makes sense for your operating system. 9 | 2. Copy the file `gradle.properties.example` and rename it to `gradle.properties`. This file holds the credential (publish token) for you to publish your local build to Idea plugin repository, and is ignored in version control. This file is required in the Gradle build process, but feel free to leave the sample token value as is. Just remember to replace it with the actual token if you decide to upload the build to the Idea plugin repository. 10 | 3. Use IntelliJ to **Open** the root folder of this repo. A Gradle daemon should start building the project. 11 | 4. In the Gradle menu, select `call-graph-plugin / Tasks / intelliJ / buildPlugin` to build the plugin. 12 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java' 3 | id 'org.jetbrains.intellij' version '1.17.2' 4 | id "org.jetbrains.kotlin.jvm" version "1.9.22" 5 | } 6 | 7 | group 'com.jetbrains' 8 | //version '0.0.0' // plugin version, update me! (specified in plugins.xml instead) 9 | 10 | repositories { 11 | mavenCentral() 12 | } 13 | 14 | dependencies { 15 | implementation 'guru.nidi:graphviz-java:0.18.1' 16 | implementation 'org.apache.logging.log4j:log4j-core:2.23.0' 17 | implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.23.0' 18 | testImplementation 'junit:junit:4.13.2' 19 | } 20 | 21 | apply plugin: 'idea' 22 | apply plugin: 'org.jetbrains.intellij' 23 | apply plugin: 'kotlin' 24 | 25 | intellij { 26 | version.set('2023.3') // Intellij version to build against 27 | pluginName.set('call-graph') 28 | intellij.updateSinceUntilBuild.set(false) // Disables updating since-build attribute in plugin.xml 29 | plugins.set(['java']) // declaring a dependency on the Java functionality 30 | } 31 | 32 | publishPlugin { 33 | token.set(System.getenv("ORG_GRADLE_PROJECT_intellijPublishToken")) 34 | } 35 | -------------------------------------------------------------------------------- /gradle.properties.example: -------------------------------------------------------------------------------- 1 | intellijPublishToken=your-publish-token-here-looks-like-perm:A1B2C3 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | # suppress inspection "UnusedProperty" for whole file 2 | #Thu Jan 03 23:30:10 CST 2019 3 | distributionBase=GRADLE_USER_HOME 4 | distributionPath=wrapper/dists 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'call-graph-plugin' 2 | 3 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/CallGraphToolWindow.form: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 |
610 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/CallGraphToolWindow.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.ide.util.EditorHelper 4 | import com.intellij.psi.PsiMethod 5 | import java.awt.Dimension 6 | import java.awt.event.KeyEvent 7 | import java.awt.event.KeyListener 8 | import java.awt.geom.Point2D 9 | import javax.swing.* 10 | 11 | class CallGraphToolWindow { 12 | private lateinit var runButton: JButton 13 | private lateinit var callGraphToolWindowContent: JPanel 14 | private lateinit var canvasPanel: JPanel 15 | private lateinit var projectScopeButton: JRadioButton 16 | private lateinit var moduleScopeButton: JRadioButton 17 | private lateinit var directoryScopeButton: JRadioButton 18 | private lateinit var directoryScopeTextField: JTextField 19 | private lateinit var moduleScopeComboBox: JComboBox 20 | private lateinit var mainTabbedPanel: JTabbedPane 21 | private lateinit var includeTestFilesCheckBox: JCheckBox 22 | private lateinit var buildTypeLabel: JLabel 23 | private lateinit var loadingProgressBar: JProgressBar 24 | private lateinit var showOnlyUpstreamButton: JButton 25 | private lateinit var showOnlyDownstreamButton: JButton 26 | private lateinit var showOnlyUpstreamDownstreamButton: JButton 27 | private lateinit var upstreamDownstreamScopeCheckbox: JCheckBox 28 | private lateinit var fitGraphToViewButton: JButton 29 | private lateinit var fitGraphToBestRatioButton: JButton 30 | private lateinit var increaseXGridButton: JButton 31 | private lateinit var decreaseXGridButton: JButton 32 | private lateinit var increaseYGridButton: JButton 33 | private lateinit var decreaseYGridButton: JButton 34 | private lateinit var statsLabel: JLabel 35 | private lateinit var viewSourceCodeButton: JButton 36 | private lateinit var viewPackageNameComboBox: JComboBox 37 | private lateinit var viewFilePathComboBox: JComboBox 38 | private lateinit var nodeSelectionComboBox: JComboBox 39 | private lateinit var searchTextField: JTextField 40 | private lateinit var nodeColorComboBox: JComboBox 41 | private lateinit var filterExternalCheckbox: JCheckBox 42 | private lateinit var filterAccessPublicCheckbox: JCheckBox 43 | private lateinit var filterAccessProtectedCheckbox: JCheckBox 44 | private lateinit var filterAccessPackageLocalCheckbox: JCheckBox 45 | private lateinit var filterAccessPrivateCheckbox: JCheckBox 46 | 47 | private val canvasBuilder = CanvasBuilder() 48 | private val canvas: Canvas = Canvas(this) 49 | private val focusedMethods = mutableSetOf() 50 | private val filterCheckboxes = listOf( 51 | this.filterExternalCheckbox, 52 | this.filterAccessPublicCheckbox, 53 | this.filterAccessProtectedCheckbox, 54 | this.filterAccessPackageLocalCheckbox, 55 | this.filterAccessPrivateCheckbox 56 | ) 57 | 58 | init { 59 | // drop-down options 60 | val viewComboBoxOptions = listOf( 61 | ComboBoxOptions.VIEW_ALWAYS, 62 | ComboBoxOptions.VIEW_HOVERED, 63 | ComboBoxOptions.VIEW_NEVER 64 | ) 65 | viewComboBoxOptions.forEach { this.viewPackageNameComboBox.addItem(it.text) } 66 | this.viewPackageNameComboBox.selectedItem = ComboBoxOptions.VIEW_HOVERED.text 67 | viewComboBoxOptions.forEach { this.viewFilePathComboBox.addItem(it.text) } 68 | this.viewFilePathComboBox.selectedItem = ComboBoxOptions.VIEW_NEVER.text 69 | val nodeSelectionComboBoxOptions = listOf( 70 | ComboBoxOptions.NODE_SELECTION_SINGLE, 71 | ComboBoxOptions.NODE_SELECTION_MULTIPLE 72 | ) 73 | nodeSelectionComboBoxOptions.forEach { this.nodeSelectionComboBox.addItem(it.text) } 74 | this.nodeSelectionComboBox.selectedItem = ComboBoxOptions.NODE_SELECTION_SINGLE.text 75 | val nodeColorComboBoxOptions = listOf( 76 | ComboBoxOptions.NODE_COLOR_NONE, 77 | ComboBoxOptions.NODE_COLOR_ACCESS, 78 | ComboBoxOptions.NODE_COLOR_CLASS 79 | ) 80 | nodeColorComboBoxOptions.forEach { option -> this.nodeColorComboBox.addItem(option.text) } 81 | this.nodeColorComboBox.selectedItem = ComboBoxOptions.NODE_COLOR_NONE.text 82 | 83 | // search field 84 | this.searchTextField.addKeyListener(object: KeyListener { 85 | override fun keyTyped(keyEvent: KeyEvent) { 86 | } 87 | 88 | override fun keyPressed(keyEvent: KeyEvent) { 89 | } 90 | 91 | override fun keyReleased(keyEvent: KeyEvent) { 92 | this@CallGraphToolWindow.canvas.repaint() 93 | } 94 | }) 95 | this.filterCheckboxes.forEach { it.addActionListener { this.canvas.filterChangeHandler() } } 96 | 97 | // click handlers for buttons 98 | this.projectScopeButton.addActionListener { projectScopeButtonHandler() } 99 | this.moduleScopeButton.addActionListener { moduleScopeButtonHandler() } 100 | this.directoryScopeButton.addActionListener { directoryScopeButtonHandler() } 101 | this.runButton.addActionListener { 102 | this.focusedMethods.clear() 103 | run(getSelectedBuildType()) 104 | } 105 | this.viewPackageNameComboBox.addActionListener { this.canvas.repaint() } 106 | this.viewFilePathComboBox.addActionListener { this.canvas.repaint() } 107 | this.nodeColorComboBox.addActionListener { this.canvas.repaint() } 108 | this.showOnlyUpstreamButton.addActionListener { run(CanvasConfig.BuildType.UPSTREAM) } 109 | this.showOnlyDownstreamButton.addActionListener { run(CanvasConfig.BuildType.DOWNSTREAM) } 110 | this.showOnlyUpstreamDownstreamButton.addActionListener { run(CanvasConfig.BuildType.UPSTREAM_DOWNSTREAM) } 111 | this.viewSourceCodeButton.addActionListener { viewSourceCodeHandler() } 112 | this.fitGraphToViewButton.addActionListener { this.canvas.fitCanvasToView() } 113 | this.fitGraphToBestRatioButton.addActionListener { this.canvas.fitCanvasToBestRatio() } 114 | this.increaseXGridButton.addActionListener { gridSizeButtonHandler(isXGrid = true, isIncrease = true) } 115 | this.decreaseXGridButton.addActionListener { gridSizeButtonHandler(isXGrid = true, isIncrease = false) } 116 | this.increaseYGridButton.addActionListener { gridSizeButtonHandler(isXGrid = false, isIncrease = true) } 117 | this.decreaseYGridButton.addActionListener { gridSizeButtonHandler(isXGrid = false, isIncrease = false) } 118 | 119 | // attach event listeners to canvas 120 | val mouseEventHandler = MouseEventHandler(this.canvas) 121 | this.canvas.addMouseListener(mouseEventHandler) 122 | this.canvas.addMouseMotionListener(mouseEventHandler) 123 | this.canvas.addMouseWheelListener(mouseEventHandler) 124 | this.canvas.isVisible = false 125 | this.canvasPanel.add(this.canvas) 126 | } 127 | 128 | fun getContent() = this.callGraphToolWindowContent 129 | 130 | fun isFocusedMethod(method: PsiMethod) = this.focusedMethods.contains(method) 131 | 132 | fun toggleFocusedMethod(method: PsiMethod): CallGraphToolWindow { 133 | if (this.focusedMethods.contains(method)) { 134 | // clicked on a selected node 135 | this.focusedMethods.remove(method) 136 | } else { 137 | // clicked on an un-selected node 138 | if (getSelectedComboBoxOption(this.nodeSelectionComboBox) == ComboBoxOptions.NODE_SELECTION_SINGLE) { 139 | this.focusedMethods.clear() 140 | } 141 | this.focusedMethods.add(method) 142 | } 143 | enableFocusedMethodButtons() 144 | return this 145 | } 146 | 147 | fun clearFocusedMethods(): CallGraphToolWindow { 148 | this.focusedMethods.clear() 149 | enableFocusedMethodButtons() 150 | return this 151 | } 152 | 153 | fun resetProgressBar(maximum: Int) { 154 | this.loadingProgressBar.isIndeterminate = false 155 | this.loadingProgressBar.maximum = maximum 156 | this.loadingProgressBar.value = 0 157 | } 158 | 159 | fun incrementProgressBar() { 160 | val newValue = this.loadingProgressBar.value + 1 161 | this.loadingProgressBar.value = newValue 162 | val text = 163 | if (this.loadingProgressBar.isIndeterminate) "$newValue functions processed" 164 | else "$newValue functions processed (total ${this.loadingProgressBar.maximum})" 165 | this.loadingProgressBar.string = text 166 | } 167 | 168 | fun isRenderFunctionPackageName(isNodeHovered: Boolean): Boolean { 169 | val option = getSelectedComboBoxOption(this.viewPackageNameComboBox) 170 | return option == ComboBoxOptions.VIEW_ALWAYS || (option == ComboBoxOptions.VIEW_HOVERED && isNodeHovered) 171 | } 172 | 173 | fun isRenderFunctionFilePath(isNodeHovered: Boolean): Boolean { 174 | val option = getSelectedComboBoxOption(this.viewFilePathComboBox) 175 | return option == ComboBoxOptions.VIEW_ALWAYS || (option == ComboBoxOptions.VIEW_HOVERED && isNodeHovered) 176 | } 177 | 178 | fun isQueried(text: String): Boolean { 179 | val searchQuery = this.searchTextField.text.toLowerCase() 180 | return searchQuery.isNotEmpty() && text.toLowerCase().contains(searchQuery) 181 | } 182 | 183 | fun isNodeColorByAccess() = getSelectedComboBoxOption(this.nodeColorComboBox) == ComboBoxOptions.NODE_COLOR_ACCESS 184 | 185 | fun isNodeColorByClassName() = getSelectedComboBoxOption(this.nodeColorComboBox) == ComboBoxOptions.NODE_COLOR_CLASS 186 | 187 | fun isFilterExternalChecked() = this.filterExternalCheckbox.isSelected 188 | 189 | fun isFilterAccessPublicChecked() = this.filterAccessPublicCheckbox.isSelected 190 | 191 | fun isFilterAccessProtectedChecked() = this.filterAccessProtectedCheckbox.isSelected 192 | 193 | fun isFilterAccessPackageLocalChecked() = this.filterAccessPackageLocalCheckbox.isSelected 194 | 195 | fun isFilterAccessPrivateChecked() = this.filterAccessPrivateCheckbox.isSelected 196 | 197 | fun isLegendNeeded() = getSelectedComboBoxOption(this.nodeColorComboBox) != ComboBoxOptions.NODE_COLOR_NONE 198 | 199 | fun getCanvasSize(): Dimension = this.canvasPanel.size 200 | 201 | fun run(buildType: CanvasConfig.BuildType) { 202 | val project = Utils.getActiveProject() 203 | if (project != null) { 204 | Utils.runBackgroundTask(project, Runnable { 205 | // set up the config object 206 | val canvasConfig = CanvasConfig( 207 | project, 208 | buildType, 209 | this.canvas, 210 | this@CallGraphToolWindow.moduleScopeComboBox.selectedItem as String? ?: "", 211 | this@CallGraphToolWindow.directoryScopeTextField.text, 212 | this@CallGraphToolWindow.focusedMethods, 213 | this@CallGraphToolWindow 214 | ) 215 | // start building graph 216 | setupUiBeforeRun(buildType) 217 | this@CallGraphToolWindow.canvasBuilder.build(canvasConfig) 218 | setupUiAfterRun() 219 | }) 220 | } 221 | } 222 | 223 | private fun getSelectedComboBoxOption(comboBox: JComboBox): ComboBoxOptions { 224 | val selectedText = comboBox.selectedItem as String? 225 | return if (selectedText == null) ComboBoxOptions.DUMMY else ComboBoxOptions.fromText(selectedText) 226 | } 227 | 228 | private fun disableAllSecondaryOptions() { 229 | this.includeTestFilesCheckBox.isEnabled = false 230 | this.moduleScopeComboBox.isEnabled = false 231 | this.directoryScopeTextField.isEnabled = false 232 | } 233 | 234 | private fun projectScopeButtonHandler() { 235 | disableAllSecondaryOptions() 236 | this.includeTestFilesCheckBox.isEnabled = true 237 | } 238 | 239 | private fun moduleScopeButtonHandler() { 240 | val project = Utils.getActiveProject() 241 | if (project != null) { 242 | // set up modules drop-down 243 | this.moduleScopeComboBox.removeAllItems() 244 | Utils.getActiveModules(project) 245 | .forEach { this.moduleScopeComboBox.addItem(it.name) } 246 | disableAllSecondaryOptions() 247 | this.moduleScopeComboBox.isEnabled = true 248 | } 249 | } 250 | 251 | private fun directoryScopeButtonHandler() { 252 | val project = Utils.getActiveProject() 253 | if (project != null) { 254 | // set up directory option text field 255 | disableAllSecondaryOptions() 256 | this.directoryScopeTextField.text = project.basePath 257 | this.directoryScopeTextField.isEnabled = true 258 | } 259 | } 260 | 261 | private fun gridSizeButtonHandler(isXGrid: Boolean, isIncrease: Boolean) { 262 | val zoomFactor = if (isIncrease) 1.25f else 1 / 1.25f 263 | val xZoomFactor = if (isXGrid) zoomFactor else 1f 264 | val yZoomFactor = if (isXGrid) 1f else zoomFactor 265 | val zoomCenter = Point2D.Float( 266 | 0.5f * this.canvasPanel.width.toFloat(), 267 | 0.5f * this.canvasPanel.height.toFloat() 268 | ) 269 | this.canvas.zoomAtPoint(zoomCenter, xZoomFactor, yZoomFactor) 270 | } 271 | 272 | private fun viewSourceCodeHandler() { 273 | this.focusedMethods.forEach { EditorHelper.openInEditor(it) } 274 | } 275 | 276 | private fun setupUiBeforeRun(buildType: CanvasConfig.BuildType) { 277 | // focus on the 'graph tab 278 | this.mainTabbedPanel.getComponentAt(1).isEnabled = true 279 | this.mainTabbedPanel.selectedIndex = 1 280 | // stats label 281 | this.statsLabel.text = "..." 282 | // build-type label 283 | when (buildType) { 284 | CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST_LIMITED, 285 | CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST, 286 | CanvasConfig.BuildType.WHOLE_PROJECT_WITHOUT_TEST_LIMITED, 287 | CanvasConfig.BuildType.WHOLE_PROJECT_WITHOUT_TEST -> this.buildTypeLabel.text = buildType.label 288 | CanvasConfig.BuildType.MODULE_LIMITED, 289 | CanvasConfig.BuildType.MODULE -> { 290 | val moduleName = this.moduleScopeComboBox.selectedItem as String 291 | this.buildTypeLabel.text = "Module $moduleName" 292 | } 293 | CanvasConfig.BuildType.DIRECTORY_LIMITED, 294 | CanvasConfig.BuildType.DIRECTORY -> { 295 | val path = this.directoryScopeTextField.text 296 | this.buildTypeLabel.text = "Directory $path" 297 | } 298 | CanvasConfig.BuildType.UPSTREAM, 299 | CanvasConfig.BuildType.DOWNSTREAM, 300 | CanvasConfig.BuildType.UPSTREAM_DOWNSTREAM -> { 301 | val functionNames = this.focusedMethods.joinToString { it.name } 302 | this.buildTypeLabel.text = "${buildType.label} of function $functionNames" 303 | } 304 | } 305 | // disable some checkboxes and buttons 306 | listOf( 307 | this.viewPackageNameComboBox, 308 | this.viewFilePathComboBox, 309 | this.nodeSelectionComboBox, 310 | this.nodeColorComboBox, 311 | this.fitGraphToBestRatioButton, 312 | this.fitGraphToViewButton, 313 | this.increaseXGridButton, 314 | this.decreaseXGridButton, 315 | this.increaseYGridButton, 316 | this.decreaseYGridButton, 317 | this.viewSourceCodeButton, 318 | this.showOnlyUpstreamButton, 319 | this.showOnlyDownstreamButton, 320 | this.showOnlyUpstreamDownstreamButton, 321 | this.searchTextField 322 | ).forEach { (it as JComponent).isEnabled = false } 323 | // filter-related checkboxes 324 | this.filterCheckboxes.forEach { 325 | it.isEnabled = false 326 | it.isSelected = true 327 | } 328 | // progress bar 329 | this.loadingProgressBar.isVisible = true 330 | // clear the canvas panel, ready for new graph 331 | this.canvas.isVisible = false 332 | } 333 | 334 | private fun setupUiAfterRun() { 335 | // hide progress bar 336 | this.loadingProgressBar.isVisible = false 337 | // show the rendered canvas 338 | this.canvas.isVisible = true 339 | this.canvasPanel.updateUI() 340 | // stats label 341 | this.statsLabel.text = "${this.canvas.getNodesCount()} methods" 342 | // enable some checkboxes and buttons 343 | enableFocusedMethodButtons() 344 | listOf( 345 | this.viewPackageNameComboBox, 346 | this.viewFilePathComboBox, 347 | this.nodeSelectionComboBox, 348 | this.nodeColorComboBox, 349 | this.fitGraphToBestRatioButton, 350 | this.fitGraphToViewButton, 351 | this.increaseXGridButton, 352 | this.decreaseXGridButton, 353 | this.increaseYGridButton, 354 | this.decreaseYGridButton, 355 | this.searchTextField 356 | ).forEach { (it as JComponent).isEnabled = true } 357 | // filter-related checkboxes 358 | this.filterCheckboxes.forEach { it.isEnabled = true } 359 | } 360 | 361 | private fun getSelectedBuildType(): CanvasConfig.BuildType { 362 | val isLimitedScope = this.upstreamDownstreamScopeCheckbox.isSelected 363 | return when { 364 | this.projectScopeButton.isSelected -> { 365 | if (this.includeTestFilesCheckBox.isSelected) { 366 | if (isLimitedScope) CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST_LIMITED 367 | else CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST 368 | } 369 | if (isLimitedScope) CanvasConfig.BuildType.WHOLE_PROJECT_WITHOUT_TEST_LIMITED 370 | else CanvasConfig.BuildType.WHOLE_PROJECT_WITHOUT_TEST 371 | } 372 | this.moduleScopeButton.isSelected -> 373 | if (isLimitedScope) CanvasConfig.BuildType.MODULE_LIMITED 374 | else CanvasConfig.BuildType.MODULE 375 | this.directoryScopeButton.isSelected -> 376 | if (isLimitedScope) CanvasConfig.BuildType.DIRECTORY_LIMITED 377 | else CanvasConfig.BuildType.DIRECTORY 378 | else -> CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST 379 | } 380 | } 381 | 382 | private fun enableFocusedMethodButtons() { 383 | listOf( 384 | this.showOnlyUpstreamButton, 385 | this.showOnlyDownstreamButton, 386 | this.showOnlyUpstreamDownstreamButton, 387 | this.viewSourceCodeButton 388 | ).forEach { it.isEnabled = this.focusedMethods.isNotEmpty() } 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/CallGraphToolWindowFactory.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.openapi.components.service 4 | import com.intellij.openapi.project.Project 5 | import com.intellij.openapi.wm.ToolWindow 6 | import com.intellij.ui.content.ContentFactory 7 | 8 | class CallGraphToolWindowFactory: com.intellij.openapi.wm.ToolWindowFactory { 9 | // Create the tool window content. 10 | override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { 11 | val callGraphToolWindow = CallGraphToolWindow() 12 | 13 | // register the call graph tool window as a project service, so it can be accessed by editor menu actions. 14 | val callGraphToolWindowProjectService = project.service() 15 | callGraphToolWindowProjectService.callGraphToolWindow = callGraphToolWindow 16 | 17 | // register the tool window content 18 | val content = ContentFactory.getInstance().createContent(callGraphToolWindow.getContent(), "", false) 19 | toolWindow.contentManager.addContent(content) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/CallGraphToolWindowProjectService.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.openapi.components.Service 4 | 5 | // Project service holds a reference to the tool window, which is accessible by an action (editor menu) 6 | @Service(Service.Level.PROJECT) 7 | class CallGraphToolWindowProjectService { 8 | lateinit var callGraphToolWindow: CallGraphToolWindow 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/Canvas.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.psi.PsiModifier 4 | import java.awt.* 5 | import java.awt.geom.Arc2D 6 | import java.awt.geom.Ellipse2D 7 | import java.awt.geom.Line2D 8 | import java.awt.geom.Point2D 9 | import javax.swing.JPanel 10 | 11 | class Canvas(private val callGraphToolWindow: CallGraphToolWindow): JPanel() { 12 | private val defaultCameraOrigin = Point2D.Float(0f, 0f) 13 | val cameraOrigin = Point2D.Float(defaultCameraOrigin.x, defaultCameraOrigin.y) 14 | private val defaultZoomRatio = 1f 15 | private val zoomRatio = Point2D.Float(defaultZoomRatio, defaultZoomRatio) 16 | private val nodeRadius = 5f 17 | private val regularLineWidth = 1f 18 | private val solidLineStroke = BasicStroke(regularLineWidth) 19 | private val visibleNodes = mutableSetOf() 20 | private val visibleEdges = mutableSetOf() 21 | private val nodeShapesMap = mutableMapOf() 22 | private val methodAccessColorMap = mapOf( 23 | PsiModifier.PUBLIC to Colors.GREEN.color, 24 | PsiModifier.PROTECTED to Colors.LIGHT_ORANGE.color, 25 | PsiModifier.PACKAGE_LOCAL to Colors.BLUE.color, 26 | PsiModifier.PRIVATE to Colors.RED.color 27 | ) 28 | private val methodAccessLabelMap = mapOf( 29 | PsiModifier.PUBLIC to "public", 30 | PsiModifier.PROTECTED to "protected", 31 | PsiModifier.PACKAGE_LOCAL to "package local", 32 | PsiModifier.PRIVATE to "private" 33 | ) 34 | private val heatMapColors = listOf( 35 | Colors.DEEP_BLUE.color, 36 | Colors.BLUE.color, 37 | Colors.LIGHT_BLUE.color, 38 | Colors.CYAN.color, 39 | Colors.GREEN.color, 40 | Colors.LIGHT_GREEN.color, 41 | Colors.YELLOW.color, 42 | Colors.LIGHT_ORANGE.color, 43 | Colors.ORANGE.color, 44 | Colors.RED.color 45 | ) 46 | 47 | private var graph = Graph() 48 | private var hoveredNode: Node? = null 49 | 50 | override fun paintComponent(graphics: Graphics) { 51 | super.paintComponent(graphics) 52 | 53 | // set up the drawing panel 54 | val graphics2D = graphics as Graphics2D 55 | graphics2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) 56 | graphics2D.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) 57 | 58 | // fill the background for entire canvas 59 | graphics2D.color = Colors.BACKGROUND_COLOR.color 60 | graphics2D.fillRect(0, 0, this.width, this.height) 61 | 62 | // draw un-highlighted and highlighted self loops 63 | this.visibleEdges 64 | .filter { it.sourceNode === it.targetNode } 65 | .forEach { drawSelfLoopEdge(graphics2D, it, isNodeHighlighted(it.sourceNode)) } 66 | 67 | // draw un-highlighted edgesMap 68 | this.visibleEdges 69 | .filter { it.sourceNode !== it.targetNode && 70 | !isNodeHighlighted(it.sourceNode) && !isNodeHighlighted(it.targetNode) } 71 | .forEach { drawNonLoopEdge(graphics2D, it, Colors.UN_HIGHLIGHTED_COLOR.color) } 72 | 73 | // draw upstream/downstream edgesMap 74 | val highlightedNodes = this.visibleNodes.filter { isNodeHighlighted(it) }.toSet() 75 | val upstreamEdges = highlightedNodes.flatMap { it.inEdges.values }.toSet() 76 | val downstreamEdges = highlightedNodes.flatMap { it.outEdges.values }.toSet() 77 | upstreamEdges.forEach { drawNonLoopEdge(graphics2D, it, Colors.UPSTREAM_COLOR.color) } 78 | downstreamEdges.forEach { drawNonLoopEdge(graphics2D, it, Colors.DOWNSTREAM_COLOR.color) } 79 | 80 | // draw un-highlighted labels 81 | val upstreamNodes = upstreamEdges.map { it.sourceNode }.toSet() 82 | val downstreamNodes = downstreamEdges.map { it.targetNode }.toSet() 83 | val unHighlightedNodes = this.visibleNodes 84 | .filter { !isNodeHighlighted(it) && !upstreamNodes.contains(it) && !downstreamNodes.contains(it) } 85 | .toSet() 86 | unHighlightedNodes.forEach { drawNodeLabels(graphics2D, it, Colors.NEUTRAL_COLOR.color, false) } 87 | 88 | // draw un-highlighted nodesMap (upstream/downstream nodesMap are excluded) 89 | this.nodeShapesMap.clear() 90 | unHighlightedNodes 91 | .filter { !upstreamNodes.contains(it) && !downstreamNodes.contains(it) } 92 | .forEach { drawNode(graphics2D, it, Colors.UN_HIGHLIGHTED_COLOR.color) } 93 | 94 | // draw upstream/downstream label and nodesMap 95 | upstreamNodes.forEach { drawNodeLabels(graphics2D, it, Colors.UPSTREAM_COLOR.color, false) } 96 | downstreamNodes.forEach { drawNodeLabels(graphics2D, it, Colors.DOWNSTREAM_COLOR.color, false) } 97 | upstreamNodes.forEach { drawNode(graphics2D, it, Colors.UPSTREAM_COLOR.color) } 98 | downstreamNodes.forEach { drawNode(graphics2D, it, Colors.DOWNSTREAM_COLOR.color) } 99 | 100 | // draw highlighted node and label 101 | this.visibleNodes 102 | .filter { isNodeHighlighted(it) } 103 | .forEach { 104 | drawNode(graphics2D, it, Colors.HIGHLIGHTED_COLOR.color) 105 | drawNodeLabels(graphics2D, it, Colors.HIGHLIGHTED_COLOR.color, true) 106 | } 107 | 108 | // draw legend 109 | if (this.callGraphToolWindow.isLegendNeeded()) { 110 | val legend = if (this.callGraphToolWindow.isNodeColorByAccess()) { 111 | listOf(PsiModifier.PUBLIC, PsiModifier.PROTECTED, PsiModifier.PACKAGE_LOCAL, PsiModifier.PRIVATE) 112 | .map { this.methodAccessLabelMap.getValue(it) to this.methodAccessColorMap.getValue(it) } 113 | } else { 114 | emptyList() 115 | } 116 | drawLegend(graphics2D, legend) 117 | } 118 | } 119 | 120 | fun reset(graph: Graph) { 121 | this.graph = graph 122 | this.visibleNodes.clear() 123 | this.visibleNodes.addAll(graph.getNodes()) 124 | this.visibleEdges.clear() 125 | this.visibleEdges.addAll(graph.getEdges()) 126 | this.nodeShapesMap.clear() 127 | this.hoveredNode = null 128 | this.cameraOrigin.setLocation(defaultCameraOrigin) 129 | this.zoomRatio.setLocation(this.defaultZoomRatio, this.defaultZoomRatio) 130 | } 131 | 132 | fun setHoveredNode(node: Node?): Canvas { 133 | if (this.hoveredNode !== node) { 134 | this.hoveredNode = node 135 | repaint() 136 | } 137 | return this 138 | } 139 | 140 | fun toggleClickedNode(node: Node) { 141 | this.callGraphToolWindow.toggleFocusedMethod(node.method) 142 | repaint() 143 | } 144 | 145 | fun clearClickedNodes() { 146 | this.callGraphToolWindow.clearFocusedMethods() 147 | repaint() 148 | } 149 | 150 | fun zoomAtPoint(point: Point2D.Float, xZoomFactor: Float, yZoomFactor: Float) { 151 | this.cameraOrigin.setLocation( 152 | xZoomFactor * this.cameraOrigin.x + (xZoomFactor - 1) * point.x, 153 | yZoomFactor * this.cameraOrigin.y + (yZoomFactor - 1) * point.y 154 | ) 155 | this.zoomRatio.x *= xZoomFactor 156 | this.zoomRatio.y *= yZoomFactor 157 | repaint() 158 | } 159 | 160 | fun getNodeUnderPoint(point: Point2D): Node? { 161 | return this.nodeShapesMap 162 | .filter { (shape, _) -> shape.contains(point.x, point.y) } 163 | .values 164 | .firstOrNull() 165 | } 166 | 167 | fun fitCanvasToView() { 168 | val blueprint = this.graph.getNodes().associateBy({ it.id }, { it.rawLayoutPoint }) 169 | val bestFitBlueprint = Utils.fitLayoutToViewport(blueprint) 170 | Utils.applyLayoutBlueprintToGraph(bestFitBlueprint, this.graph) 171 | this.cameraOrigin.setLocation(defaultCameraOrigin) 172 | this.zoomRatio.setLocation(defaultZoomRatio, defaultZoomRatio) 173 | repaint() 174 | } 175 | 176 | fun fitCanvasToBestRatio() { 177 | // set every node coordinate to its original raw layout by GraphViz 178 | this.graph.getNodes().forEach { it.point.setLocation(it.rawLayoutPoint) } 179 | this.cameraOrigin.setLocation(defaultCameraOrigin) 180 | this.zoomRatio.setLocation(defaultZoomRatio, defaultZoomRatio) 181 | repaint() 182 | } 183 | 184 | fun getNodesCount() = this.graph.getNodes().size 185 | 186 | fun filterChangeHandler() { 187 | this.visibleNodes.clear() 188 | this.visibleNodes.addAll(this.graph.getNodes() 189 | .filter { node -> 190 | val method = node.method 191 | val isVisibleAccessLevel = when { 192 | Utils.isPublic(method) -> this.callGraphToolWindow.isFilterAccessPublicChecked() 193 | Utils.isProtected(method) -> this.callGraphToolWindow.isFilterAccessProtectedChecked() 194 | Utils.isPackageLocal(method) -> this.callGraphToolWindow.isFilterAccessPackageLocalChecked() 195 | Utils.isPrivate(method) -> this.callGraphToolWindow.isFilterAccessPrivateChecked() 196 | else -> true 197 | } 198 | val isExternalMethod = Utils.getSourceRoot(method.containingFile.virtualFile) == null 199 | val isVisibleExternal = !isExternalMethod || this.callGraphToolWindow.isFilterExternalChecked() 200 | 201 | isVisibleAccessLevel && isVisibleExternal 202 | }) 203 | this.visibleEdges.clear() 204 | this.visibleEdges.addAll(graph.getEdges() 205 | .filter { this.visibleNodes.contains(it.sourceNode) && this.visibleNodes.contains(it.targetNode) }) 206 | repaint() 207 | } 208 | 209 | private fun toCameraView(point: Point2D.Float): Point2D.Float { 210 | val canvasSize = this.callGraphToolWindow.getCanvasSize() 211 | return Point2D.Float( 212 | this.zoomRatio.x * point.x * canvasSize.width - this.cameraOrigin.x, 213 | this.zoomRatio.y * point.y * canvasSize.height - this.cameraOrigin.y 214 | ) 215 | } 216 | 217 | private fun isNodeHighlighted(node: Node): Boolean { 218 | return this.hoveredNode === node || this.callGraphToolWindow.isFocusedMethod(node.method) 219 | } 220 | 221 | private fun drawLegend(graphics2D: Graphics2D, labels: List>) { 222 | val singleLabelHeight = graphics2D.fontMetrics.ascent + graphics2D.fontMetrics.descent 223 | val boundingBoxLowerLeft = Point2D.Float( 224 | 0f, 225 | labels.size * singleLabelHeight.toFloat() 226 | ) 227 | drawLabels(graphics2D, boundingBoxLowerLeft, labels, Colors.BACKGROUND_COLOR.color, 228 | Colors.UN_HIGHLIGHTED_COLOR.color, 1) 229 | } 230 | 231 | private fun drawSelfLoopEdge(graphics2D: Graphics2D, edge: Edge, isHighlighted: Boolean) { 232 | val sourceNodeCenter = toCameraView(edge.sourceNode.point) 233 | drawSelfLoop(graphics2D, sourceNodeCenter, isHighlighted) 234 | } 235 | 236 | private fun drawNonLoopEdge(graphics2D: Graphics2D, edge: Edge, color: Color) { 237 | val sourceNodeCenter = toCameraView(edge.sourceNode.point) 238 | val targetNodeCenter = toCameraView(edge.targetNode.point) 239 | drawLine(graphics2D, sourceNodeCenter, targetNodeCenter, color) 240 | drawLineArrow(graphics2D, sourceNodeCenter, targetNodeCenter, color) 241 | } 242 | 243 | private fun drawNode(graphics2D: Graphics2D, node: Node, outlineColor: Color) { 244 | val nodeCenter = toCameraView(node.point) 245 | val backgroundColor = getNodeBackgroundColor(node) 246 | val nodeShape = drawCircle(graphics2D, nodeCenter, backgroundColor, outlineColor) 247 | this.nodeShapesMap[nodeShape] = node 248 | } 249 | 250 | private fun getNodeBackgroundColor(node: Node): Color { 251 | if (this.callGraphToolWindow.isNodeColorByAccess()) { 252 | return this.methodAccessColorMap.entries 253 | .firstOrNull { (accessLevel, _) -> node.method.modifierList.hasModifierProperty(accessLevel) } 254 | ?.value 255 | ?: Colors.BACKGROUND_COLOR.color 256 | } else if (this.callGraphToolWindow.isNodeColorByClassName()) { 257 | val psiClass = node.method.containingClass 258 | if (psiClass != null) { 259 | val hashIndex = psiClass.hashCode() % this.heatMapColors.size 260 | return this.heatMapColors[hashIndex] 261 | } 262 | } 263 | return Colors.BACKGROUND_COLOR.color 264 | } 265 | 266 | private fun createNodeLabels(node: Node, signatureColor: Color, isNodeHovered: Boolean): List> { 267 | // draw labels in top-down order 268 | val labels = mutableListOf>() 269 | // file path 270 | if (this.callGraphToolWindow.isRenderFunctionFilePath(isNodeHovered)) { 271 | labels.add(node.filePath to Colors.UN_HIGHLIGHTED_COLOR.color) 272 | } 273 | // package name 274 | if (this.callGraphToolWindow.isRenderFunctionPackageName(isNodeHovered)) { 275 | labels.add(node.packageName to Colors.UN_HIGHLIGHTED_COLOR.color) 276 | } 277 | // function signature 278 | val signature = if (isNodeHovered) node.signature else node.method.name 279 | labels.add(signature to signatureColor) 280 | return labels 281 | } 282 | 283 | private fun drawNodeLabels(graphics2D: Graphics2D, node: Node, labelColor: Color, isNodeHovered: Boolean) { 284 | // create labels 285 | val labels = createNodeLabels(node, labelColor, isNodeHovered) 286 | val fontMetrics = graphics2D.fontMetrics 287 | val halfLabelHeight = 0.5f * (fontMetrics.ascent + fontMetrics.descent) 288 | val nodeCenter = toCameraView(node.point) 289 | val boundingBoxLowerLeft = Point2D.Float( 290 | nodeCenter.x + 4 * nodeRadius, 291 | nodeCenter.y + halfLabelHeight 292 | ) 293 | val backgroundColor = 294 | if (this.callGraphToolWindow.isQueried(node.method.name)) Colors.HIGHLIGHTED_BACKGROUND_COLOR.color 295 | else Colors.BACKGROUND_COLOR.color 296 | val borderColor = if (isNodeHovered) Colors.UN_HIGHLIGHTED_COLOR.color else Colors.BACKGROUND_COLOR.color 297 | drawLabels(graphics2D, boundingBoxLowerLeft, labels, backgroundColor, borderColor, 2) 298 | } 299 | 300 | private fun drawLabels( 301 | graphics2D: Graphics2D, 302 | boundingBoxLowerLeft: Point2D.Float, 303 | labels: List>, 304 | backgroundColor: Color, 305 | borderColor: Color, 306 | padding: Int 307 | ) { 308 | val fontMetrics = graphics2D.fontMetrics 309 | val singleLabelHeight = fontMetrics.ascent + fontMetrics.descent 310 | val boundingBoxWidth = labels 311 | .map { (text, _) -> fontMetrics.getStringBounds(text, graphics2D).width.toInt() } 312 | .maxOrNull() 313 | ?: 0 314 | val boundingBoxHeight = labels.size * singleLabelHeight 315 | val boundingBoxUpperLeft = Point2D.Float( 316 | boundingBoxLowerLeft.x, 317 | boundingBoxLowerLeft.y - 2 * padding - boundingBoxHeight 318 | ) 319 | val boundingBoxUpperRight = Point2D.Float( 320 | boundingBoxUpperLeft.x + 2 * padding + boundingBoxWidth, 321 | boundingBoxUpperLeft.y 322 | ) 323 | val boundingBoxLowerRight = Point2D.Float( 324 | boundingBoxUpperRight.x, 325 | boundingBoxLowerLeft.y 326 | ) 327 | // fill background to overall bounding box 328 | graphics2D.color = backgroundColor 329 | graphics2D.fillRect( 330 | (boundingBoxUpperLeft.x + 1).toInt(), 331 | (boundingBoxUpperLeft.y + 1).toInt(), 332 | 2 * padding + boundingBoxWidth, 333 | 2 * padding + boundingBoxHeight 334 | ) 335 | // draw border if the node is hovered 336 | drawLine(graphics2D, boundingBoxLowerLeft, boundingBoxUpperLeft, borderColor) 337 | drawLine(graphics2D, boundingBoxUpperLeft, boundingBoxUpperRight, borderColor) 338 | drawLine(graphics2D, boundingBoxUpperRight, boundingBoxLowerRight, borderColor) 339 | drawLine(graphics2D, boundingBoxLowerRight, boundingBoxLowerLeft, borderColor) 340 | // draw text 341 | labels.reversed().mapIndexed { index, (text, color) -> 342 | val labelLowerLeft = Point2D.Float( 343 | boundingBoxLowerLeft.x + padding, 344 | boundingBoxLowerLeft.y - padding - fontMetrics.descent - index * singleLabelHeight 345 | ) 346 | drawText(graphics2D, labelLowerLeft, text, color) 347 | } 348 | } 349 | 350 | private fun drawCircle( 351 | graphics2D: Graphics2D, 352 | circleCenter: Point2D.Float, 353 | backgroundColor: Color, 354 | outlineColor: Color): Shape { 355 | // create node shape 356 | val upperLeft = Point2D.Float( 357 | circleCenter.x - this.nodeRadius, 358 | circleCenter.y - this.nodeRadius 359 | ) 360 | val diameter = 2 * this.nodeRadius 361 | val shape = Ellipse2D.Float( 362 | upperLeft.x, 363 | upperLeft.y, 364 | diameter, 365 | diameter 366 | ) 367 | // fill node with color 368 | graphics2D.color = backgroundColor 369 | graphics2D.fill(shape) 370 | // draw the outline 371 | graphics2D.color = outlineColor 372 | val strokedShape = this.solidLineStroke.createStrokedShape(shape) 373 | graphics2D.draw(strokedShape) 374 | return shape 375 | } 376 | 377 | private fun drawText(graphics2D: Graphics2D, textLowerLeft: Point2D.Float, text: String, textColor: Color) { 378 | graphics2D.color = textColor 379 | graphics2D.drawString(text, textLowerLeft.x, textLowerLeft.y) 380 | } 381 | 382 | private fun drawLine( 383 | graphics2D: Graphics2D, 384 | sourcePoint: Point2D.Float, 385 | targetPoint: Point2D.Float, 386 | lineColor: Color) { 387 | val shape = Line2D.Float(sourcePoint, targetPoint) 388 | val strokedShape = this.solidLineStroke.createStrokedShape(shape) 389 | graphics2D.color = lineColor 390 | graphics2D.draw(strokedShape) 391 | } 392 | 393 | private fun drawSelfLoop(graphics2D: Graphics2D, nodeCenter: Point2D.Float, isHighlighted: Boolean) { 394 | // draw circle shape 395 | val selfLoopRadius = 10f 396 | val selfLoopDiameter = 2 * selfLoopRadius 397 | val loopUpperLeft = Point2D.Float( 398 | nodeCenter.x - selfLoopRadius, 399 | nodeCenter.y - selfLoopDiameter 400 | ) 401 | val upstreamHalfArc = Arc2D.Float( 402 | loopUpperLeft.x, 403 | loopUpperLeft.y, 404 | selfLoopDiameter, 405 | selfLoopDiameter, 406 | 90f, 407 | 180f, 408 | Arc2D.OPEN 409 | ) 410 | val downstreamHalfArc = Arc2D.Float( 411 | loopUpperLeft.x, 412 | loopUpperLeft.y, 413 | selfLoopDiameter, 414 | selfLoopDiameter, 415 | 270f, 416 | 180f, 417 | Arc2D.OPEN 418 | ) 419 | val strokedUpstreamHalfShape = this.solidLineStroke.createStrokedShape(upstreamHalfArc) 420 | val strokedDownstreamHalfShape = this.solidLineStroke.createStrokedShape(downstreamHalfArc) 421 | val upstreamHalfLoopColor = 422 | if (isHighlighted) Colors.UPSTREAM_COLOR.color 423 | else Colors.UN_HIGHLIGHTED_COLOR.color 424 | val downstreamHalfLoopColor = 425 | if (isHighlighted) Colors.DOWNSTREAM_COLOR.color 426 | else Colors.UN_HIGHLIGHTED_COLOR.color 427 | graphics2D.color = upstreamHalfLoopColor 428 | graphics2D.draw(strokedUpstreamHalfShape) 429 | graphics2D.color = downstreamHalfLoopColor 430 | graphics2D.draw(strokedDownstreamHalfShape) 431 | // draw arrow 432 | val arrowCenter = Point2D.Float(nodeCenter.x, nodeCenter.y - selfLoopDiameter) 433 | drawArrow(graphics2D, arrowCenter, Math.PI, downstreamHalfLoopColor) 434 | } 435 | 436 | private fun drawLineArrow( 437 | graphics2D: Graphics2D, 438 | sourcePoint: Point2D.Float, 439 | targetPoint: Point2D.Float, 440 | arrowColor: Color 441 | ) { 442 | val angle = Math.atan2((targetPoint.y - sourcePoint.y).toDouble(), (targetPoint.x - sourcePoint.x).toDouble()) 443 | val arrowCenter = Point2D.Float( 444 | 0.5f * (sourcePoint.x + targetPoint.x), 445 | 0.5f * (sourcePoint.y + targetPoint.y) 446 | ) 447 | drawArrow(graphics2D, arrowCenter, angle, arrowColor) 448 | } 449 | 450 | private fun drawArrow( 451 | graphics2D: Graphics2D, 452 | center: Point2D.Float, 453 | angle: Double, 454 | arrowColor: Color 455 | ) { 456 | val arrowSize = 5f 457 | val midPoint = Point2D.Float( 458 | center.x + arrowSize * Math.cos(angle).toFloat(), 459 | center.y + arrowSize * Math.sin(angle).toFloat() 460 | ) 461 | val upperTipAngle = angle + Math.PI * 2 / 3 462 | val upperTipPoint = Point2D.Float( 463 | center.x + arrowSize * Math.cos(upperTipAngle).toFloat(), 464 | center.y + arrowSize * Math.sin(upperTipAngle).toFloat() 465 | ) 466 | val lowerTipAngle = angle - Math.PI * 2 / 3 467 | val lowerTipPoint = Point2D.Float( 468 | center.x + arrowSize * Math.cos(lowerTipAngle).toFloat(), 469 | center.y + arrowSize * Math.sin(lowerTipAngle).toFloat() 470 | ) 471 | val points = listOf(midPoint, upperTipPoint, lowerTipPoint, midPoint) 472 | val xPoints = points.map { Math.round(it.x) } 473 | val yPoints = points.map { Math.round(it.y) } 474 | graphics2D.color = arrowColor 475 | graphics2D.fillPolygon(xPoints.toIntArray(), yPoints.toIntArray(), xPoints.size) 476 | } 477 | } 478 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/CanvasBuilder.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.openapi.progress.ProgressIndicator 4 | import com.intellij.openapi.progress.ProgressIndicatorProvider 5 | import com.intellij.psi.PsiFile 6 | import com.intellij.psi.PsiMethod 7 | 8 | class CanvasBuilder { 9 | private val fileModifiedTimeCache = mutableMapOf() 10 | 11 | private var progressIndicator: ProgressIndicator? = null 12 | private var dependenciesCache = emptySet() 13 | 14 | fun build(canvasConfig: CanvasConfig) { 15 | // cancel existing progress if any 16 | this.progressIndicator?.cancel() 17 | this.progressIndicator = ProgressIndicatorProvider.getGlobalProgressIndicator() 18 | 19 | // build a dependency snapshot for the entire code base 20 | val dependencies = getDependencies(canvasConfig, this.dependenciesCache, this.fileModifiedTimeCache) 21 | 22 | // visualize the viewing part as graph 23 | val sourceCodeRoots = Utils.getSourceCodeRoots(canvasConfig) 24 | val files = Utils.getSourceCodeFiles(canvasConfig.project, sourceCodeRoots) 25 | val methods = Utils.getMethodsInScope(canvasConfig, files) 26 | val dependencyView = Utils.getDependencyView(canvasConfig, methods, dependencies) 27 | val graph = buildGraph(methods, dependencyView) 28 | canvasConfig.canvas.reset(graph) 29 | } 30 | 31 | private fun buildGraph(methods: Set, dependencyView: Set): Graph { 32 | val graph = Graph() 33 | methods.forEach { graph.addNode(it) } 34 | dependencyView.forEach { 35 | val caller = it.caller.element 36 | val callee = it.callee.element 37 | if (caller != null && callee != null) { 38 | graph.addNode(caller) 39 | graph.addNode(callee) 40 | graph.addEdge(caller, callee) 41 | } 42 | } 43 | Utils.layout(graph) 44 | return graph 45 | } 46 | 47 | private fun getDependencies( 48 | canvasConfig: CanvasConfig, 49 | dependenciesCache: Set, 50 | fileModifiedTimeCache: Map 51 | ): Set { 52 | val allFiles = Utils.getAllSourceCodeFiles(canvasConfig.project) 53 | val newFiles = allFiles.filter { !fileModifiedTimeCache.containsKey(it) } 54 | val changedFiles = allFiles 55 | .filter { fileModifiedTimeCache.containsKey(it) && fileModifiedTimeCache[it] != it.modificationStamp } 56 | .toSet() 57 | val validDependencies = dependenciesCache 58 | .filter { 59 | !changedFiles.contains(it.caller.containingFile) && !changedFiles.contains(it.callee.containingFile) 60 | } 61 | .toSet() 62 | val invalidFiles = dependenciesCache 63 | .filter { !validDependencies.contains(it) } 64 | .flatMap { listOf(it.caller.containingFile, it.callee.containingFile) } 65 | .filterNotNull() 66 | .toSet() 67 | val filesToParse = newFiles.union(invalidFiles) 68 | val methodsToParse = Utils.getMethodsFromFiles(filesToParse) 69 | 70 | // parse method dependencies 71 | canvasConfig.callGraphToolWindow.resetProgressBar(methodsToParse.size) 72 | val newDependencies = methodsToParse 73 | .flatMap { 74 | canvasConfig.callGraphToolWindow.incrementProgressBar() 75 | Utils.getDependenciesFromMethod(it) 76 | } 77 | .toSet() 78 | val dependencies = validDependencies.union(newDependencies) 79 | 80 | // cache the dependencies for next use 81 | this.dependenciesCache = dependencies 82 | this.fileModifiedTimeCache.clear() 83 | this.fileModifiedTimeCache.putAll(allFiles.associateBy({ it }, { it.modificationStamp })) 84 | 85 | return dependencies 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/CanvasConfig.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.openapi.project.Project 4 | import com.intellij.psi.PsiMethod 5 | 6 | data class CanvasConfig( 7 | val project: Project, 8 | val buildType: BuildType, 9 | val canvas: Canvas, 10 | val selectedModuleName: String, 11 | val selectedDirectoryPath: String, 12 | val focusedMethods: Set, 13 | val callGraphToolWindow: CallGraphToolWindow 14 | ) { 15 | enum class BuildType(val label: String) { 16 | WHOLE_PROJECT_WITH_TEST_LIMITED("Whole project (test files included), limited upstream/downstream scope"), 17 | WHOLE_PROJECT_WITHOUT_TEST_LIMITED("Whole project (test files excluded), limited upstream/downstream scope"), 18 | MODULE_LIMITED("Module, limited upstream/downstream scope"), 19 | DIRECTORY_LIMITED("Directory, limited upstream/downstream scope"), 20 | WHOLE_PROJECT_WITH_TEST("Whole project (test files included)"), 21 | WHOLE_PROJECT_WITHOUT_TEST("Whole project (test files excluded)"), 22 | MODULE("Module"), 23 | DIRECTORY("Directory"), 24 | UPSTREAM("Upstream"), 25 | DOWNSTREAM("Downstream"), 26 | UPSTREAM_DOWNSTREAM("Upstream & downstream") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/Colors.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.ui.JBColor 4 | import java.awt.Color 5 | 6 | enum class Colors(val color: JBColor) { 7 | BACKGROUND_COLOR(JBColor(Color(0xFDFEFF), Color(0x292B2D))), 8 | UN_HIGHLIGHTED_COLOR(JBColor(Color(0xC6C8CA), Color(0x585A5C))), 9 | NEUTRAL_COLOR(JBColor(Color(0x626466), Color(0x949698))), 10 | HIGHLIGHTED_COLOR(JBColor(Color(0x4285F4), Color(0x589DEF))), 11 | HIGHLIGHTED_BACKGROUND_COLOR(JBColor(Color(0xFFFF00), Color(0xFFFF00))), 12 | UPSTREAM_COLOR(JBColor(Color(0xFBBC05), Color(0xBE9117))), 13 | DOWNSTREAM_COLOR(JBColor(Color(0x34A853), Color(0x538863))), 14 | 15 | DEEP_BLUE(JBColor(Color(0x0000FF), Color(0x0000FF))), 16 | BLUE(JBColor(Color(0x0088FF), Color(0x0088FF))), 17 | LIGHT_BLUE(JBColor(Color(0x00FFFF), Color(0x00FFFF))), 18 | CYAN(JBColor(Color(0x00FF88), Color(0x00FF88))), 19 | GREEN(JBColor(Color(0x00FF00), Color(0x00FF00))), 20 | LIGHT_GREEN(JBColor(Color(0x88FF00), Color(0x88FF00))), 21 | YELLOW(JBColor(Color(0xFFFF00), Color(0xFFFF00))), 22 | LIGHT_ORANGE(JBColor(Color(0xFFAA00), Color(0xFFAA00))), 23 | ORANGE(JBColor(Color(0xFF6600), Color(0xFF6600))), 24 | RED(JBColor(Color(0xFF0000), Color(0xFF0000))) 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/ComboBoxOptions.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | enum class ComboBoxOptions(val text: String) { 4 | VIEW_ALWAYS("Always show"), 5 | VIEW_HOVERED("When hovered"), 6 | VIEW_NEVER("Hide"), 7 | NODE_SELECTION_SINGLE("Single node"), 8 | NODE_SELECTION_MULTIPLE("Multiple nodes"), 9 | NODE_COLOR_NONE("None"), 10 | NODE_COLOR_ACCESS("By access level"), 11 | NODE_COLOR_CLASS("By class name"), 12 | DUMMY("(Dummy value)"); 13 | 14 | companion object { 15 | private val reverseMap = entries.associateBy(ComboBoxOptions::text) 16 | 17 | fun fromText(text: String) = reverseMap.getValue(text) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/Dependency.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.psi.PsiMethod 4 | import com.intellij.psi.SmartPsiElementPointer 5 | 6 | data class Dependency(val caller: SmartPsiElementPointer, val callee: SmartPsiElementPointer) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/Edge.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | data class Edge(val id: String, val sourceNode: Node, val targetNode: Node) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/Graph.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.psi.PsiElement 4 | import com.intellij.psi.PsiMethod 5 | import java.util.* 6 | 7 | data class Graph( 8 | val nodesMap: MutableMap = mutableMapOf(), 9 | val edgesMap: MutableMap = mutableMapOf() 10 | ) { 11 | val connectedComponents: Set by lazy { 12 | val visitedNodes = mutableSetOf() 13 | this.getNodes() 14 | .map { traverseBfs(it, visitedNodes) } 15 | .filter { it.isNotEmpty() } 16 | .map { component -> 17 | val componentNodes = this.nodesMap.filterValues { component.contains(it) }.toMutableMap() 18 | val componentEdges = this.edgesMap.filterValues { 19 | component.contains(it.sourceNode) || component.contains(it.targetNode) 20 | }.toMutableMap() 21 | Graph(componentNodes, componentEdges) 22 | } 23 | .toSet() 24 | } 25 | 26 | fun addNode(method: PsiMethod) { 27 | val nodeId = getNodeHash(method) 28 | if (!this.nodesMap.containsKey(nodeId)) { 29 | val node = Node(nodeId, method) 30 | this.nodesMap[nodeId] = node 31 | } 32 | } 33 | 34 | fun addEdge(sourceMethod: PsiMethod, targetMethod: PsiMethod) { 35 | val sourceNodeId = getNodeHash(sourceMethod) 36 | val targetNodeId = getNodeHash(targetMethod) 37 | val edgeId = getEdgeHash(sourceNodeId, targetNodeId) 38 | if (!this.edgesMap.containsKey(edgeId)) { 39 | val sourceNode = this.nodesMap.getValue(sourceNodeId) 40 | val targetNode = this.nodesMap.getValue(targetNodeId) 41 | val edge = Edge(edgeId, sourceNode, targetNode) 42 | this.edgesMap[edgeId] = edge 43 | sourceNode.addOutEdge(edge) 44 | targetNode.addInEdge(edge) 45 | } 46 | } 47 | 48 | fun getNode(nodeId: String) = this.nodesMap.getValue(nodeId) 49 | 50 | fun getNodes() = this.nodesMap.values.toSet() 51 | 52 | fun getEdges() = this.edgesMap.values.toSet() 53 | 54 | private fun traverseBfs(root: Node, visitedNodes: MutableSet): Set { 55 | if (visitedNodes.contains(root)) { 56 | return Collections.emptySet() 57 | } 58 | val path = mutableSetOf() 59 | val queue = mutableSetOf(root) 60 | while (queue.isNotEmpty()) { 61 | visitedNodes.addAll(queue) 62 | path.addAll(queue) 63 | val newQueue = queue 64 | .flatMap { it.getNeighbors() } 65 | .filter { !visitedNodes.contains(it) } 66 | queue.clear() 67 | queue.addAll(newQueue) 68 | } 69 | return path 70 | } 71 | 72 | private fun getNodeHash(element: PsiElement) = element.hashCode().toString() 73 | 74 | private fun getEdgeHash(sourceNodeId: String, targetNodeId: String) = "$sourceNodeId-$targetNodeId" 75 | } 76 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/MouseEventHandler.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import java.awt.event.* 4 | import java.awt.geom.Point2D 5 | 6 | class MouseEventHandler(private val canvas: Canvas): MouseListener, MouseMotionListener, MouseWheelListener { 7 | private val lastMousePosition = Point2D.Float() 8 | 9 | override fun mouseClicked(event: MouseEvent) { 10 | val node = this.canvas.getNodeUnderPoint(event.point) 11 | if (node == null) { 12 | this.canvas.clearClickedNodes() 13 | } else { 14 | this.canvas.toggleClickedNode(node) 15 | } 16 | } 17 | 18 | override fun mousePressed(event: MouseEvent) { 19 | this.lastMousePosition.setLocation(event.x.toFloat(), event.y.toFloat()) 20 | } 21 | 22 | override fun mouseReleased(event: MouseEvent) { 23 | } 24 | 25 | override fun mouseEntered(event: MouseEvent) { 26 | } 27 | 28 | override fun mouseExited(event: MouseEvent) { 29 | } 30 | 31 | override fun mouseDragged(event: MouseEvent) { 32 | val currentMousePosition = Point2D.Float(event.x.toFloat(), event.y.toFloat()) 33 | if (currentMousePosition != this.lastMousePosition) { 34 | val currentCameraOrigin = this.canvas.cameraOrigin 35 | val newCameraOrigin = Point2D.Float( 36 | currentCameraOrigin.x - currentMousePosition.x + this.lastMousePosition.x, 37 | currentCameraOrigin.y - currentMousePosition.y + this.lastMousePosition.y 38 | ) 39 | this.canvas.cameraOrigin.setLocation(newCameraOrigin) 40 | this.canvas.repaint() 41 | this.lastMousePosition.setLocation(currentMousePosition) 42 | } 43 | } 44 | 45 | override fun mouseMoved(event: MouseEvent) { 46 | val node = this.canvas.getNodeUnderPoint(event.point) 47 | this.canvas.setHoveredNode(node) 48 | } 49 | 50 | override fun mouseWheelMoved(event: MouseWheelEvent) { 51 | val scrollRotation = event.wheelRotation // 1 if scroll down, -1 otherwise 52 | val zoomFactor = Math.pow(1.25, -scrollRotation.toDouble()).toFloat() 53 | val mousePosition = Point2D.Float(event.x.toFloat(), event.y.toFloat()) 54 | this.canvas.zoomAtPoint(mousePosition, zoomFactor, zoomFactor) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/Node.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.psi.PsiMethod 4 | import java.awt.geom.Point2D 5 | 6 | data class Node(val id: String, val method: PsiMethod) { 7 | val outEdges = mutableMapOf() 8 | val inEdges = mutableMapOf() 9 | val filePath = Utils.getMethodFilePath(method) ?: "(no file)" 10 | val packageName = Utils.getMethodPackageName(method) 11 | val signature = Utils.getMethodSignature(method) 12 | val point = Point2D.Float() 13 | val rawLayoutPoint = Point2D.Float() 14 | 15 | fun addInEdge(edge: Edge) { 16 | if (!this.inEdges.containsKey(edge.id)) { 17 | this.inEdges[edge.id] = edge 18 | } 19 | } 20 | 21 | fun addOutEdge(edge: Edge) { 22 | if (!this.outEdges.containsKey(edge.id)) { 23 | this.outEdges[edge.id] = edge 24 | } 25 | } 26 | 27 | fun getNeighbors(): List { 28 | val upstreamNodes = this.inEdges.values.map { it.sourceNode } 29 | val downstreamNodes = this.outEdges.values.map { it.targetNode } 30 | return upstreamNodes.union(downstreamNodes).toList() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/Utils.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.openapi.actionSystem.AnActionEvent 4 | import com.intellij.openapi.actionSystem.CommonDataKeys 5 | import com.intellij.openapi.application.ApplicationManager 6 | import com.intellij.openapi.components.service 7 | import com.intellij.openapi.module.Module 8 | import com.intellij.openapi.module.ModuleManager 9 | import com.intellij.openapi.progress.ProgressIndicator 10 | import com.intellij.openapi.progress.ProgressManager 11 | import com.intellij.openapi.progress.Task 12 | import com.intellij.openapi.project.Project 13 | import com.intellij.openapi.project.ProjectManager 14 | import com.intellij.openapi.roots.ModuleRootManager 15 | import com.intellij.openapi.roots.ProjectRootManager 16 | import com.intellij.openapi.vfs.LocalFileSystem 17 | import com.intellij.openapi.vfs.VfsUtilCore 18 | import com.intellij.openapi.vfs.VirtualFile 19 | import com.intellij.openapi.wm.ToolWindowManager 20 | import com.intellij.openapi.wm.WindowManager 21 | import com.intellij.psi.* 22 | import com.intellij.psi.SmartPointerManager 23 | import com.intellij.psi.util.PsiTreeUtil 24 | import guru.nidi.graphviz.attribute.Rank 25 | import guru.nidi.graphviz.engine.Format 26 | import guru.nidi.graphviz.engine.Graphviz 27 | import guru.nidi.graphviz.model.Factory.mutGraph 28 | import guru.nidi.graphviz.model.Factory.mutNode 29 | import java.awt.geom.Point2D 30 | 31 | object Utils { 32 | private const val NORMALIZED_GRID_SIZE = 0.1f 33 | 34 | fun getActiveProject(): Project? { 35 | return ProjectManager.getInstance() 36 | .openProjects 37 | .firstOrNull { WindowManager.getInstance().suggestParentWindow(it)?.isActive ?: false } 38 | } 39 | 40 | fun getActiveModules(project: Project): List { 41 | return ModuleManager.getInstance(project).modules.toList() 42 | } 43 | 44 | fun getDependencyView( 45 | canvasConfig: CanvasConfig, 46 | methods: Set, 47 | dependencies: Set 48 | ): Set { 49 | return when (canvasConfig.buildType) { 50 | CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST_LIMITED, 51 | CanvasConfig.BuildType.WHOLE_PROJECT_WITHOUT_TEST_LIMITED, 52 | CanvasConfig.BuildType.MODULE_LIMITED, 53 | CanvasConfig.BuildType.DIRECTORY_LIMITED -> dependencies 54 | .filter { methods.contains(it.caller.element) && methods.contains(it.callee.element) } 55 | .toSet() 56 | CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST, 57 | CanvasConfig.BuildType.WHOLE_PROJECT_WITHOUT_TEST, 58 | CanvasConfig.BuildType.MODULE, 59 | CanvasConfig.BuildType.DIRECTORY -> dependencies 60 | .filter { methods.contains(it.caller.element) || methods.contains(it.callee.element) } 61 | .toSet() 62 | CanvasConfig.BuildType.UPSTREAM -> 63 | getNestedDependencyView(dependencies, methods, mutableSetOf(), true) 64 | CanvasConfig.BuildType.DOWNSTREAM -> 65 | getNestedDependencyView(dependencies, methods, mutableSetOf(), false) 66 | CanvasConfig.BuildType.UPSTREAM_DOWNSTREAM -> { 67 | val upstream = getNestedDependencyView(dependencies, methods, mutableSetOf(), true) 68 | val downstream = getNestedDependencyView(dependencies, methods, mutableSetOf(), false) 69 | upstream.union(downstream) 70 | } 71 | } 72 | } 73 | 74 | fun getMethodsInScope(canvasConfig: CanvasConfig, files: Set): Set { 75 | return when (canvasConfig.buildType) { 76 | CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST_LIMITED, 77 | CanvasConfig.BuildType.WHOLE_PROJECT_WITHOUT_TEST_LIMITED, 78 | CanvasConfig.BuildType.MODULE_LIMITED, 79 | CanvasConfig.BuildType.DIRECTORY_LIMITED, 80 | CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST, 81 | CanvasConfig.BuildType.WHOLE_PROJECT_WITHOUT_TEST, 82 | CanvasConfig.BuildType.MODULE, 83 | CanvasConfig.BuildType.DIRECTORY -> getMethodsFromFiles(files) 84 | CanvasConfig.BuildType.UPSTREAM, 85 | CanvasConfig.BuildType.DOWNSTREAM, 86 | CanvasConfig.BuildType.UPSTREAM_DOWNSTREAM -> canvasConfig.focusedMethods 87 | } 88 | } 89 | 90 | fun getMethodsFromFiles(files: Set) = 91 | files 92 | .flatMap { // get all classes 93 | try { 94 | (it as PsiJavaFile).classes.toList() 95 | } 96 | catch (e: ClassCastException) { // when file conversion to Java file type fails 97 | emptyList() 98 | } 99 | } 100 | .flatMap { it.methods.toList() } // get all methods 101 | .toSet() 102 | 103 | fun getDependenciesFromMethod(method: PsiMethod): List { 104 | val methodPointer = toSmartPsiElementPointer(method) 105 | return PsiTreeUtil 106 | .findChildrenOfType(method, PsiIdentifier::class.java) 107 | .mapNotNull { it.context } 108 | .flatMap { it.references.toList() } 109 | .map { it.resolve() } 110 | .filterIsInstance() 111 | .map { Dependency(methodPointer, toSmartPsiElementPointer(it)) } 112 | } 113 | 114 | private fun toSmartPsiElementPointer(method: PsiMethod) = 115 | SmartPointerManager.getInstance(method.project).createSmartPsiElementPointer(method) 116 | 117 | fun layout(graph: Graph) { 118 | // get connected components from the graph, and render each part separately 119 | val subGraphBlueprints = graph.connectedComponents 120 | .map { this.getLayoutFromGraphViz(it) } 121 | .map { this.normalizeBlueprintGridSize(it) } 122 | .toList() 123 | 124 | // merge all connected components to a single graph, then adjust node coordinates, so they fit in the view 125 | val mergedBlueprint = this.mergeNormalizedLayouts(subGraphBlueprints) 126 | applyRawLayoutBlueprintToGraph(mergedBlueprint, graph) 127 | applyLayoutBlueprintToGraph(mergedBlueprint, graph) 128 | } 129 | 130 | fun runBackgroundTask(project: Project, runnable: Runnable) { 131 | ProgressManager.getInstance() 132 | .run(object: Task.Backgroundable(project, "Call graph") { 133 | override fun run(progressIndicator: ProgressIndicator) { 134 | ApplicationManager 135 | .getApplication() 136 | .invokeLater(runnable) 137 | } 138 | }) 139 | } 140 | 141 | fun getMethodPackageName(method: PsiMethod): String { 142 | // get package name 143 | val psiJavaFile = method.containingFile as PsiJavaFile 144 | val packageName = psiJavaFile.packageStatement?.packageName ?: "" 145 | // get class name 146 | val className = method.containingClass?.qualifiedName ?: "" 147 | return if (packageName.isBlank() || className.startsWith(packageName)) className else "$packageName.$className" 148 | } 149 | 150 | fun getMethodFilePath(method: PsiMethod): String? { 151 | val file = method.containingFile.virtualFile 152 | val sourceRoot = getSourceRoot(file) 153 | return if (sourceRoot == null) null else VfsUtilCore.getRelativePath(file, sourceRoot) 154 | } 155 | 156 | fun getSourceRoot(file: VirtualFile): VirtualFile? { 157 | val project = getActiveProject() 158 | return if (project == null) null else ProjectRootManager.getInstance(project).fileIndex.getContentRootForFile(file) 159 | } 160 | 161 | fun getMethodSignature(method: PsiMethod): String { 162 | val parameterNames = method.parameterList.parameters.map { it.name }.joinToString() 163 | val parameters = if (parameterNames.isEmpty()) "" else "($parameterNames)" 164 | return "${method.name}$parameters" 165 | } 166 | 167 | fun fitLayoutToViewport(blueprint: Map): Map { 168 | val maxPoint = blueprint.values.reduce { a, b -> Point2D.Float(maxOf(a.x, b.x), maxOf(a.y, b.y)) } 169 | val minPoint = blueprint.values.reduce { a, b -> Point2D.Float(minOf(a.x, b.x), minOf(a.y, b.y)) } 170 | val graphSize = Point2D.Float(maxPoint.x - minPoint.x, maxPoint.y - minPoint.y) 171 | val bestFitBaseline = 0.1f // make the best fit window between 0.1 - 0.9 of the viewport 172 | val bestFitSize = 1 - 2 * bestFitBaseline 173 | return blueprint.mapValues { (_, point) -> 174 | Point2D.Float( 175 | (point.x - minPoint.x) / graphSize.x * bestFitSize + bestFitBaseline, 176 | (point.y - minPoint.y) / graphSize.y * bestFitSize + bestFitBaseline 177 | ) 178 | } 179 | } 180 | 181 | fun runCallGraphFromAction(anActionEvent: AnActionEvent, buildType: CanvasConfig.BuildType) { 182 | val project = anActionEvent.project 183 | val psiElement = anActionEvent.getData(CommonDataKeys.PSI_ELEMENT) // get the element under editor caret 184 | if (project != null && psiElement is PsiMethod) { 185 | ToolWindowManager.getInstance(project) 186 | .getToolWindow("Call Graph") 187 | ?.activate { 188 | project.service() 189 | .callGraphToolWindow 190 | .clearFocusedMethods() 191 | .toggleFocusedMethod(psiElement) 192 | .run(buildType) 193 | } 194 | } 195 | } 196 | 197 | fun setActionEnabledAndVisibleByContext(anActionEvent: AnActionEvent) { 198 | val project = anActionEvent.project 199 | val psiElement = anActionEvent.getData(CommonDataKeys.PSI_ELEMENT) 200 | anActionEvent.presentation.isEnabledAndVisible = project != null && psiElement is PsiMethod 201 | } 202 | 203 | fun isPublic(method: PsiMethod) = method.modifierList.hasModifierProperty(PsiModifier.PUBLIC) 204 | 205 | fun isProtected(method: PsiMethod) = method.modifierList.hasModifierProperty(PsiModifier.PROTECTED) 206 | 207 | fun isPackageLocal(method: PsiMethod) = method.modifierList.hasModifierProperty(PsiModifier.PACKAGE_LOCAL) 208 | 209 | fun isPrivate(method: PsiMethod) = method.modifierList.hasModifierProperty(PsiModifier.PRIVATE) 210 | 211 | fun getAllSourceCodeFiles(project: Project): Set { 212 | val sourceCodeRoots = getAllSourceCodeRoots(project) 213 | return getSourceCodeFiles(project, sourceCodeRoots) 214 | } 215 | 216 | fun getSourceCodeFiles(project: Project, sourceCodeRoots: Set) = 217 | sourceCodeRoots 218 | .flatMap { contentSourceRoot -> 219 | val childrenVirtualFiles = mutableListOf() 220 | VfsUtilCore.iterateChildrenRecursively(contentSourceRoot, null) { 221 | if (it.isValid && !it.isDirectory) { 222 | val extension = it.extension 223 | if (extension.equals("java")) { 224 | childrenVirtualFiles.add(it) 225 | } 226 | } 227 | true 228 | } 229 | childrenVirtualFiles 230 | } 231 | .mapNotNull { PsiManager.getInstance(project).findFile(it) } 232 | .toSet() 233 | 234 | fun applyLayoutBlueprintToGraph(blueprint: Map, graph: Graph) { 235 | blueprint.forEach { (nodeId, point) -> graph.getNode(nodeId).point.setLocation(point) } 236 | } 237 | 238 | fun getSourceCodeRoots(canvasConfig: CanvasConfig) = 239 | when (canvasConfig.buildType) { 240 | CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST_LIMITED, 241 | CanvasConfig.BuildType.WHOLE_PROJECT_WITH_TEST -> 242 | getAllSourceCodeRoots(canvasConfig.project) 243 | CanvasConfig.BuildType.WHOLE_PROJECT_WITHOUT_TEST_LIMITED, 244 | CanvasConfig.BuildType.WHOLE_PROJECT_WITHOUT_TEST -> 245 | getActiveModules(canvasConfig.project) 246 | .flatMap { ModuleRootManager.getInstance(it).getSourceRoots(false).toSet() } 247 | .toSet() 248 | CanvasConfig.BuildType.MODULE_LIMITED, CanvasConfig.BuildType.MODULE -> 249 | getSelectedModules(canvasConfig.project, canvasConfig.selectedModuleName) 250 | .flatMap { ModuleRootManager.getInstance(it).sourceRoots.toSet() } 251 | .toSet() 252 | CanvasConfig.BuildType.DIRECTORY_LIMITED, 253 | CanvasConfig.BuildType.DIRECTORY -> { 254 | val directoryPath = canvasConfig.selectedDirectoryPath 255 | listOfNotNull(LocalFileSystem.getInstance().findFileByPath(directoryPath)).toSet() 256 | } 257 | else -> emptySet() 258 | } 259 | 260 | private fun getNestedDependencyView( 261 | dependencies: Set, 262 | methods: Set, 263 | seenMethods: MutableSet, 264 | isUpstream: Boolean 265 | ): Set { 266 | if (methods.isEmpty()) { 267 | return emptySet() 268 | } 269 | val directPairs = dependencies.filter { 270 | methods.contains(if (isUpstream) it.callee.element else it.caller.element) 271 | }.toSet() 272 | val nextBatchMethods = directPairs 273 | .mapNotNull { if (isUpstream) it.caller.element else it.callee.element } 274 | .filter { !seenMethods.contains(it) } 275 | .toSet() 276 | seenMethods.addAll(nextBatchMethods) 277 | val nestedPairs = getNestedDependencyView(dependencies, nextBatchMethods, seenMethods, isUpstream) 278 | return directPairs + nestedPairs 279 | } 280 | 281 | private fun getLayoutFromGraphViz(graph: Graph): Map { 282 | // if graph only has one node, just set its coordinate to (0.5, 0.5), no need to call GraphViz 283 | if (graph.getNodes().size == 1) { 284 | return graph.getNodes() 285 | .map { it.id to Point2D.Float(0.5f, 0.5f) } 286 | .toMap() 287 | } 288 | // construct the GraphViz graph 289 | val gvGraph = mutGraph("test") 290 | .setDirected(true) 291 | .graphAttrs() 292 | .add(Rank.dir(Rank.RankDir.LEFT_TO_RIGHT)) 293 | graph.getNodes() 294 | .sortedBy { it.method.name } 295 | .forEach { node -> 296 | val gvNode = mutNode(node.id) 297 | node.outEdges.values 298 | .map { it.targetNode } 299 | .sortedBy { it.method.name } 300 | .forEach { gvNode.addLink(it.id) } 301 | gvGraph.add(gvNode) 302 | } 303 | 304 | // parse the GraphViz layout as a mapping from "node name" to "x-y coordinate (percent of full graph size)" 305 | // GraphViz doc: https://graphviz.gitlab.io/_pages/doc/info/output.html#d:plain 306 | val layoutRawText = Graphviz.fromGraph(gvGraph).render(Format.PLAIN).toString() 307 | return layoutRawText.split("\n") 308 | .filter { it.startsWith("node") } 309 | .map { it.split(" ") } 310 | .map { it[1] to Point2D.Float(it[2].toFloat(), it[3].toFloat()) } // (x, y) 311 | .toMap() 312 | } 313 | 314 | private fun normalizeBlueprintGridSize(blueprint: Map): Map { 315 | if (blueprint.size < 2) { 316 | return blueprint 317 | } 318 | val gridSize = getGridSize(blueprint) 319 | val desiredGridSize = Point2D.Float(NORMALIZED_GRID_SIZE, NORMALIZED_GRID_SIZE) 320 | val xFactor = if (gridSize.x == 0f) 1f else desiredGridSize.x / gridSize.x 321 | val yFactor = if (gridSize.y == 0f) 1f else desiredGridSize.y / gridSize.y 322 | return blueprint.mapValues { (_, point) -> Point2D.Float(point.x * xFactor, point.y * yFactor) } 323 | } 324 | 325 | private fun getGridSize(blueprint: Map): Point2D.Float { 326 | val precisionFactor = 1000 327 | val xUniqueValues = blueprint.values.map { Math.round(precisionFactor * it.x) }.toSet() 328 | val yUniqueValues = blueprint.values.map { Math.round(precisionFactor * it.y) }.toSet() 329 | return Point2D.Float( 330 | getAverageElementDifference(xUniqueValues) / precisionFactor, 331 | getAverageElementDifference(yUniqueValues) / precisionFactor 332 | ) 333 | } 334 | 335 | private fun getAverageElementDifference(elements: Set): Float { 336 | val max = elements.maxOrNull() 337 | val min = elements.minOrNull() 338 | return if (elements.size < 2 || max == null || min == null) 0f else (max - min) / (elements.size - 1).toFloat() 339 | } 340 | 341 | private fun mergeNormalizedLayouts(blueprints: List>): Map { 342 | if (blueprints.isEmpty()) { 343 | return emptyMap() 344 | } 345 | val blueprintSizes = blueprints 346 | .map { blueprint -> 347 | val xPoints = blueprint.values.map { it.x } 348 | val yPoints = blueprint.values.map { it.y } 349 | val max = Point2D.Float(xPoints.maxOrNull() ?: 0f, yPoints.maxOrNull() ?: 0f) 350 | val min = Point2D.Float(xPoints.minOrNull() ?: 0f, yPoints.minOrNull() ?: 0f) 351 | val width = max.x - min.x + NORMALIZED_GRID_SIZE 352 | val height = max.y - min.y + NORMALIZED_GRID_SIZE 353 | Triple(blueprint, height, width) 354 | } 355 | val sortedHeights = blueprintSizes.map { (_, height, _) -> height }.sortedBy { -it } 356 | val sortedBlueprints = blueprintSizes 357 | .toList() 358 | .sortedWith(compareBy({ (_, height, _) -> -height }, { (_, _, width) -> -width })) 359 | .map { (blueprint, _, _) -> blueprint } 360 | val baseline = Point2D.Float(0.5f, 0.5f) 361 | // put the left-most point of the first sub-graph in the view center, by using its y value as central line 362 | val yCentralLine = sortedBlueprints.first().values.minByOrNull { it.x }?.y ?: 0f 363 | return sortedBlueprints 364 | .mapIndexed { index, blueprint -> 365 | // calculate the y-offset of this sub-graph (by summing up all the height of previous sub-graphs) 366 | val yOffset = sortedHeights.subList(0, index).sum() 367 | // left align the graph by the left-most nodesMap, then centering the baseline 368 | val minX = blueprint.values.map { it.x }.minOrNull() ?: 0f 369 | //noinspection UnnecessaryLocalVariable 370 | blueprint.mapValues { (_, point) -> 371 | Point2D.Float( 372 | point.x - minX + baseline.x, 373 | point.y + yOffset - yCentralLine + baseline.y 374 | ) 375 | } 376 | } 377 | .reduce { blueprintA, blueprintB -> blueprintA + blueprintB } 378 | } 379 | 380 | private fun applyRawLayoutBlueprintToGraph(blueprint: Map, graph: Graph) { 381 | blueprint.forEach { (nodeId, point) -> graph.getNode(nodeId).rawLayoutPoint.setLocation(point) } 382 | } 383 | 384 | private fun getSelectedModules(project: Project, selectedModuleName: String): Set { 385 | return getActiveModules(project).filter { it.name == selectedModuleName }.toSet() 386 | } 387 | 388 | private fun getAllSourceCodeRoots(project: Project) = ProjectRootManager.getInstance(project).contentRoots.toSet() 389 | } 390 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/ViewDownstreamAction.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.openapi.actionSystem.AnAction 4 | import com.intellij.openapi.actionSystem.AnActionEvent 5 | 6 | class ViewDownstreamAction: AnAction() { 7 | override fun actionPerformed(anActionEvent: AnActionEvent) { 8 | Utils.runCallGraphFromAction(anActionEvent, CanvasConfig.BuildType.DOWNSTREAM) 9 | } 10 | 11 | override fun update(anActionEvent: AnActionEvent) { 12 | Utils.setActionEnabledAndVisibleByContext(anActionEvent) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/ViewUpstreamAction.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.openapi.actionSystem.AnAction 4 | import com.intellij.openapi.actionSystem.AnActionEvent 5 | 6 | class ViewUpstreamAction: AnAction() { 7 | override fun actionPerformed(anActionEvent: AnActionEvent) { 8 | Utils.runCallGraphFromAction(anActionEvent, CanvasConfig.BuildType.UPSTREAM) 9 | } 10 | 11 | override fun update(anActionEvent: AnActionEvent) { 12 | Utils.setActionEnabledAndVisibleByContext(anActionEvent) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/callgraph/ViewUpstreamDownstreamAction.kt: -------------------------------------------------------------------------------- 1 | package callgraph 2 | 3 | import com.intellij.openapi.actionSystem.AnAction 4 | import com.intellij.openapi.actionSystem.AnActionEvent 5 | 6 | class ViewUpstreamDownstreamAction: AnAction() { 7 | override fun actionPerformed(anActionEvent: AnActionEvent) { 8 | Utils.runCallGraphFromAction(anActionEvent, CanvasConfig.BuildType.UPSTREAM_DOWNSTREAM) 9 | } 10 | 11 | override fun update(anActionEvent: AnActionEvent) { 12 | Utils.setActionEnabledAndVisibleByContext(anActionEvent) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Call Graph 5 | 6 | 7 | 0.1.18 8 | 9 | 11 | call-graph 12 | 13 | 14 | A plugin to visualize function call graph of any codebase for IDEs based on the IntelliJ platform.

16 |

The goal is to make codebase extremely easy to understand, necessary for code navigation and debugging.

17 |

Currently it only supports Java. If you want a similar tool for Typescript, Javascript, or Python, I highly recommend Codemap, another tool I built.

18 |

See also:

19 |
    20 |
  • 21 | Source code: pull requests are welcome! 22 |
  • 23 |
  • 24 | Demo video: a quick glance of its features. 25 |
  • 26 |
  • 27 | Love this plugin? Please leave a review, or consider 28 | donation 29 | to support the developer. 30 |
  • 31 |
32 | ]]>
33 | 34 | 36 | 0.1:

38 |
    39 |
  • 40 | Initial release. 41 |
  • 42 |
  • 43 | Support Java. 44 |
  • 45 |
  • 46 | Support building call graph from functions of the entire project, a single module, or a single folder path. 47 |
  • 48 |
  • 49 | Support two graph layouts: fit to best ratio and fit to viewport. 50 | You can also tweak the grid size yourself. 51 |
  • 52 |
  • 53 | Support selecting a single node or multiple nodes to visualize upstream/downstream calls. 54 |
  • 55 |
  • 56 | Support code to graph: build call graph by right-clicking any function in the source code. 57 |
  • 58 |
  • 59 | Support graph to code: jump to function definition in the source code from any node in the graph. 60 |
  • 61 |
  • 62 | Support function name search: highlight nodes in the graph whose function name matches your search query. 63 |
  • 64 |
  • 65 | Support color-coding nodes by function access level (public, protected, package local, private) and class name. 66 |
  • 67 |
  • 68 | Support filtering nodes by function access level (public, protected, package local, private). 69 |
  • 70 |
71 | ]]>
72 | 73 | 75 | Chentai Kao 76 | 77 | 78 | com.intellij.modules.lang 79 | com.intellij.modules.java 80 | 81 | 84 | 85 | 86 | 89 | 90 | 91 | 92 | 93 | 94 | 96 | 97 | 98 | 99 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 115 | 116 | 117 | 119 | 120 | 121 | 122 | 123 | 124 | 129 | 130 | 131 | 132 | 143 | 148 | 155 | 160 | 161 | 166 | 167 | 172 | 173 | 181 | 182 | 183 | 184 | 185 | 187 | 188 | 189 | 190 | 191 | 194 | 198 | 199 | 204 | 205 | 211 | 212 | 213 | 216 | 217 | 222 | 223 | 228 | 231 |
232 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon_dark.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/icons/build.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/build.png -------------------------------------------------------------------------------- /src/main/resources/icons/color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/color.png -------------------------------------------------------------------------------- /src/main/resources/icons/compress-x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/compress-x.png -------------------------------------------------------------------------------- /src/main/resources/icons/compress-y.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/compress-y.png -------------------------------------------------------------------------------- /src/main/resources/icons/downstream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/downstream.png -------------------------------------------------------------------------------- /src/main/resources/icons/expand-x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/expand-x.png -------------------------------------------------------------------------------- /src/main/resources/icons/expand-y.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/expand-y.png -------------------------------------------------------------------------------- /src/main/resources/icons/filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/filter.png -------------------------------------------------------------------------------- /src/main/resources/icons/fit-ratio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/fit-ratio.png -------------------------------------------------------------------------------- /src/main/resources/icons/fit-viewport.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/fit-viewport.png -------------------------------------------------------------------------------- /src/main/resources/icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/main/resources/icons/navigation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/navigation.png -------------------------------------------------------------------------------- /src/main/resources/icons/node-selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/node-selection.png -------------------------------------------------------------------------------- /src/main/resources/icons/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/play.png -------------------------------------------------------------------------------- /src/main/resources/icons/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/search.png -------------------------------------------------------------------------------- /src/main/resources/icons/statistics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/statistics.png -------------------------------------------------------------------------------- /src/main/resources/icons/upstream-downstream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/upstream-downstream.png -------------------------------------------------------------------------------- /src/main/resources/icons/upstream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/upstream.png -------------------------------------------------------------------------------- /src/main/resources/icons/view-source-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/view-source-code.png -------------------------------------------------------------------------------- /src/main/resources/icons/view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chentai-Kao/call-graph-plugin/a8f8b2955502deb0e1eff485e0f03b75eb4a93c8/src/main/resources/icons/view.png --------------------------------------------------------------------------------