├── 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 | [](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 | 
36 |
37 | If the `--hide-node-stats` option is enabled, then the following graph would be generated:
38 |
39 | 
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()
--------------------------------------------------------------------------------