├── tests ├── __init__.py ├── test_cli.py ├── test_render.py └── test_core.py ├── callgraph ├── __init__.py ├── __main__.py ├── render.py ├── callgraph.py └── core.py ├── examples ├── loc.png ├── example1.png ├── example1-nodestats.png ├── example1-noshowall.png ├── example1.cmd ├── loc.cmd ├── example1-noshowall.dot ├── example1.dot ├── loc.dot └── example1-nodestats.dot ├── .github ├── dependabot.yml ├── workflows │ ├── dependabot-automerge.yml │ └── main.yml └── copilot-instructions.md ├── cmd-call-graph.py ├── scripts ├── generate-examples.sh └── generate-sample-cmd.py ├── LICENSE ├── .gitignore ├── setup.py ├── SECURITY.md ├── CHANGELOG.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /callgraph/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.2.1" 2 | -------------------------------------------------------------------------------- /callgraph/__main__.py: -------------------------------------------------------------------------------- 1 | from .callgraph import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /examples/loc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/cmd-call-graph/HEAD/examples/loc.png -------------------------------------------------------------------------------- /examples/example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/cmd-call-graph/HEAD/examples/example1.png -------------------------------------------------------------------------------- /examples/example1-nodestats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/cmd-call-graph/HEAD/examples/example1-nodestats.png -------------------------------------------------------------------------------- /examples/example1-noshowall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/cmd-call-graph/HEAD/examples/example1-noshowall.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /cmd-call-graph.py: -------------------------------------------------------------------------------- 1 | # cmd-call-graph.py 2 | # 3 | # Outputs a call graph for the given CMD / batch file in DOT 4 | # (https://en.wikipedia.org/wiki/DOT_(graph_description_language)). 5 | 6 | from callgraph.callgraph import main 7 | 8 | if __name__ == "__main__": 9 | main() 10 | -------------------------------------------------------------------------------- /examples/example1.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | call :foo 3 | goto :eof 4 | :bar 5 | echo "in bar" 6 | call :baz 7 | call :baz 8 | :baz 9 | echo "in baz" 10 | call powershell.exe Write-Host "Hello World from PowerShell" 11 | 12 | :foo 13 | echo "In foo" 14 | goto :bar 15 | -------------------------------------------------------------------------------- /examples/loc.cmd: -------------------------------------------------------------------------------- 1 | :one 2 | :two 3 | rem second line 4 | :three 5 | rem second line 6 | rem third line 7 | :four 8 | rem second line 9 | rem third line 10 | call powershell.exe foo.ps1 11 | :five 12 | rem second line 13 | rem third line 14 | call powershell.exe foo.ps1 15 | call powershell.exe foo.ps1 -------------------------------------------------------------------------------- /examples/example1-noshowall.dot: -------------------------------------------------------------------------------- 1 | digraph g { 2 | "__begin__" [color="#e6e6e6",style=filled,label=<__begin__
(line 1)
[terminating]>] 3 | "__begin__" -> "foo" [label=" call",color="#0078d4"] 4 | "bar" [label=<bar
(line 4)>] 5 | "bar" -> "baz" [label=" call",color="#0078d4"] 6 | "bar" -> "baz" [label=" nested",color="#008575"] 7 | "baz" [label=<baz
(line 8)>] 8 | "baz" -> "foo" [label=" nested",color="#008575"] 9 | "foo" [label=<foo
(line 12)>] 10 | "foo" -> "bar" [label=" goto",color="#d83b01"] 11 | } 12 | -------------------------------------------------------------------------------- /examples/example1.dot: -------------------------------------------------------------------------------- 1 | digraph g { 2 | "__begin__" [color="#e6e6e6",style=filled,label=<__begin__
(line 1)
[terminating]>] 3 | "__begin__" -> "foo" [label=<call
(line 2)>,color="#0078d4"] 4 | "bar" [label=<bar
(line 4)>] 5 | "bar" -> "baz" [label=<call
(line 6)>,color="#0078d4"] 6 | "bar" -> "baz" [label=<call
(line 7)>,color="#0078d4"] 7 | "bar" -> "baz" [label=" nested",color="#008575"] 8 | "baz" [label=<baz
(line 8)>] 9 | "baz" -> "foo" [label=" nested",color="#008575"] 10 | "foo" [label=<foo
(line 12)>] 11 | "foo" -> "bar" [label=<goto
(line 14)>,color="#d83b01"] 12 | } 13 | -------------------------------------------------------------------------------- /examples/loc.dot: -------------------------------------------------------------------------------- 1 | digraph g { 2 | "five" [color="#e6e6e6",style=filled,label=<five
(line 11)
[5 LOC]
[2 external calls]
[terminating]>] 3 | "four" [label=<four
(line 7)
[4 LOC]
[1 external call]>] 4 | "four" -> "five" [label=" nested",color="#008575"] 5 | "one" [label=<one
(line 1)
[1 LOC]>] 6 | "one" -> "two" [label=" nested",color="#008575"] 7 | "three" [label=<three
(line 4)
[3 LOC]>] 8 | "three" -> "four" [label=" nested",color="#008575"] 9 | "two" [label=<two
(line 2)
[2 LOC]>] 10 | "two" -> "three" [label=" nested",color="#008575"] 11 | } 12 | -------------------------------------------------------------------------------- /examples/example1-nodestats.dot: -------------------------------------------------------------------------------- 1 | digraph g { 2 | "__begin__" [color="#e6e6e6",style=filled,label=<__begin__
(line 1)
[3 LOC]
[terminating]>] 3 | "__begin__" -> "foo" [label=<call
(line 2)>,color="#0078d4"] 4 | "bar" [label=<bar
(line 4)
[4 LOC]>] 5 | "bar" -> "baz" [label=<call
(line 6)>,color="#0078d4"] 6 | "bar" -> "baz" [label=<call
(line 7)>,color="#0078d4"] 7 | "bar" -> "baz" [label=" nested",color="#008575"] 8 | "baz" [label=<baz
(line 8)
[4 LOC]
[1 external call]>] 9 | "baz" -> "foo" [label=" nested",color="#008575"] 10 | "foo" [label=<foo
(line 12)
[3 LOC]>] 11 | "foo" -> "bar" [label=<goto
(line 14)>,color="#d83b01"] 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot Auto-Merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | auto-merge: 10 | runs-on: ubuntu-latest 11 | if: github.actor == 'dependabot[bot]' 12 | steps: 13 | - name: Get dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | 19 | - name: Approve PR 20 | run: gh pr review --approve "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | 25 | - name: Enable auto-merge for Dependabot PRs 26 | run: gh pr merge --auto --merge "$PR_URL" 27 | env: 28 | PR_URL: ${{github.event.pull_request.html_url}} 29 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /scripts/generate-examples.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "example1.cmd, shows all calls" 5 | python cmd-call-graph.py --hide-node-stats examples/example1.cmd > examples/example1.dot 6 | echo 7 | echo "example1.cmd, shows all calls and the node stats" 8 | python cmd-call-graph.py examples/example1.cmd > examples/example1-nodestats.dot 9 | echo 10 | echo "example1.cmd, simplify calls and no node stats" 11 | python cmd-call-graph.py --simplify-calls --hide-node-stats examples/example1.cmd > examples/example1-noshowall.dot 12 | echo 13 | echo "loc.cmd, with simplified calls and node stats" 14 | python cmd-call-graph.py --simplify-calls examples/loc.cmd > examples/loc.dot 15 | 16 | dot -Tpng examples/example1.dot > examples/example1.png 17 | dot -Tpng examples/example1-nodestats.dot > examples/example1-nodestats.png 18 | dot -Tpng examples/example1-noshowall.dot > examples/example1-noshowall.png 19 | dot -Tpng examples/loc.dot > examples/loc.png 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /scripts/generate-sample-cmd.py: -------------------------------------------------------------------------------- 1 | # Generate some sample CMD scripts to aid with debugging. 2 | 3 | import random 4 | import sys 5 | 6 | # Number of functions to generate. 7 | NUM_FUNCTIONS = 30 8 | 9 | # Maximum number of LoC to generate. 10 | MAX_LENGTH = 100 11 | 12 | # Probability for any line of code to be a call to a random function. 13 | CALL_PROBABILITY = 0.05 14 | 15 | # Probability for any function to not terminate with "exit /b 0", which 16 | # results in a "nested" link in cmd-call-graph 17 | NESTED_PROBABILITY = 0.01 18 | 19 | functions = [u"function{}".format(i) for i in range(NUM_FUNCTIONS)] 20 | 21 | code = [u"@echo off", u"call :function0", u"exit /b 0"] 22 | 23 | for function in functions: 24 | code.append(":{}".format(function)) 25 | loc = random.randint(1, MAX_LENGTH) 26 | 27 | for i in range(loc): 28 | if random.random() < CALL_PROBABILITY: 29 | target = random.choice(functions) 30 | code.append(u" call :{}".format(target)) 31 | else: 32 | code.append(u" ; some code goes here.") 33 | 34 | if random.random() > NESTED_PROBABILITY: 35 | code.append(u"exit /b 0") 36 | 37 | if len(sys.argv) > 1: 38 | with open(sys.argv[1], "w") as f: 39 | f.write(u"\n".join(code)) 40 | else: 41 | print(u"\n".join(code)) 42 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import sys 4 | import unittest 5 | from unittest.mock import patch 6 | 7 | from callgraph.callgraph import main 8 | from callgraph import __version__ 9 | 10 | 11 | class CLITest(unittest.TestCase): 12 | """Tests for command-line interface functionality.""" 13 | 14 | def test_version_flag(self): 15 | """Test that --version flag outputs the correct version.""" 16 | with patch('sys.argv', ['cmd-call-graph', '--version']): 17 | with patch('sys.stdout', new=io.StringIO()) as mock_stdout: 18 | with self.assertRaises(SystemExit) as cm: 19 | # argparse exits with code 0 when --version is used 20 | main() 21 | 22 | # SystemExit with code 0 is expected for --version 23 | self.assertEqual(cm.exception.code, 0) 24 | 25 | # Verify the output contains the version from __version__ 26 | output = mock_stdout.getvalue() 27 | self.assertIn(__version__, output, 28 | f"Version output should contain {__version__}, got: {output}") 29 | 30 | def test_version_in_module(self): 31 | """Test that __version__ is defined and has correct format.""" 32 | self.assertIsNotNone(__version__) 33 | self.assertIsInstance(__version__, str) 34 | # Basic version format check (e.g., "1.2.1") 35 | parts = __version__.split('.') 36 | self.assertGreaterEqual(len(parts), 2) 37 | for part in parts: 38 | self.assertTrue(part.isdigit(), f"Version part '{part}' should be numeric") 39 | 40 | 41 | if __name__ == '__main__': 42 | unittest.main() 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS Files 2 | .DS_Store 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | test-results.xml 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | 110 | # vim 111 | *~ 112 | *.un~ 113 | 114 | # Visual Studio Code 115 | .vscode 116 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from setuptools import setup 3 | import re 4 | 5 | this_directory = path.abspath(path.dirname(__file__)) 6 | with open(path.join(this_directory, "README.md"), encoding="utf-8") as readme: 7 | long_description = readme.read() 8 | 9 | # Read version from __init__.py using regex to avoid exec() 10 | with open(path.join(this_directory, "callgraph", "__init__.py"), encoding="utf-8") as f: 11 | version_match = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE) 12 | if version_match: 13 | version = version_match.group(1) 14 | else: 15 | raise RuntimeError("Unable to find version string.") 16 | 17 | setup( 18 | name="cmd-call-graph", 19 | packages=["callgraph"], 20 | entry_points={ 21 | "console_scripts": ["cmd-call-graph = callgraph.callgraph:main"] 22 | }, 23 | version=version, 24 | author="Andrea Spadaccini", 25 | author_email="andrea.spadaccini@gmail.com", 26 | description="A simple tool to generate a call graph for calls within Windows CMD (batch) files.", 27 | license="MIT", 28 | long_description=long_description, 29 | long_description_content_type="text/markdown", 30 | url="https://github.com/Microsoft/cmd-call-graph", 31 | python_requires=">=3.10", 32 | classifiers=[ 33 | "Development Status :: 5 - Production/Stable", 34 | "Environment :: Console", 35 | "Intended Audience :: Developers", 36 | "License :: OSI Approved :: MIT License", 37 | "Operating System :: OS Independent", 38 | "Programming Language :: Python :: 3", 39 | "Programming Language :: Python :: 3.10", 40 | "Programming Language :: Python :: 3.11", 41 | "Programming Language :: Python :: 3.12", 42 | "Programming Language :: Python :: 3.13", 43 | "Programming Language :: Python :: 3.14", 44 | "Topic :: Scientific/Engineering :: Visualization", 45 | "Topic :: Software Development :: Documentation", 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | schedule: 9 | - cron: "0 1 * * *" 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 17 | steps: 18 | - uses: actions/checkout@v5 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v6 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install flake8 pytest pytest-cov 27 | # TODO: there are several flake8 errors right now. Once they are cleaned up we should uncomment this stanza. 28 | # - name: Lint with flake8 29 | # run: | 30 | # # stop the build if there are Python syntax errors or undefined names 31 | # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 32 | # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 33 | # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 34 | - name: Test with pytest 35 | run: | 36 | pytest tests --doctest-modules --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov=callgraph --cov-report=xml --cov-report=html 37 | - name: Upload pytest test results 38 | uses: actions/upload-artifact@v5 39 | with: 40 | name: pytest-results-${{ matrix.python-version }}.xml 41 | path: junit/test-results-${{ matrix.python-version }}.xml 42 | # Use always() to always run this step to publish test results when there are test failures 43 | if: ${{ always() }} 44 | generate-samples: 45 | runs-on: ubuntu-latest 46 | needs: test 47 | steps: 48 | - uses: actions/checkout@v5 49 | - name: Set up Python 3.10 50 | uses: actions/setup-python@v6 51 | with: 52 | python-version: "3.10" 53 | - name: Install dependencies 54 | run: | 55 | sudo apt-get install graphviz 56 | - name: Generate sample images 57 | run: | 58 | bash scripts/generate-examples.sh 59 | build: 60 | runs-on: ubuntu-latest 61 | needs: test 62 | steps: 63 | - uses: actions/checkout@v5 64 | - name: Set up Python 3.10 65 | uses: actions/setup-python@v6 66 | with: 67 | python-version: "3.10" 68 | - name: Build sdist 69 | run: | 70 | python setup.py sdist 71 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions for cmd-call-graph 2 | 3 | ## Repository Overview 4 | This repository contains tools and utilities for analyzing and visualizing call graphs in Windows batch files (.cmd, .bat) and command scripts. 5 | 6 | ## Project Context 7 | - **Purpose**: Generate and analyze call graphs for Windows command/batch scripts 8 | - **Primary Languages**: The codebase likely includes parsers and analyzers for CMD/BAT files 9 | - **Target Platform**: Windows command line environments 10 | 11 | ## Coding Standards and Conventions 12 | 13 | ### General Guidelines 14 | - Follow existing code patterns and style in the repository 15 | - Maintain backward compatibility with existing command line interfaces 16 | - Ensure all code works on Windows platforms 17 | - Include appropriate error handling for file I/O operations 18 | 19 | ### Command/Batch File Parsing 20 | - Handle both .cmd and .bat file extensions 21 | - Support common batch file constructs (CALL, GOTO, labels, etc.) 22 | - Account for case-insensitive command names in Windows 23 | - Handle environment variable expansion appropriately 24 | 25 | ### Graph Generation 26 | - Use standard graph notation and formats where applicable 27 | - Support multiple output formats if possible (DOT, JSON, etc.) 28 | - Ensure graph visualization is clear and readable 29 | 30 | ## Key Components 31 | - **Parser**: Handles parsing of CMD/BAT files 32 | - **Analyzer**: Builds call relationships and dependencies 33 | - **Visualizer**: Generates graph outputs 34 | 35 | ## Testing Guidelines 36 | - Test with various complexity levels of batch files 37 | - Include edge cases like recursive calls, conditional calls 38 | - Verify cross-file call detection works correctly 39 | - Test on different Windows versions when applicable 40 | 41 | ## Documentation 42 | - Include examples of usage in code comments 43 | - Document any limitations or known issues 44 | - Provide clear error messages for user-facing components 45 | 46 | ## Security Considerations 47 | - Sanitize file paths to prevent directory traversal 48 | - Be cautious with executing or evaluating batch file content 49 | - Validate input files before processing 50 | 51 | ## Performance Considerations 52 | - Optimize for large batch file repositories 53 | - Consider memory usage when processing multiple files 54 | - Implement caching where appropriate for repeated analyses 55 | 56 | ## Common Patterns to Follow 57 | - Use consistent naming for graph nodes (e.g., script names, labels) 58 | - Maintain a clear separation between parsing and visualization logic 59 | - Follow Windows path conventions and handle both forward and backslashes 60 | 61 | ## Dependencies and Libraries 62 | - List any specific libraries used for graph generation 63 | - Note any Windows-specific dependencies 64 | - Specify minimum supported Windows/PowerShell versions if applicable 65 | 66 | ## When Generating Code 67 | - Prefer clarity over brevity for batch file parsing logic 68 | - Include helpful comments explaining Windows-specific behaviors 69 | - Consider cross-platform compatibility where feasible 70 | - Add appropriate logging for debugging call graph generation 71 | 72 | ## Areas Needing Special Attention 73 | - Complex batch file constructs (nested calls, dynamic label generation) 74 | - Cross-file dependencies and external script calls 75 | - Performance optimization for large codebases 76 | - Edge cases in Windows command parsing 77 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project are documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 | 7 | This project uses [Semantic Versioning](https://semver.org) starting from version 8 | 1.0.0. 9 | 10 | ## [1.2.1] - 2019-11-08 11 | 12 | # Fixed 13 | 14 | - Fixed command-line options, which were broken due to my misunderstanding of store_true: 15 | options that had to be enabled by default were renamed with their corresponding 16 | negation (e.g., to enable by default --show-all-calls it was renamed to --simplify-calls 17 | and the corresponding logic was inverted. Same for --show-node-stats which became 18 | --hide-node-stats). 19 | 20 | ## [1.2.0] - 2019-11-08 21 | 22 | ## Changed 23 | 24 | - The -i option is now a positional parameter, called input 25 | - Enabled a few options by default. 26 | 27 | ## [1.1.0] - 2019-10-22 28 | 29 | ## Added 30 | 31 | - Added options to control the node size and make it proportional to the number of 32 | lines of code in the given function (Issue #29) 33 | 34 | ## [1.0.2] - 2019-01-09 35 | 36 | ## Fixed 37 | 38 | - Fixed handling of commands enclosed in parentheses (e.g., "if defined foo (exit /b 0)") 39 | 40 | ## [1.0.1] - 2019-01-09 41 | 42 | ## Fixed 43 | 44 | - Fixed exit node detection (#13) 45 | - Fixed eof node pruning 46 | 47 | ## Changed 48 | 49 | - Changed color palette to be more muted, and do not rely on color alone to convey 50 | information (fixes Issue #14) 51 | 52 | ## [1.0.0] - 2018-12-17 53 | 54 | ### Added 55 | 56 | - Added a Changelog, that retroactively covers all versions. 57 | - Added an option (-v, --verbose) to enable/disable verbose output (Issue #5) 58 | - Added options for input file (-i, --input), output file (-o, --output) and 59 | log file (-l, --log-file) (Issue #6) 60 | 61 | ### Fixed 62 | 63 | - Issue #15 (Fix handling of nodes with %) 64 | - Issue #17 (Create a ChangeLog) 65 | 66 | ### Changed 67 | 68 | - Changed the command-line options --show-all-calls and --show-node-stats to 69 | not require =True from the command line (Issue #18) 70 | 71 | ## [0.4] - 2018-12-12 72 | 73 | ### Added 74 | 75 | - Add an option to hide some nodes (--nodes-to-hide) 76 | 77 | ### Fixed 78 | 79 | - Allow connections to non-existing nodes 80 | - Fix comment detection logic 81 | - Issue #11 (Fix connection type for nodes ending in goto) 82 | 83 | ## [0.3] - 2018-12-08 84 | 85 | ### Added 86 | 87 | - Show number of external calls with --show-node-stats (fixes Issue #9) 88 | 89 | ### Fixed 90 | 91 | - Issue #8 (Fix treating the last block as exit node if it's not) 92 | 93 | 94 | ## [0.2] - 2018-12-06 95 | 96 | ### Added 97 | 98 | - Improved unit test coverage to ~90% (fixes Issue #4) 99 | - Add an option to show per-node statistics (--show-node-stats) 100 | - Automatic pruning of the EOF node if it's not used 101 | 102 | ### Fixed 103 | 104 | - Execution under Python 2.x 105 | - Issue #10 (Make the ordering of the resulting graph deterministic) 106 | - Logic for the generation of nested connections 107 | 108 | ## [0.1.2] - 2018-11-18 109 | 110 | ### Added 111 | 112 | - Most of the core functionality :) 113 | - First release pushed to PyPI 114 | 115 | ### Fixed 116 | 117 | - Fixed overflow error in tokenization of the exit command (by @refack) 118 | -------------------------------------------------------------------------------- /callgraph/render.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import sys 4 | 5 | from . import core 6 | 7 | def _Escape(input_string): 8 | return input_string.replace("%", r"\%") 9 | 10 | 11 | COLORS = { 12 | 'goto': '"#d83b01"', # Orange 13 | 'nested': '"#008575"', # Teal 14 | 'call': '"#0078d4"', # Blue 15 | 'terminating': '"#e6e6e6"', # Light gray 16 | } 17 | 18 | def PrintDot(call_graph, out_file=sys.stdout, log_file=sys.stderr, show_all_calls=True, show_node_stats=False, nodes_to_hide=None, represent_node_size=False, min_node_size=3, max_node_size=7, font_scale_factor=7): 19 | if min_node_size > max_node_size: 20 | min_node_size, max_node_size = max_node_size, min_node_size 21 | 22 | if max_node_size < 1: 23 | max_node_size = min_node_size = 1 24 | 25 | if min_node_size < 1: 26 | min_node_size = 1 27 | 28 | # Output the DOT code. 29 | print(u"digraph g {", file=out_file) 30 | 31 | max_node_loc = 0 32 | 33 | for node in sorted(call_graph.nodes.values()): 34 | if nodes_to_hide and (node.name in nodes_to_hide): 35 | continue 36 | 37 | if node.loc > max_node_loc: 38 | max_node_loc = node.loc 39 | 40 | for node in sorted(call_graph.nodes.values()): 41 | if nodes_to_hide and (node.name in nodes_to_hide): 42 | print(u"Skipping node {0}".format(node.name), file=log_file) 43 | continue 44 | 45 | name = node.name 46 | pretty_name = name 47 | if node.original_name != "": 48 | pretty_name = node.original_name 49 | 50 | print(u"Processing node {0} (using name: {1})".format(node.name, pretty_name), file=log_file) 51 | 52 | attributes = [] 53 | label_lines = ["{}".format(pretty_name)] 54 | 55 | if node.line_number > 0: 56 | label_lines.append("(line {})".format(node.line_number)) 57 | 58 | if show_node_stats: 59 | label_lines.append("[{} LOC]".format(node.loc)) 60 | command_count = node.GetCommandCount() 61 | external_call_count = command_count["external_call"] 62 | 63 | if external_call_count > 0: 64 | text = "call" if external_call_count == 1 else "calls" 65 | label_lines.append("[{} external {}]".format(external_call_count, text)) 66 | 67 | if node.is_exit_node: 68 | attributes.append("color={}".format(COLORS["terminating"])) 69 | attributes.append("style=filled") 70 | label_lines.append("[terminating]") 71 | 72 | attributes.append("label=<{}>".format("
".join(label_lines))) 73 | 74 | # Minimum width and height of each node proportional to the number of lines contained (self.loc) 75 | if represent_node_size: 76 | nw = round((node.loc / max_node_loc) * (max_node_size - min_node_size) + min_node_size, 1) 77 | nh = round(nw / 2, 1) 78 | attributes.append("width={}".format(nw)) 79 | attributes.append("height={}".format(nh)) 80 | 81 | # Font size set to be 7 times node width 82 | attributes.append("fontsize={}".format(nw * font_scale_factor)) 83 | 84 | if attributes: 85 | print(u"\"{}\" [{}]".format(name, ",".join(attributes)), file=out_file) 86 | 87 | # De-duplicate connections by line number if show_all_calls is set to 88 | # False. 89 | connections = node.connections 90 | if not show_all_calls: 91 | connections = list(set(core.Connection(c.dst, c.kind, core.NO_LINE_NUMBER) for c in connections)) 92 | 93 | for c in sorted(connections): 94 | # Remove EOF connections if necessary. 95 | if nodes_to_hide and (c.dst in nodes_to_hide): 96 | print(u"Skipping connection to node {0}".format(c.dst), file=log_file) 97 | continue 98 | label = "\" {}\"".format(c.kind) 99 | if c.line_number != core.NO_LINE_NUMBER: 100 | label = "<{}
(line {})>".format(c.kind, c.line_number) 101 | src_escaped_name = _Escape(name) 102 | dst_escaped_name = _Escape(c.dst) 103 | print(u"\"{}\" -> \"{}\" [label={},color={}]".format(src_escaped_name, dst_escaped_name, label, COLORS[c.kind]), file=out_file) 104 | 105 | print(u"}", file=out_file) 106 | -------------------------------------------------------------------------------- /callgraph/callgraph.py: -------------------------------------------------------------------------------- 1 | # Core functionality of cmd-call-graph. 2 | 3 | from __future__ import print_function 4 | 5 | import argparse 6 | import io 7 | import os 8 | import sys 9 | 10 | from . import core 11 | from . import render 12 | from . import __version__ 13 | 14 | DEFAULT_MIN_NODE_SIZE = 3 15 | DEFAULT_MAX_NODE_SIZE = 7 16 | DEFAULT_FONT_SCALE_FACTOR = 7 17 | 18 | def main(): 19 | parser = argparse.ArgumentParser() 20 | parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") 21 | parser.add_argument("input", help="Input cmd file.", 22 | type=str) 23 | parser.add_argument("--simplify-calls", 24 | help="Only show one edge for each type of call.", 25 | dest="simplifycalls", action="store_true") 26 | parser.add_argument("--hide-node-stats", 27 | help="Set to hide statistics about the nodes in the graph.", 28 | dest="hidenodestats", action="store_true") 29 | parser.add_argument("--represent-node-size", 30 | help="Nodes' size will be proportional to the number of lines they contain.", 31 | action="store_true", dest="nodesize") 32 | parser.add_argument("--nodes-to-hide", type=str, nargs="+", dest="nodestohide", 33 | help="List of space-separated nodes to hide.") 34 | parser.add_argument("-v", "--verbose", action="store_true", dest="verbose", 35 | help="Output extra information about what the program does.") 36 | parser.add_argument("-o", "--output", help="Output file. If it's not set, stdout is used.", 37 | type=str) 38 | parser.add_argument("-l", "--log-file", help="Log file. If it's not set, stderr is used.", 39 | type=str, dest="logfile") 40 | parser.add_argument("--min-node-size", help="Set minimum rendered node size.", 41 | dest="min_node_size", action="store", type=int, default=DEFAULT_MIN_NODE_SIZE) 42 | parser.add_argument("--max-node-size", help="Set maximum rendered node size.", 43 | dest="max_node_size", action="store", type=int, default=DEFAULT_MAX_NODE_SIZE) 44 | parser.add_argument("--font-scale-factor", help="Set the font scale factor", 45 | dest="font_scale_factor", action="store", type=int, default=DEFAULT_FONT_SCALE_FACTOR) 46 | 47 | args = parser.parse_args() 48 | 49 | nodes_to_hide = None 50 | if args.nodestohide: 51 | nodes_to_hide = set(x.lower() for x in args.nodestohide) 52 | 53 | log_file = sys.stderr 54 | if args.logfile: 55 | try: 56 | log_file = open(args.logfile, 'w') 57 | except IOError as e: 58 | print(u"Error opening {}: {}".format(args.logfile, e), file=sys.stderr) 59 | sys.exit(1) 60 | 61 | if not args.verbose: 62 | log_file = io.StringIO() # will just be ignored 63 | 64 | input_file = sys.stdin 65 | if args.input: 66 | try: 67 | input_file = open(args.input, 'r') 68 | except IOError as e: 69 | print(u"Error opening {}: {}".format(args.input, e), file=sys.stderr) 70 | sys.exit(1) 71 | 72 | output_file = sys.stdout 73 | if args.output: 74 | try: 75 | output_file = open(args.output, 'w') 76 | except IOError as e: 77 | print(u"Error opening {}: {}".format(args.output, e), file=sys.stderr) 78 | sys.exit(1) 79 | 80 | if args.min_node_size > args.max_node_size: 81 | print("Minimum node size should be less than maximum node size", file=sys.stderr) 82 | sys.exit(1) 83 | 84 | if args.font_scale_factor < 0: 85 | print("Font scale factor should be greater than zero", file=sys.stderr) 86 | sys.exit(1) 87 | 88 | try: 89 | call_graph = core.CallGraph.Build(input_file, log_file=log_file) 90 | render.PrintDot(call_graph, out_file=output_file, log_file=log_file, show_all_calls=not args.simplifycalls, 91 | show_node_stats=not args.hidenodestats, nodes_to_hide=nodes_to_hide, represent_node_size=args.nodesize, 92 | min_node_size=args.min_node_size, max_node_size=args.max_node_size, 93 | font_scale_factor=args.font_scale_factor) 94 | except Exception as e: 95 | print(u"Error processing the call graph: {}".format(e)) 96 | 97 | finally: 98 | if args.input: 99 | input_file.close() 100 | if args.output: 101 | output_file.close() 102 | if args.logfile: 103 | log_file.close() 104 | -------------------------------------------------------------------------------- /tests/test_render.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import unittest 4 | 5 | from callgraph.render import PrintDot 6 | from callgraph.core import CallGraph 7 | 8 | class RenderTest(unittest.TestCase): 9 | def setUp(self): 10 | self.devnull = open(os.devnull, "w") 11 | 12 | def tearDown(self): 13 | self.devnull.close() 14 | 15 | # The code contains a double call to the same label (foo). 16 | # If allgraph is set to False, it should count as a single call, 17 | # and no line annotations should appear in the graph, 18 | # while if it's set to True it should count as a double call. 19 | class PrintOptionsGraphTest(RenderTest): 20 | def setUp(self): 21 | RenderTest.setUp(self) 22 | code = """ 23 | call :foo 24 | call :foo 25 | call powershell.exe something.ps1 26 | call powershell.exe something.ps1 27 | exit 28 | :foo 29 | call powershell.exe something.ps1 30 | goto :%command% 31 | goto :eof 32 | """.split("\n") 33 | 34 | self.call_graph = CallGraph.Build(code, self.devnull) 35 | begin = self.call_graph.nodes["__begin__"] 36 | 37 | # There should only be two connections of type call in __begin__. 38 | self.assertEqual(2, len(begin.connections)) 39 | kinds = set(c.kind for c in begin.connections) 40 | self.assertEqual(1, len(kinds)) 41 | self.assertEqual("call", kinds.pop()) 42 | 43 | def test_duplicate_no_allgraph(self): 44 | f = io.StringIO() 45 | PrintDot(self.call_graph, f, show_all_calls=False, log_file=self.devnull) 46 | dot = f.getvalue() 47 | self.assertEqual(1, dot.count('"__begin__" -> "foo"'), "No connection found in the dot document: " + dot) 48 | 49 | # Test that no connections have line number annotations, 50 | # since connections are de-duplicated by line. 51 | self.assertEqual(0, dot.count("line 2")) 52 | self.assertEqual(0, dot.count("line 3")) 53 | 54 | def test_loc(self): 55 | f = io.StringIO() 56 | PrintDot(self.call_graph, f, show_node_stats=True, log_file=self.devnull) 57 | dot = f.getvalue() 58 | 59 | # Check the number of lines of code. 60 | self.assertEqual(1, dot.count('5 LOC')) 61 | self.assertEqual(1, dot.count('6 LOC')) 62 | 63 | def test_external_call(self): 64 | f = io.StringIO() 65 | PrintDot(self.call_graph, f, show_node_stats=True, log_file=self.devnull) 66 | dot = f.getvalue() 67 | 68 | self.assertEqual(1, dot.count('2 external calls]')) 69 | self.assertEqual(1, dot.count('1 external call]')) 70 | 71 | def test_duplicate_allgraph(self): 72 | f = io.StringIO() 73 | PrintDot(self.call_graph, f, show_all_calls=True, log_file=self.devnull) 74 | dot = f.getvalue() 75 | self.assertEqual(2, dot.count('"__begin__" -> "foo"')) 76 | 77 | # Test that connections do have line number annotations. 78 | self.assertEqual(1, dot.count("line 2")) 79 | self.assertEqual(1, dot.count("line 3")) 80 | 81 | def test_hide_eof(self): 82 | f = io.StringIO() 83 | PrintDot(self.call_graph, f, log_file=self.devnull, nodes_to_hide=set(["eof"])) 84 | dot = f.getvalue() 85 | self.assertEqual(0, dot.count("eof")) 86 | 87 | def test_percent(self): 88 | f = io.StringIO() 89 | PrintDot(self.call_graph, f, log_file=self.devnull) 90 | dot = f.getvalue() 91 | self.assertEqual(1, dot.count(r"\%command\%"), dot) 92 | 93 | 94 | # Regression test for issue #44: calls/goto in sub-expressions (nested within parentheses) 95 | # should be represented correctly in the rendered graph 96 | class ParenthesisLabelRenderTest(RenderTest): 97 | def setUp(self): 98 | RenderTest.setUp(self) 99 | # Code with goto and call statements nested in parentheses 100 | code = """ 101 | if foo==bar (call :foo) ELSE (goto :bar) 102 | exit 103 | :foo 104 | echo "in foo" 105 | goto :eof 106 | :bar 107 | echo "in bar" 108 | goto :eof 109 | """.split("\n") 110 | 111 | self.call_graph = CallGraph.Build(code, self.devnull) 112 | 113 | def test_parenthesis_labels_in_rendered_graph(self): 114 | """Verify that calls to labels in sub-expressions are rendered with correct label names (without parentheses)""" 115 | f = io.StringIO() 116 | PrintDot(self.call_graph, f, show_all_calls=True, log_file=self.devnull) 117 | dot = f.getvalue() 118 | 119 | # Verify that connections point to correct labels (without trailing parenthesis) 120 | self.assertIn('"__begin__" -> "foo"', dot, "Connection to 'foo' should be present") 121 | self.assertIn('"__begin__" -> "bar"', dot, "Connection to 'bar' should be present") 122 | 123 | # Verify that incorrect labels (with parenthesis) are NOT in the graph 124 | self.assertNotIn('"foo)"', dot, "Label 'foo)' should not be present") 125 | self.assertNotIn('"bar)"', dot, "Label 'bar)' should not be present") 126 | self.assertNotIn('-> "foo)"', dot, "Connection to 'foo)' should not be present") 127 | self.assertNotIn('-> "bar)"', dot, "Connection to 'bar)' should not be present") 128 | 129 | # Verify the actual node definitions use correct names 130 | self.assertIn('"foo"', dot, "Node 'foo' should be defined") 131 | self.assertIn('"bar"', dot, "Node 'bar' should be defined") 132 | 133 | 134 | if __name__ == "__main__": 135 | unittest.main() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cmd-call-graph 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/cmd-call-graph.svg)](https://pypi.org/project/cmd-call-graph/) 4 | 5 | A simple tool to generate a call graph for calls within Windows CMD (batch) files. 6 | 7 | The tool is available on PyPI: https://pypi.org/project/cmd-call-graph/ 8 | 9 | By default, it takes the input file as stdin and outputs the resulting file 10 | to stdout, outputting logs and errors to stderr. 11 | 12 | ## Output Examples 13 | 14 | Given the following CMD script: 15 | 16 | ``` 17 | @echo off 18 | call :foo 19 | goto :eof 20 | :bar 21 | echo "in bar" 22 | call :baz 23 | call :baz 24 | :baz 25 | echo "in baz" 26 | call powershell.exe Write-Host "Hello World from PowerShell" 27 | 28 | :foo 29 | echo "In foo" 30 | goto :bar 31 | ``` 32 | 33 | This script would generate the following graph: 34 | 35 | ![call graph](examples/example1-nodestats.png) 36 | 37 | If the `--hide-node-stats` option is enabled, then the following graph would be generated: 38 | 39 | ![call graph showall](examples/example1.png) 40 | 41 | ## Invocation Examples 42 | 43 | Invocation example for Ubuntu Linux and WSL (Windows Subsystem for Linux), assumes 44 | Python and `pip` are installed: 45 | 46 | ```bash 47 | $ pip install cmd-call-graph 48 | $ cmd-call-graph < your-file.cmd > your-file-call-graph.dot 2>log 49 | ``` 50 | 51 | The resulting `dot` file can be rendered with any `dot` renderer. Example with 52 | graphviz (`VIEWER` could be `explorer.exe` under Windows): 53 | 54 | ```bash 55 | $ sudo apt install graphviz 56 | $ dot -Tpng your-file-call-graph.dot > your-file-call-graph.png 57 | $ $VIEWER your-file-call-graph.png 58 | ``` 59 | 60 | Example with PowerShell: 61 | 62 | ```powershell 63 | PS C:\> choco install graphviz python3 pip 64 | PS C:\> cmd-call-graph.exe -i your-file.cmd -o your-file-call-graph.dot 65 | PS C:\> dot.exe -Tpng your-file-call-graph.dot -O 66 | PS C:\> explorer.exe your-file-call-graph.dot.png 67 | ``` 68 | 69 | ## Types of entities represented 70 | 71 | The script analyzes CMD scripts, and represents each block of text under a given label as a *node* in 72 | the call graph. 73 | 74 | ### Node properties 75 | 76 | Each node always contains the line number where it starts, except if the node is never defined in the code, 77 | which can happen in case of programming errors, dynamic node names (e.g., `%command%`) and the `eof` pseudo-node. 78 | 79 | If a node causes the program to exit, it is marked as `terminating`. 80 | 81 | Each node contains the following extra stats, if present: 82 | 83 | * number of lines of code (`LOC`); 84 | * number of external calls. 85 | 86 | ### Special nodes 87 | 88 | There are 2 special nodes: 89 | 90 | * `_begin_` is a pseudo-node inserted at the start of each call graph, which represents the start of the 91 | script, which is by definition without a label; 92 | * `eof`, which may or may not be a pseudo-node. In CMD, `eof` is a special node that is used as target 93 | of `goto` to indicate that the current "subroutine" should terminate, or the whole program should 94 | terminate if the call stack is empty. 95 | 96 | The `eof` node is automatically removed if it's a pseudo-node and it's not reached via `call` or `nested` 97 | connections. 98 | 99 | The `_begin_` pseudo-node is removed if there is another node starting at line 1. 100 | 101 | ### Types of connections 102 | 103 | * `goto`: if an edge of type `goto` goes from `A` to `B`, it means that in the code within the label `A` 104 | there is an instruction in the form `goto :B`. 105 | * `call`: if an edge of type `call` goes from `A` to `B`, it means that in the code within the label `A` 106 | there is an instruction in the form `call :B`. 107 | * `nested`: if an edge of type `nested` goes from `A` to `B`, it means that in the code within the label `A` 108 | ends directly where `B` starts, and there is no `goto` or `exit` statement at the end of `A` which would 109 | prevent the execution from not going into `B` as `A` ends. 110 | 111 | Example of a `nested` connection: 112 | 113 | ``` 114 | A: 115 | echo "foo" 116 | echo "bar" 117 | B: 118 | echo "baz" 119 | ``` 120 | 121 | The above code would lead to a `nested` connection between `A` and `B`. 122 | 123 | ## Command-line options 124 | 125 | The input file needs to be passed as an argument. 126 | 127 | * `--simplify-calls`: create one edge for each type of connection instead of creating one for each 128 | individual `call`/`goto` (which is the default). Leads to a simpler but less accurate graph; 129 | * `--hide-node-stats`: removes from each node additional information about itself (i.e., number 130 | of lines of code, number of external calls); 131 | * `--nodes-to-hide`: hides the list of nodes passed as a space-separated list after this parameter. 132 | * `-v` or `--verbose`: enable debug output, which will be sent to the log file; 133 | * `-l` or `--log-file`: name of the log file. If not specified, the standard error file is used; 134 | * `-o` or `--output`: name of the output file. If not specified, the standard output file is used. 135 | 136 | ## Legend for Output Graphs 137 | 138 | The graphs are self-explanatory: all information is codified with descriptive labels, and there is no 139 | information conveyed only with color or other types of non-text graphical hint. 140 | 141 | Colors are used to make the graph easier to follow, but no information is conveyed only with color. 142 | 143 | Here is what each color means in the graph: 144 | 145 | * Orange: `goto` connection; 146 | * Blue: `call` connection; 147 | * Teal: `nested` connection; 148 | * Light gray: background for terminating nodes 149 | 150 | ## Why? 151 | Sometimes legacy code bases may contain old CMD files. This tool allows to 152 | generate a visual representation of the internal calls within the script. 153 | 154 | ## Contributing 155 | 156 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 157 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 158 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 159 | 160 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 161 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 162 | provided by the bot. You will only need to do this once across all repos using our CLA. 163 | 164 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 165 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 166 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 167 | 168 | ## Unit tests 169 | Run unit tests from the project root either with the built-in `unittest` module: 170 | 171 | python -m unittest discover 172 | 173 | Or by using `pytests`, which can produce reports both for unit test success and for code coverage, by 174 | using the following invocation: 175 | 176 | pip install pytest 177 | pip install pytest-cov 178 | pytest tests --doctest-modules --junitxml=junit/test-results.xml --cov=callgraph --cov-report=xml --cov-report=html 179 | -------------------------------------------------------------------------------- /callgraph/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import collections 4 | import itertools 5 | import sys 6 | 7 | NO_LINE_NUMBER = -1 8 | 9 | Command = collections.namedtuple("Command", ["command", "target"]) 10 | 11 | # Line of code. Not a namedtuple because we need mutability. 12 | 13 | 14 | class CodeLine: 15 | def __init__(self, number, text, terminating=False, noop=False): 16 | self.number = number 17 | self.text = text 18 | self.terminating = terminating 19 | self.noop = noop 20 | self.commands = [] 21 | self.commands_counter = collections.Counter() 22 | 23 | def AddCommand(self, command): 24 | self.commands.append(command) 25 | self.commands_counter[command.command] += 1 26 | 27 | def __repr__(self): 28 | return "[{0} (terminating: {1}, noop: {2}, commands: {3})] {4}".format(self.number, self.terminating, self.noop, self.commands, self.text) 29 | 30 | def __eq__(self, other): 31 | return other is not None and self.number == other.number and self.text == other.text and self.terminating == other.terminating 32 | 33 | # Connection between two nodes. 34 | # dst is the name of the target node, kind is the type of connection, 35 | # line_number is the line where the call/goto happens. 36 | Connection = collections.namedtuple("Connection", ["dst", "kind", "line_number"]) 37 | 38 | # Node in the call graph. 39 | 40 | 41 | class Node: 42 | def __init__(self, name): 43 | self.name = name 44 | self.connections = set() 45 | self.line_number = NO_LINE_NUMBER 46 | self.original_name = name 47 | self.is_exit_node = False 48 | self.is_last_node = False 49 | self.code = [] 50 | self.loc = 0 51 | self.node_width = 0 52 | self.node_height = 0 53 | 54 | def AddConnection(self, dst, kind, line_number=NO_LINE_NUMBER): 55 | self.connections.add(Connection(dst, kind, line_number)) 56 | 57 | def AddCodeLine(self, line_number, code): 58 | self.code.append(CodeLine(line_number, code.strip().lower(), False)) 59 | self.loc += 1 60 | 61 | def GetCommandCount(self): 62 | node_counter = collections.Counter() 63 | 64 | for line in self.code: 65 | for command, count in line.commands_counter.items(): 66 | node_counter[command] += count 67 | return node_counter 68 | 69 | def __repr__(self): 70 | return "{0}. {1}, {2}".format(self.name, self.code, self.connections) 71 | 72 | def __lt__(self, other): 73 | if other is None: 74 | return False 75 | return self.name < other.name 76 | 77 | 78 | class CallGraph: 79 | def __init__(self, log_file=sys.stderr): 80 | self.nodes = {} 81 | self.log_file = log_file 82 | self.first_node = None 83 | 84 | def GetOrCreateNode(self, name): 85 | if name in self.nodes: 86 | return self.nodes[name] 87 | 88 | node = Node(name) 89 | self.nodes[name] = node 90 | return node 91 | 92 | def _MarkExitNodes(self): 93 | # A node is an exit node if: 94 | # 1. it contains an "exit" command with no target 95 | # or 96 | # 2. it's reached from the starting node via "goto" or 97 | # "nested" connections 98 | # and it contains an exit command or a "goto eof" command. 99 | 100 | # Identify all nodes with an exit command with no targets. 101 | for node in self.nodes.values(): 102 | all_commands = set(itertools.chain.from_iterable(line.commands for line in node.code)) 103 | exit_cmd = Command("exit", "") 104 | if exit_cmd in all_commands: 105 | node.is_exit_node = True 106 | 107 | # Visit the call graph to find nodes satisfying condition #2. 108 | q = [self.first_node] 109 | visited = set() # Used to avoid loops, since the call graph is not acyclic. 110 | 111 | while q: 112 | cur = q.pop() 113 | visited.add(cur.name) 114 | 115 | # Evaluate condition for marking exit node. 116 | if cur.is_last_node: 117 | cur.is_exit_node = True 118 | else: 119 | all_commands = itertools.chain.from_iterable(line.commands for line in cur.code) 120 | for command in all_commands: 121 | if command[0] == "exit" or (command[0] == "goto" and command[1] == "eof"): 122 | cur.is_exit_node = True 123 | break 124 | 125 | for connection in cur.connections: 126 | if connection.dst not in self.nodes or connection.dst in visited: 127 | continue 128 | if connection.kind == "nested" or connection.kind == "goto": 129 | q.append(self.nodes[connection.dst]) 130 | 131 | # Adds to each node information depending on the 132 | # contents of the code, such as connections 133 | # deriving from goto/call commands and 134 | # whether the node is terminating or not. 135 | def _AnnotateNode(self, node): 136 | print(u"Annotating node {0} (line {1})".format(node.original_name, node.line_number), file=self.log_file) 137 | for i in range(len(node.code)): 138 | line = node.code[i] 139 | line_number = line.number 140 | text = line.text 141 | 142 | # Tokenize the line of code, and store all elements that 143 | # warrant augmenting the 144 | # node in a list (interesting_commands), which will then 145 | # be processed later. 146 | tokens = text.strip().lower().split() 147 | if not tokens: 148 | line.noop = True 149 | continue 150 | 151 | for i, token in enumerate(tokens): 152 | # Remove open/close parenthesis from the start/end of the command, to deal with inline commands 153 | # enclosed in parentheses. 154 | token = token.lstrip("(").rstrip(")") 155 | 156 | # Comment; stop processing the rest of the line. 157 | if token.startswith("::") or token == "rem" or token.startswith("@::") or token == "@rem": 158 | line.noop = True 159 | break 160 | 161 | if token == "goto" or token == "@goto": 162 | # Strip parentheses from the target before extracting the label name 163 | target = tokens[i+1].lstrip("(").rstrip(")") 164 | block_name = target[1:] 165 | if not block_name: 166 | continue 167 | line.AddCommand(Command("goto", block_name)) 168 | continue 169 | 170 | if token == "call" or token == "@call": 171 | # Strip parentheses from the target before processing 172 | target = tokens[i+1].lstrip("(").rstrip(")") 173 | if target[0] != ":": 174 | line.AddCommand(Command("external_call", target)) 175 | continue 176 | block_name = target[1:] 177 | if not block_name: 178 | continue 179 | line.AddCommand(Command("call", block_name)) 180 | continue 181 | 182 | if token == "exit" or token == "@exit": 183 | target = "" 184 | if i+1 < len(tokens): 185 | target = tokens[i+1] 186 | line.AddCommand(Command("exit", target)) 187 | 188 | for command, target in line.commands: 189 | if command == "call" or command == "goto": 190 | node.AddConnection(target, command, line_number) 191 | print(u"Line {} has a goto towards: <{}>. Current block: {}".format(line_number, target, node.name), file=self.log_file) 192 | 193 | if (command == "goto" and target == "eof") or command == "exit": 194 | line.terminating = True 195 | 196 | if command == "exit" and target == "": 197 | line.terminating = True 198 | 199 | @staticmethod 200 | def Build(input_file, log_file=sys.stderr): 201 | call_graph = CallGraph._ParseSource(input_file, log_file) 202 | for node in call_graph.nodes.values(): 203 | call_graph._AnnotateNode(node) 204 | 205 | # Prune away EOF if it is a virtual node (no line number) and 206 | # there are no call/nested connections to it. 207 | eof = call_graph.GetOrCreateNode("eof") 208 | all_connections = itertools.chain.from_iterable(n.connections for n in call_graph.nodes.values()) 209 | destinations = set((c.dst, c.kind) for c in all_connections) 210 | if eof.line_number == NO_LINE_NUMBER and ("eof", "call") not in destinations and ("eof", "nested") not in destinations: 211 | print(u"Removing the eof node, since there are no call/nested connections to it and it's not a real node", file=log_file) 212 | del call_graph.nodes["eof"] 213 | for node in call_graph.nodes.values(): 214 | eof_connections = [c for c in node.connections if c.dst == "eof"] 215 | print(u"Removing {} eof connections in node {}".format(len(eof_connections), node.name), file=log_file) 216 | for c in eof_connections: 217 | node.connections.remove(c) 218 | 219 | # Warn the user if there are goto connections to eof 220 | # which will not be executed by CMD. 221 | if eof.line_number != NO_LINE_NUMBER and ("eof", "goto") in destinations: 222 | print(u"WARNING: there are goto connections to eof, but CMD will not execute that code via goto.", file=log_file) 223 | 224 | # Find and mark the "nested" connections. 225 | nodes = [n for n in call_graph.nodes.values() if n.line_number != NO_LINE_NUMBER] 226 | nodes_by_line_number = sorted(nodes, key=lambda x: x.line_number) 227 | for i in range(1, len(nodes_by_line_number)): 228 | cur_node = nodes_by_line_number[i] 229 | prev_node = nodes_by_line_number[i-1] 230 | 231 | # Special case: the previous node has no code or all lines are 232 | # comments / empty lines. 233 | all_noop = all(line.noop for line in prev_node.code) 234 | if not prev_node.code or all_noop: 235 | print(u"Adding nested connection between {0} and {1} because all_noop ({2}) or empty code ({3})".format( 236 | prev_node.name, cur_node.name, all_noop, not prev_node.code), file=log_file) 237 | prev_node.AddConnection(cur_node.name, "nested") 238 | break 239 | 240 | # Heuristic for "nested" connections: 241 | # iterate the previous node's commands, and create a nested 242 | # connection only if the command that logically precedes the 243 | # current node does not contain a goto or an exit (which would mean 244 | # that the current node is not reached by "flowing" from the 245 | # previous node to the current node.) 246 | for line in reversed(prev_node.code): 247 | # Skip comments and empty lines. 248 | if line.noop: 249 | continue 250 | 251 | commands = set(c.command for c in line.commands) 252 | if "exit" not in commands and "goto" not in commands: 253 | print(u"Adding nested connection between {0} and {1} because there is a non-exit or non-goto command.".format( 254 | prev_node.name, cur_node.name), file=log_file) 255 | prev_node.AddConnection(cur_node.name, "nested") 256 | 257 | break 258 | 259 | # Mark all exit nodes. 260 | last_node = max(call_graph.nodes.values(), key=lambda x: x.line_number) 261 | print(u"{0} is the last node, marking it as exit node.".format(last_node.name), file=log_file) 262 | last_node.is_last_node = True 263 | call_graph._MarkExitNodes() 264 | 265 | return call_graph 266 | 267 | # Creates a call graph from an input file, parsing the file in blocks and 268 | # creating one node for each block. Note that the nodes don't contain any 269 | # information that depend on the contents of the node, as this is just the 270 | # starting point for the processing. 271 | @staticmethod 272 | def _ParseSource(input_file, log_file=sys.stderr): 273 | call_graph = CallGraph(log_file) 274 | # Special node to signal the start of the script. 275 | cur_node = call_graph.GetOrCreateNode("__begin__") 276 | cur_node.line_number = 1 277 | call_graph.first_node = cur_node 278 | 279 | # Special node used by cmd to signal the end of the script. 280 | eof = call_graph.GetOrCreateNode("eof") 281 | eof.is_exit_node = True 282 | 283 | for line_number, line in enumerate(input_file, 1): 284 | line = line.strip() 285 | 286 | # Start of new block. 287 | if line.startswith(":") and not line.startswith("::"): 288 | # In the off chance that there are multiple words, 289 | # cmd considers the first word the label name. 290 | original_block_name = line[1:].split()[0].strip() 291 | 292 | # Since cmd is case-insensitive, let's convert block names to 293 | # lowercase. 294 | block_name = original_block_name.lower() 295 | 296 | print(u"Line {} defines a new block: <{}>".format(line_number, block_name), file=log_file) 297 | if block_name: 298 | next_node = call_graph.GetOrCreateNode(block_name) 299 | next_node.line_number = line_number 300 | next_node.original_name = original_block_name 301 | 302 | # If this node is defined on line one, remove __begin__, 303 | # so we avoid having two 304 | # nodes with the same line number. 305 | if line_number == 1: 306 | del call_graph.nodes["__begin__"] 307 | call_graph.first_node = next_node 308 | 309 | cur_node = next_node 310 | 311 | cur_node.AddCodeLine(line_number, line) 312 | 313 | return call_graph 314 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import unittest 4 | 5 | from callgraph.core import CallGraph, CodeLine, Command 6 | 7 | 8 | class CodeLineTest(unittest.TestCase): 9 | def test_command_counters(self): 10 | line = CodeLine(0, "foo") 11 | line.AddCommand(Command("goto", "")) 12 | line.AddCommand(Command("goto", "")) 13 | line.AddCommand(Command("external_call", "")) 14 | 15 | self.assertEqual(2, line.commands_counter["goto"]) 16 | self.assertEqual(1, line.commands_counter["external_call"]) 17 | 18 | class CallGraphTest(unittest.TestCase): 19 | def setUp(self): 20 | self.devnull = open(os.devnull, "w") 21 | 22 | def tearDown(self): 23 | self.devnull.close() 24 | 25 | class ParseSourceTests(CallGraphTest): 26 | def test_one_block(self): 27 | code = """ 28 | do something 29 | do something else 30 | exit 31 | """.split("\n") 32 | call_graph = CallGraph._ParseSource(code, self.devnull) 33 | self.assertEqual(2, len(call_graph.nodes)) 34 | self.assertIn("__begin__", call_graph.nodes.keys()) 35 | 36 | begin_node = call_graph.nodes["__begin__"] 37 | self.assertEqual(0, len(begin_node.connections)) 38 | self.assertEqual(1, begin_node.line_number) 39 | 40 | def test_two_blocks(self): 41 | code = """ 42 | do something 43 | :foo 44 | exit 45 | """.split("\n") 46 | call_graph = CallGraph._ParseSource(code, self.devnull) 47 | self.assertEqual(3, len(call_graph.nodes)) 48 | self.assertIn("__begin__", call_graph.nodes.keys()) 49 | self.assertIn("foo", call_graph.nodes.keys()) 50 | 51 | begin_node = call_graph.nodes["__begin__"] 52 | self.assertEqual(0, len(begin_node.connections)) 53 | self.assertEqual(1, begin_node.line_number) 54 | 55 | foo_node = call_graph.nodes["foo"] 56 | self.assertEqual(0, len(foo_node.connections)) 57 | self.assertEqual(3, foo_node.line_number) 58 | 59 | def test_comment_ignore(self): 60 | code = """ 61 | ::do something 62 | @:: do something else 63 | rem do something 64 | @rem do something else 65 | :foo 66 | exit 67 | """.split("\n") 68 | call_graph = CallGraph._ParseSource(code, self.devnull) 69 | self.assertEqual(3, len(call_graph.nodes)) 70 | self.assertIn("__begin__", call_graph.nodes.keys()) 71 | self.assertIn("foo", call_graph.nodes.keys()) 72 | 73 | begin_node = call_graph.nodes["__begin__"] 74 | self.assertEqual(0, len(begin_node.connections)) 75 | self.assertEqual(1, begin_node.line_number) 76 | 77 | call_graph._AnnotateNode(begin_node) 78 | for line in begin_node.code: 79 | self.assertTrue(line.noop) 80 | 81 | foo_node = call_graph.nodes["foo"] 82 | self.assertEqual(0, len(foo_node.connections)) 83 | self.assertEqual(6, foo_node.line_number) 84 | 85 | class BasicBuildTests(CallGraphTest): 86 | def test_empty(self): 87 | call_graph = CallGraph.Build("", self.devnull) 88 | self.assertEqual(1, len(call_graph.nodes)) 89 | self.assertIn("__begin__", call_graph.nodes.keys()) 90 | 91 | def test_eof_defined_once(self): 92 | call_graph = CallGraph.Build([":eof"], self.devnull) 93 | self.assertEqual(1, len(call_graph.nodes)) 94 | self.assertIn("eof", call_graph.nodes.keys()) 95 | 96 | def test_simple_call(self): 97 | code = """ 98 | call :foo 99 | exit 100 | :foo 101 | goto :eof 102 | """.split("\n") 103 | 104 | call_graph = CallGraph.Build(code, self.devnull) 105 | self.assertEqual(2, len(call_graph.nodes)) 106 | self.assertIn("__begin__", call_graph.nodes.keys()) 107 | self.assertIn("foo", call_graph.nodes.keys()) 108 | 109 | begin = call_graph.nodes["__begin__"] 110 | self.assertTrue(begin.is_exit_node) 111 | self.assertEqual(1, len(begin.connections)) 112 | self.assertFalse(begin.is_last_node) 113 | 114 | connection = begin.connections.pop() 115 | self.assertEqual("call", connection.kind) 116 | self.assertEqual("foo", connection.dst) 117 | 118 | foo = call_graph.nodes["foo"] 119 | self.assertTrue(foo.is_last_node) 120 | 121 | def test_handle_nonexisting_target(self): 122 | code = """ 123 | goto :nonexisting 124 | """.split("\n") 125 | call_graph = CallGraph.Build(code, self.devnull) 126 | self.assertEqual(1, len(call_graph.nodes)) 127 | self.assertIn("__begin__", call_graph.nodes.keys()) 128 | begin_node = call_graph.nodes["__begin__"] 129 | self.assertEqual(1, len(begin_node.connections)) 130 | self.assertEqual("nonexisting", begin_node.connections.pop().dst) 131 | 132 | 133 | def test_exit_terminating(self): 134 | code = """ 135 | :foo 136 | exit 137 | """.split("\n") 138 | 139 | call_graph = CallGraph.Build(code, self.devnull) 140 | self.assertEqual(2, len(call_graph.nodes)) 141 | self.assertIn("foo", call_graph.nodes.keys()) 142 | 143 | foo_node = call_graph.nodes["foo"] 144 | begin_node = call_graph.nodes["__begin__"] 145 | 146 | self.assertTrue(foo_node.is_exit_node) 147 | self.assertFalse(begin_node.is_exit_node) 148 | 149 | self.assertTrue(foo_node.is_last_node) 150 | self.assertFalse(begin_node.is_last_node) 151 | 152 | def test_simple_terminating(self): 153 | code = """ 154 | goto :foo 155 | :foo 156 | something 157 | something 158 | """.split("\n") 159 | 160 | call_graph = CallGraph.Build(code, self.devnull) 161 | self.assertEqual(2, len(call_graph.nodes)) 162 | self.assertIn("foo", call_graph.nodes.keys()) 163 | 164 | foo_node = call_graph.nodes["foo"] 165 | begin_node = call_graph.nodes["__begin__"] 166 | 167 | self.assertTrue(foo_node.is_exit_node) 168 | self.assertFalse(begin_node.is_exit_node) 169 | 170 | def test_last_node_goto_not_terminating(self): 171 | code = """ 172 | :foo 173 | goto :eof 174 | :bar 175 | goto :foo 176 | """.split("\n") 177 | call_graph = CallGraph.Build(code, self.devnull) 178 | self.assertIn("bar", call_graph.nodes.keys()) 179 | bar_node = call_graph.nodes["bar"] 180 | self.assertFalse(bar_node.is_exit_node) 181 | 182 | def test_last_node_goto_eof_terminating(self): 183 | code = """ 184 | :foo 185 | goto :eof 186 | """.split("\n") 187 | call_graph = CallGraph.Build(code, self.devnull) 188 | self.assertIn("foo", call_graph.nodes.keys()) 189 | foo_node = call_graph.nodes["foo"] 190 | self.assertTrue(foo_node.is_exit_node) 191 | 192 | def test_simple_nested(self): 193 | code = """ 194 | something 195 | :bar 196 | something 197 | """.split("\n") 198 | call_graph = CallGraph.Build(code, self.devnull) 199 | self.assertEqual(2, len(call_graph.nodes)) 200 | self.assertIn("bar", call_graph.nodes.keys()) 201 | 202 | begin_node = call_graph.nodes["__begin__"] 203 | self.assertEqual(1, len(begin_node.connections)) 204 | connection = begin_node.connections.pop() 205 | 206 | self.assertEqual("nested", connection.kind) 207 | 208 | def test_block_end_goto_no_nested(self): 209 | code = """ 210 | goto :eof 211 | :foo 212 | """.split("\n") 213 | call_graph = CallGraph.Build(code, self.devnull) 214 | begin_node = call_graph.nodes["__begin__"] 215 | self.assertEqual(0, len(begin_node.connections)) 216 | 217 | def test_code_in_nodes(self): 218 | code = """ 219 | call :foo 220 | exit 221 | :foo 222 | goto :eof 223 | """.split("\n") 224 | call_graph = CallGraph.Build(code, self.devnull) 225 | begin = call_graph.nodes["__begin__"] 226 | foo = call_graph.nodes["foo"] 227 | self.assertEqual(begin.code, [ 228 | CodeLine(1, ""), 229 | CodeLine(2, "call :foo"), 230 | CodeLine(3, "exit", True), 231 | ]) 232 | self.assertEqual(foo.code, [ 233 | CodeLine(4, ":foo"), 234 | CodeLine(5, "goto :eof", True), 235 | CodeLine(6, ""), 236 | ]) 237 | self.assertEqual(True, foo.code[2].noop) 238 | 239 | def test_empty_lines_nested(self): 240 | code = """ 241 | :foo 242 | """.split("\n") 243 | call_graph = CallGraph.Build(code, self.devnull) 244 | begin = call_graph.nodes["__begin__"] 245 | 246 | self.assertEqual(1, len(begin.connections), begin.code) 247 | self.assertEqual("nested", begin.connections.pop().kind) 248 | 249 | def test_empty_node_nested(self): 250 | code = """:foo 251 | :bar 252 | """.split("\n") 253 | call_graph = CallGraph.Build(code, self.devnull) 254 | begin = call_graph.nodes["foo"] 255 | 256 | self.assertEqual(1, len(begin.connections)) 257 | self.assertEqual("nested", begin.connections.pop().kind) 258 | self.assertFalse(begin.is_exit_node) 259 | self.assertFalse(begin.is_last_node) 260 | 261 | bar = call_graph.nodes["bar"] 262 | self.assertTrue(bar.is_exit_node) 263 | self.assertTrue(bar.is_last_node) 264 | 265 | # There should be no __begin__ node, only foo and bar. 266 | self.assertEqual(2, len(call_graph.nodes)) 267 | 268 | def test_call_nested_eof(self): 269 | code = """ 270 | echo "yo" 271 | call :eof 272 | :eof 273 | echo "eof" 274 | """.split("\n") 275 | call_graph = CallGraph.Build(code, self.devnull) 276 | self.assertEqual(2, len(call_graph.nodes)) 277 | 278 | begin = call_graph.nodes["__begin__"] 279 | self.assertEqual(2, len(begin.connections)) 280 | self.assertFalse(begin.is_exit_node) 281 | self.assertFalse(begin.is_last_node) 282 | 283 | eof = call_graph.nodes["eof"] 284 | self.assertTrue(eof.is_exit_node) 285 | self.assertTrue(eof.is_last_node) 286 | 287 | def test_multiple_exit_nodes(self): 288 | code = """ 289 | exit 290 | :foo 291 | exit 292 | """.split("\n") 293 | call_graph = CallGraph.Build(code, self.devnull) 294 | self.assertEqual(2, len(call_graph.nodes)) 295 | 296 | begin = call_graph.nodes["__begin__"] 297 | self.assertTrue(begin.is_exit_node) 298 | self.assertFalse(begin.is_last_node) 299 | 300 | foo = call_graph.nodes["foo"] 301 | self.assertTrue(foo.is_exit_node) 302 | self.assertTrue(foo.is_last_node) 303 | 304 | def test_no_nested_with_inline_if(self): 305 | code = """ 306 | call :Filenotempty foo 307 | echo %ERRORLEVEL% 308 | exit 309 | :filenotempty 310 | If %~z1 EQU 0 (Exit /B 1) Else (Exit /B 0) 311 | :unused 312 | echo Will never run. 313 | """.split("\n") 314 | call_graph = CallGraph.Build(code, self.devnull) 315 | self.assertEqual(3, len(call_graph.nodes)) 316 | 317 | file_not_empty = call_graph.nodes["filenotempty"] 318 | self.assertEqual(0, len(file_not_empty.connections), file_not_empty.code) 319 | 320 | # Regression test for issue #44: parenthesis in goto/call labels 321 | def test_parenthesis_in_goto_label_regression_issue_44(self): 322 | """Regression test for issue #44: right parenthesis should not be included in label names""" 323 | code = """ 324 | if foo==bar (foo & goto :foo) ELSE (bar & goto :bar) 325 | :foo 326 | echo foo 327 | :bar 328 | echo bar 329 | """.split("\n") 330 | call_graph = CallGraph.Build(code, self.devnull) 331 | 332 | # Verify that the nodes are created with correct names (without trailing parenthesis) 333 | self.assertIn("foo", call_graph.nodes) 334 | self.assertIn("bar", call_graph.nodes) 335 | self.assertNotIn("foo)", call_graph.nodes) 336 | self.assertNotIn("bar)", call_graph.nodes) 337 | 338 | # Verify that the connections point to the correct labels 339 | begin_node = call_graph.nodes["__begin__"] 340 | connection_dsts = [conn.dst for conn in begin_node.connections] 341 | self.assertIn("foo", connection_dsts) 342 | self.assertIn("bar", connection_dsts) 343 | self.assertNotIn("foo)", connection_dsts) 344 | self.assertNotIn("bar)", connection_dsts) 345 | 346 | # Regression test for issue #44: call with parentheses 347 | def test_parenthesis_in_call_label_regression_issue_44(self): 348 | """Regression test for issue #44: right parenthesis should not be included in call labels""" 349 | code = """ 350 | if foo==bar (call :foo) ELSE (call :bar) 351 | exit 352 | :foo 353 | echo foo 354 | :bar 355 | echo bar 356 | """.split("\n") 357 | call_graph = CallGraph.Build(code, self.devnull) 358 | 359 | # Verify that the nodes are created with correct names (without trailing parenthesis) 360 | self.assertIn("foo", call_graph.nodes) 361 | self.assertIn("bar", call_graph.nodes) 362 | self.assertNotIn("foo)", call_graph.nodes) 363 | self.assertNotIn("bar)", call_graph.nodes) 364 | 365 | # Verify that the connections point to the correct labels 366 | begin_node = call_graph.nodes["__begin__"] 367 | connection_dsts = [conn.dst for conn in begin_node.connections] 368 | self.assertIn("foo", connection_dsts) 369 | self.assertIn("bar", connection_dsts) 370 | self.assertNotIn("foo)", connection_dsts) 371 | self.assertNotIn("bar)", connection_dsts) 372 | 373 | # Regression test for issue #44: multiple nested parentheses 374 | def test_multiple_parentheses_in_label_regression_issue_44(self): 375 | """Regression test for issue #44: multiple parentheses should be stripped correctly""" 376 | code = """ 377 | if x==y ((goto :label)) 378 | :label 379 | echo test 380 | """.split("\n") 381 | call_graph = CallGraph.Build(code, self.devnull) 382 | 383 | # Verify that the node is created with correct name 384 | self.assertIn("label", call_graph.nodes) 385 | self.assertNotIn("label))", call_graph.nodes) 386 | self.assertNotIn("label)", call_graph.nodes) 387 | 388 | # Verify that the connection points to the correct label 389 | begin_node = call_graph.nodes["__begin__"] 390 | connection_dsts = [conn.dst for conn in begin_node.connections] 391 | self.assertIn("label", connection_dsts) 392 | 393 | if __name__ == "__main__": 394 | unittest.main() --------------------------------------------------------------------------------