├── .github └── workflows │ ├── conventional_commit_checker.yml │ ├── format.yml │ ├── publish.yml │ └── python.yml ├── .gitignore ├── LICENSE ├── README.md ├── clangd_tidy ├── __init__.py ├── __main__.py ├── args.py ├── clangd_tidy_diff_cli.py ├── diagnostic_formatter.py ├── line_filter.py ├── lsp │ ├── __init__.py │ ├── clangd.py │ ├── client.py │ ├── messages.py │ ├── rpc.py │ └── server.py ├── main_cli.py └── version.py ├── pyproject.toml └── test ├── .clang-format ├── .clang-tidy ├── a.cpp ├── b.cpp ├── c.cpp └── d.cpp /.github/workflows/conventional_commit_checker.yml: -------------------------------------------------------------------------------- 1 | name: Conventional Commit Checker 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, synchronize] 6 | 7 | jobs: 8 | check-for-cc: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Conventional Commit Checker 12 | id: conventional-commit-checker 13 | uses: agenthunt/conventional-commit-checker-action@v2.0.0 14 | with: 15 | pr-title-regex: '^((build|ci|chore|docs|feat|fix|perf|refactor|revert|style|test)!?(\([a-z0-9-]+\))?: .+)$' 16 | pr-body-regex: '^((?!null)|null.*)\S' 17 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: format 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | python-black: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: psf/black@stable 16 | with: # see: https://black.readthedocs.io/en/stable/integrations/github_actions.html 17 | version: "~= 25.0" 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish-on-tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+*" 7 | 8 | jobs: 9 | pypi-publish-on-tag: 10 | name: Upload release to PyPI 11 | runs-on: ubuntu-latest 12 | environment: 13 | name: pypi 14 | url: https://pypi.org/p/clangd-tidy 15 | permissions: 16 | id-token: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.8" 22 | - run: | 23 | python3 -m pip install --upgrade build 24 | python3 -m build 25 | - name: Publish package distributions to PyPI 26 | uses: pypa/gh-action-pypi-publish@release/v1 27 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: python 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | basedpyright: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v5 18 | 19 | - name: Install the project 20 | run: uv sync --all-extras --dev 21 | 22 | - name: Run basedpyright 23 | run: uv run basedpyright **/*.py 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Packages 2 | /clangd_tidy/_dist_ver.py 3 | 4 | # uv 5 | uv.lock 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | cover/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | .pybuilder/ 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | # For a library or package, you might want to ignore these files since the code is 93 | # intended to run in multiple environments; otherwise, check them in: 94 | # .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/#use-with-ide 116 | .pdm.toml 117 | 118 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 119 | __pypackages__/ 120 | 121 | # Celery stuff 122 | celerybeat-schedule 123 | celerybeat.pid 124 | 125 | # SageMath parsed files 126 | *.sage.py 127 | 128 | # Environments 129 | .env 130 | .venv 131 | env/ 132 | venv/ 133 | ENV/ 134 | env.bak/ 135 | venv.bak/ 136 | 137 | # Spyder project settings 138 | .spyderproject 139 | .spyproject 140 | 141 | # Rope project settings 142 | .ropeproject 143 | 144 | # mkdocs documentation 145 | /site 146 | 147 | # mypy 148 | .mypy_cache/ 149 | .dmypy.json 150 | dmypy.json 151 | 152 | # Pyre type checker 153 | .pyre/ 154 | 155 | # pytype static type analyzer 156 | .pytype/ 157 | 158 | # Cython debug symbols 159 | cython_debug/ 160 | 161 | # PyCharm 162 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 163 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 164 | # and can be added to the global gitignore or merged into this file. For a more nuclear 165 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 166 | #.idea/ 167 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 lljbash 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clangd-tidy: A Faster Alternative to clang-tidy 2 | 3 | ## Motivation 4 | 5 | [clang-tidy](https://clang.llvm.org/extra/clang-tidy/) is a powerful tool for static analysis of C++ code. However, it's [widely acknowledged](https://www.google.com/search?q=clang-tidy+slow) that clang-tidy takes a significant amount of time to run on large codebases, particularly when enabling numerous checks. This often leads to the dilemma of disabling valuable checks to expedite clang-tidy execution. 6 | 7 | In contrast, [clangd](https://clangd.llvm.org/), the language server with built-in support for clang-tidy, has been [observed](https://stackoverflow.com/questions/76531831/why-is-clang-tidy-in-clangd-so-much-faster-than-run-clang-tidy-itself) to be significantly faster than clang-tidy when running the same checks. It provides diagnostics almost instantly upon opening a file in your editor. The key distinction lies in the fact that clang-tidy checks the codes from all headers (although it suppresses the warnings from them by default), whereas clangd only builds AST from these headers. 8 | 9 | Unfortunately, there seems to be no plan within LLVM to accelerate the standalone version of clang-tidy. This project addresses this by offering a faster alternative to clang-tidy, leveraging the speed of clangd. It acts as a wrapper for clangd, running it in the background and collecting diagnostics. Designed as a drop-in replacement for clang-tidy, it seamlessly integrates with existing build systems and CI scripts. 10 | 11 | ## Comparison with clang-tidy 12 | 13 | **Pros:** 14 | 15 | - clangd-tidy is significantly faster than clang-tidy (over 10x in my experience). 16 | - clangd-tidy can check header files individually, even if they are not included in the compilation database. 17 | - clangd-tidy groups diagnostics by files -- no more duplicated diagnostics from the same header! 18 | - clangd-tidy provides an optional code format checking feature, eliminating the need to run clang-format separately. 19 | - clangd-tidy supports [`.clangd` configuration files](https://clangd.llvm.org/config), offering features not supported by clang-tidy. 20 | - Example: Removing unknown compiler flags from the compilation database. 21 | ```yaml 22 | CompileFlags: 23 | Remove: -fabi* 24 | ``` 25 | - Example: Adding IWYU include checks. 26 | ```yaml 27 | Diagnostics: 28 | # Available in clangd-14 29 | UnusedIncludes: Strict 30 | # Require clangd-17 31 | MissingIncludes: Strict 32 | ``` 33 | - Hyperlinks on diagnostic check names in supported terminals. 34 | - Refer to [Usage](#usage) for more features. 35 | 36 | **Cons:** 37 | 38 | - clangd-tidy lacks support for the `--fix` option. (Consider using code actions provided by your editor if you have clangd properly configured, as clangd-tidy is primarily designed for speeding up CI checks.) 39 | - clangd-tidy silently disables [several](https://github.com/llvm/llvm-project/blob/main/clang-tools-extra/clangd/TidyProvider.cpp#L197) checks not supported by clangd. 40 | - Diagnostics generated by clangd-tidy might be marginally less aesthetically pleasing compared to clang-tidy. 41 | - Other known discrepancies between clangd and clang-tidy behavior: #7, #15, #16. 42 | 43 | ## Prerequisites 44 | 45 | - [clangd](https://clangd.llvm.org/) 46 | - Python 3.8+ (may work on older versions, but not tested) 47 | - [attrs](https://www.attrs.org/) and [cattrs](https://catt.rs/) (automatically installed if clangd-tidy is installed via pip) 48 | - [tqdm](https://github.com/tqdm/tqdm) (optional, required for progress bar support) 49 | 50 | ## Installation 51 | 52 | ```bash 53 | pip install clangd-tidy 54 | ``` 55 | 56 | ## Usage 57 | 58 | ### clang-tidy 59 | 60 | ``` 61 | usage: clangd-tidy [--allow-extensions ALLOW_EXTENSIONS] 62 | [--fail-on-severity SEVERITY] [-f] [-o OUTPUT] 63 | [--line-filter LINE_FILTER] [--tqdm] [--github] 64 | [--git-root GIT_ROOT] [-c] [--context CONTEXT] 65 | [--color {auto,always,never}] [-v] 66 | [-p COMPILE_COMMANDS_DIR] [-j JOBS] 67 | [--clangd-executable CLANGD_EXECUTABLE] 68 | [--query-driver QUERY_DRIVER] [-V] [-h] 69 | filename [filename ...] 70 | 71 | Run clangd with clang-tidy and output diagnostics. This aims to serve as a 72 | faster alternative to clang-tidy. 73 | 74 | input options: 75 | filename Files to analyze. Ignores files with extensions not 76 | listed in ALLOW_EXTENSIONS. 77 | --allow-extensions ALLOW_EXTENSIONS 78 | A comma-separated list of file extensions to allow. 79 | [default: c,h,cpp,cc,cxx,hpp,hh,hxx,cu,cuh] 80 | 81 | check options: 82 | --fail-on-severity SEVERITY 83 | Specifies the diagnostic severity level at which the 84 | program exits with a non-zero status. Possible values: 85 | error, warn, info, hint. [default: hint] 86 | -f, --format Also check code formatting with clang-format. Exits 87 | with a non-zero status if any file violates formatting 88 | rules. 89 | 90 | output options: 91 | -o OUTPUT, --output OUTPUT 92 | Output file for diagnostics. [default: stdout] 93 | --line-filter LINE_FILTER 94 | A JSON with a list of files and line ranges that will 95 | act as a filter for diagnostics. Compatible with 96 | clang-tidy --line-filter parameter format. 97 | --tqdm Show a progress bar (tqdm required). 98 | --github Append workflow commands for GitHub Actions to output. 99 | --git-root GIT_ROOT Specifies the root directory of the Git repository. 100 | Only works with --github. [default: current directory] 101 | -c, --compact Print compact diagnostics (legacy). 102 | --context CONTEXT Number of additional lines to display on both sides of 103 | each diagnostic. This option is ineffective with 104 | --compact. [default: 2] 105 | --color {auto,always,never} 106 | Colorize the output. This option is ineffective with 107 | --compact. [default: auto] 108 | -v, --verbose Stream verbose output from clangd to stderr. 109 | 110 | clangd options: 111 | -p COMPILE_COMMANDS_DIR, --compile-commands-dir COMPILE_COMMANDS_DIR 112 | Specify a path to look for compile_commands.json. If 113 | the path is invalid, clangd will look in the current 114 | directory and parent paths of each source file. 115 | [default: build] 116 | -j JOBS, --jobs JOBS Number of async workers used by clangd. Background 117 | index also uses this many workers. [default: 1] 118 | --clangd-executable CLANGD_EXECUTABLE 119 | Clangd executable. [default: clangd] 120 | --query-driver QUERY_DRIVER 121 | Comma separated list of globs for white-listing gcc- 122 | compatible drivers that are safe to execute. Drivers 123 | matching any of these globs will be used to extract 124 | system includes. e.g. 125 | `/usr/bin/**/clang-*,/path/to/repo/**/g++-*`. 126 | 127 | generic options: 128 | -V, --version Show program's version number and exit. 129 | -h, --help Show this help message and exit. 130 | 131 | Find more information on https://github.com/lljbash/clangd-tidy. 132 | ``` 133 | 134 | ### clangd-tidy-diff 135 | 136 | ``` 137 | usage: clangd-tidy-diff [-h] [-V] [-p COMPILE_COMMANDS_DIR] 138 | [--pass-arg PASS_ARG] 139 | 140 | Run clangd-tidy on modified files, reporting diagnostics only for changed lines. 141 | 142 | optional arguments: 143 | -h, --help show this help message and exit 144 | -V, --version show program's version number and exit 145 | -p COMPILE_COMMANDS_DIR, --compile-commands-dir COMPILE_COMMANDS_DIR 146 | Specify a path to look for compile_commands.json. If 147 | the path is invalid, clangd-tidy will look in the 148 | current directory and parent paths of each source 149 | file. 150 | --pass-arg PASS_ARG Pass this argument to clangd-tidy (can be used 151 | multiple times) 152 | 153 | Receives a diff on stdin and runs clangd-tidy only on the changed lines. 154 | This is useful to slowly onboard a codebase to linting or to find regressions. 155 | Inspired by clang-tidy-diff.py from the LLVM project. 156 | 157 | Example usage with git: 158 | git diff -U0 HEAD^^..HEAD | clangd-tidy-diff -p my/build 159 | 160 | ``` 161 | 162 | ## Acknowledgement 163 | 164 | Special thanks to [@yeger00](https://github.com/yeger00) for his [pylspclient](https://github.com/yeger00/pylspclient), which inspired earlier versions of this project. 165 | 166 | A big shoutout to [clangd](https://clangd.llvm.org/) and [clang-tidy](https://clang.llvm.org/extra/clang-tidy/) for their great work! 167 | 168 | Claps to 169 | - [@ArchieAtkinson](https://github.com/ArchieAtkinson) for his artistic flair in the fancy diagnostic formatter. 170 | - [@jmpfar](https://github.com/jmpfar) for his contribution to hyperlink support and `clangd-tidy-diff`. 171 | - And all other contributors who have helped improve this project: 172 | [@mateosss](https://github.com/mateosss) 173 | [@kammce](https://github.com/kammce) 174 | 175 | Contributions are welcome! Feel free to open an issue or a pull request. 176 | -------------------------------------------------------------------------------- /clangd_tidy/__init__.py: -------------------------------------------------------------------------------- 1 | from .clangd_tidy_diff_cli import clang_tidy_diff 2 | from .main_cli import main_cli 3 | from .version import __version__ 4 | 5 | __all__ = ["main_cli", "clang_tidy_diff", "__version__"] 6 | -------------------------------------------------------------------------------- /clangd_tidy/__main__.py: -------------------------------------------------------------------------------- 1 | from .main_cli import main_cli 2 | 3 | main_cli() 4 | -------------------------------------------------------------------------------- /clangd_tidy/args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import os 4 | import pathlib 5 | import sys 6 | 7 | import cattrs 8 | 9 | from .line_filter import LineFilter 10 | from .lsp.messages import DiagnosticSeverity 11 | from .version import __version__ 12 | 13 | __all__ = ["SEVERITY_INT", "parse_args"] 14 | 15 | 16 | SEVERITY_INT = dict( 17 | error=DiagnosticSeverity.ERROR, 18 | warn=DiagnosticSeverity.WARNING, 19 | info=DiagnosticSeverity.INFORMATION, 20 | hint=DiagnosticSeverity.HINT, 21 | ) 22 | 23 | 24 | def parse_args() -> argparse.Namespace: 25 | DEFAULT_ALLOWED_EXTENSIONS = [ 26 | "c", 27 | "h", 28 | "cpp", 29 | "cc", 30 | "cxx", 31 | "hpp", 32 | "hh", 33 | "hxx", 34 | "cu", 35 | "cuh", 36 | ] 37 | 38 | parser = argparse.ArgumentParser( 39 | prog="clangd-tidy", 40 | description="Run clangd with clang-tidy and output diagnostics. This aims to serve as a faster alternative to clang-tidy.", 41 | epilog="Find more information on https://github.com/lljbash/clangd-tidy.", 42 | add_help=False, 43 | ) 44 | 45 | input_group = parser.add_argument_group("input options") 46 | input_group.add_argument( 47 | "filename", 48 | nargs="+", 49 | type=pathlib.Path, 50 | help="Files to analyze. Ignores files with extensions not listed in ALLOW_EXTENSIONS.", 51 | ) 52 | input_group.add_argument( 53 | "--allow-extensions", 54 | type=lambda x: x.strip().split(","), 55 | default=DEFAULT_ALLOWED_EXTENSIONS, 56 | help=f"A comma-separated list of file extensions to allow. [default: {','.join(DEFAULT_ALLOWED_EXTENSIONS)}]", 57 | ) 58 | 59 | check_group = parser.add_argument_group("check options") 60 | check_group.add_argument( 61 | "--fail-on-severity", 62 | metavar="SEVERITY", 63 | choices=SEVERITY_INT.keys(), 64 | default="hint", 65 | help=f"Specifies the diagnostic severity level at which the program exits with a non-zero status. Possible values: {', '.join(SEVERITY_INT.keys())}. [default: hint]", 66 | ) 67 | check_group.add_argument( 68 | "-f", 69 | "--format", 70 | action="store_true", 71 | help="Also check code formatting with clang-format. Exits with a non-zero status if any file violates formatting rules.", 72 | ) 73 | 74 | output_group = parser.add_argument_group("output options") 75 | output_group.add_argument( 76 | "-o", 77 | "--output", 78 | type=argparse.FileType("w"), 79 | default=sys.stdout, 80 | help="Output file for diagnostics. [default: stdout]", 81 | ) 82 | output_group.add_argument( 83 | "--line-filter", 84 | type=lambda x: cattrs.structure(json.loads(x), LineFilter), 85 | help=( 86 | "A JSON with a list of files and line ranges that will act as a filter for diagnostics." 87 | " Compatible with clang-tidy --line-filter parameter format." 88 | ), 89 | ) 90 | output_group.add_argument( 91 | "--tqdm", action="store_true", help="Show a progress bar (tqdm required)." 92 | ) 93 | output_group.add_argument( 94 | "--github", 95 | action="store_true", 96 | help="Append workflow commands for GitHub Actions to output.", 97 | ) 98 | output_group.add_argument( 99 | "--git-root", 100 | default=os.getcwd(), 101 | help="Specifies the root directory of the Git repository. Only works with --github. [default: current directory]", 102 | ) 103 | output_group.add_argument( 104 | "-c", 105 | "--compact", 106 | action="store_true", 107 | help="Print compact diagnostics (legacy).", 108 | ) 109 | output_group.add_argument( 110 | "--context", 111 | type=int, 112 | default=2, 113 | help="Number of additional lines to display on both sides of each diagnostic. This option is ineffective with --compact. [default: 2]", 114 | ) 115 | output_group.add_argument( 116 | "--color", 117 | choices=["auto", "always", "never"], 118 | default="auto", 119 | help="Colorize the output. This option is ineffective with --compact. [default: auto]", 120 | ) 121 | output_group.add_argument( 122 | "-v", 123 | "--verbose", 124 | action="store_true", 125 | help="Stream verbose output from clangd to stderr.", 126 | ) 127 | 128 | clangd_group = parser.add_argument_group("clangd options") 129 | clangd_group.add_argument( 130 | "-p", 131 | "--compile-commands-dir", 132 | default="build", 133 | help="Specify a path to look for compile_commands.json. If the path is invalid, clangd will look in the current directory and parent paths of each source file. [default: build]", 134 | ) 135 | clangd_group.add_argument( 136 | "-j", 137 | "--jobs", 138 | type=int, 139 | default=1, 140 | help="Number of async workers used by clangd. Background index also uses this many workers. [default: 1]", 141 | ) 142 | clangd_group.add_argument( 143 | "--clangd-executable", 144 | default="clangd", 145 | help="Clangd executable. [default: clangd]", 146 | ) 147 | clangd_group.add_argument( 148 | "--query-driver", 149 | default="", 150 | help="Comma separated list of globs for white-listing gcc-compatible drivers that are safe to execute. Drivers matching any of these globs will be used to extract system includes. e.g. `/usr/bin/**/clang-*,/path/to/repo/**/g++-*`.", 151 | ) 152 | 153 | misc_group = parser.add_argument_group("generic options") 154 | misc_group.add_argument( 155 | "-V", 156 | "--version", 157 | action="version", 158 | version=f"%(prog)s {__version__}", 159 | help="Show program's version number and exit.", 160 | ) 161 | misc_group.add_argument( 162 | "-h", "--help", action="help", help="Show this help message and exit." 163 | ) 164 | 165 | return parser.parse_args() 166 | -------------------------------------------------------------------------------- /clangd_tidy/clangd_tidy_diff_cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Receives a diff on stdin and runs clangd-tidy only on the changed lines. 3 | This is useful to slowly onboard a codebase to linting or to find regressions. 4 | Inspired by clang-tidy-diff.py from the LLVM project. 5 | 6 | Example usage with git: 7 | git diff -U0 HEAD^^..HEAD | clangd-tidy-diff -p my/build 8 | """ 9 | 10 | import argparse 11 | import json 12 | import re 13 | import subprocess 14 | import sys 15 | from pathlib import Path 16 | from typing import Callable, Dict, List, NoReturn, Optional, TextIO, Union 17 | 18 | import cattrs 19 | 20 | from .line_filter import FileLineFilter, LineFilter, LineRange 21 | from .version import __version__ 22 | 23 | 24 | def _parse_gitdiff( 25 | text: TextIO, add_file_range_callback: Callable[[Path, int, int], None] 26 | ) -> None: 27 | """ 28 | Parses a git diff and calls add_file_range_callback for each added line range. 29 | """ 30 | ADDED_FILE_NAME_REGEX = re.compile(r'^\+\+\+ "?(?P.*?/)(?P[^\s"]*)') 31 | ADDED_LINES_REGEX = re.compile(r"^@@.*\+(?P\d+)(,(?P\d+))?") 32 | 33 | last_file: Optional[str] = None 34 | for line in text: 35 | m = re.search(ADDED_FILE_NAME_REGEX, line) 36 | if m is not None: 37 | last_file = m.group("file") 38 | if last_file is None: 39 | continue 40 | 41 | m = re.search(ADDED_LINES_REGEX, line) 42 | if m is None: 43 | continue 44 | start_line = int(m.group("line")) 45 | line_count = int(m.group("count")) if m.group("count") else 1 46 | if line_count == 0: 47 | continue 48 | end_line = start_line + line_count - 1 49 | 50 | add_file_range_callback(Path(last_file), start_line, end_line) 51 | 52 | 53 | def clang_tidy_diff() -> NoReturn: 54 | parser = argparse.ArgumentParser( 55 | description="Run clangd-tidy on modified files, reporting diagnostics only for changed lines.", 56 | epilog=__doc__, 57 | formatter_class=argparse.RawDescriptionHelpFormatter, 58 | ) 59 | parser.add_argument( 60 | "-V", "--version", action="version", version=f"%(prog)s {__version__}" 61 | ) 62 | parser.add_argument( 63 | "-p", 64 | "--compile-commands-dir", 65 | help="Specify a path to look for compile_commands.json. If the path is invalid, clangd-tidy will look in the current directory and parent paths of each source file.", 66 | ) 67 | parser.add_argument( 68 | "--pass-arg", 69 | action="append", 70 | help="Pass this argument to clangd-tidy (can be used multiple times)", 71 | ) 72 | args = parser.parse_args() 73 | 74 | line_filter_map: Dict[Path, FileLineFilter] = {} 75 | _parse_gitdiff( 76 | sys.stdin, 77 | lambda file, start, end: line_filter_map.setdefault( 78 | file.resolve(), FileLineFilter(file.resolve(), []) 79 | ).lines.append(LineRange(start, end)), 80 | ) 81 | if not line_filter_map: 82 | print("No relevant changes found.", file=sys.stderr) 83 | sys.exit(0) 84 | 85 | line_filter = LineFilter(list(line_filter_map.values())) 86 | filters_json = json.dumps(cattrs.unstructure(line_filter)) 87 | command: List[Union[str, bytes, Path]] = [ 88 | "clangd-tidy", 89 | "--line-filter", 90 | filters_json, 91 | ] 92 | 93 | if args.compile_commands_dir: 94 | command.extend(["--compile-commands-dir", args.compile_commands_dir]) 95 | 96 | if args.pass_arg: 97 | command.extend(args.pass_arg) 98 | 99 | files = line_filter_map.keys() 100 | command.append("--") 101 | command.extend(files) 102 | 103 | sys.exit(subprocess.run(command).returncode) 104 | 105 | 106 | if __name__ == "__main__": 107 | clang_tidy_diff() 108 | -------------------------------------------------------------------------------- /clangd_tidy/diagnostic_formatter.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import re 4 | from abc import ABC, abstractmethod 5 | from typing import Dict, Iterable, List, Optional 6 | 7 | from .lsp.messages import Diagnostic, DiagnosticSeverity 8 | 9 | __all__ = [ 10 | "DiagnosticCollection", 11 | "DiagnosticFormatter", 12 | "CompactDiagnosticFormatter", 13 | "FancyDiagnosticFormatter", 14 | "GithubActionWorkflowCommandDiagnosticFormatter", 15 | ] 16 | 17 | 18 | DiagnosticCollection = Dict[pathlib.Path, List[Diagnostic]] 19 | 20 | 21 | class DiagnosticFormatter(ABC): 22 | SEVERITY = { 23 | 1: "Error", 24 | 2: "Warning", 25 | 3: "Information", 26 | 4: "Hint", 27 | } 28 | 29 | def format(self, diagnostic_collection: DiagnosticCollection) -> str: 30 | file_outputs: List[str] = [] 31 | for file, diagnostics in sorted( 32 | diagnostic_collection.items(), key=lambda fd: fd[0].as_posix() 33 | ): 34 | diagnostic_outputs = [ 35 | o 36 | for o in [ 37 | self._format_one_diagnostic(file, diagnostic) 38 | for diagnostic in diagnostics 39 | ] 40 | if o is not None 41 | ] 42 | if len(diagnostic_outputs) == 0: 43 | continue 44 | file_outputs.append(self._make_file_output(file, diagnostic_outputs)) 45 | return self._make_whole_output(file_outputs) 46 | 47 | @abstractmethod 48 | def _format_one_diagnostic( 49 | self, file: pathlib.Path, diagnostic: Diagnostic 50 | ) -> Optional[str]: 51 | pass 52 | 53 | @abstractmethod 54 | def _make_file_output( 55 | self, file: pathlib.Path, diagnostic_outputs: Iterable[str] 56 | ) -> str: 57 | pass 58 | 59 | @abstractmethod 60 | def _make_whole_output(self, file_outputs: Iterable[str]) -> str: 61 | pass 62 | 63 | 64 | class CompactDiagnosticFormatter(DiagnosticFormatter): 65 | def _format_one_diagnostic( 66 | self, file: pathlib.Path, diagnostic: Diagnostic 67 | ) -> Optional[str]: 68 | del file 69 | source = diagnostic.source 70 | severity = diagnostic.severity 71 | code = diagnostic.code 72 | extra_info = "{}{}{}".format( 73 | f" {source}" if source is not None else "", 74 | f" {self.SEVERITY[severity.value]}" if severity is not None else "", 75 | f" [{code}]" if code is not None else "", 76 | ) 77 | line = diagnostic.range.start.line + 1 78 | col = diagnostic.range.start.character + 1 79 | message = diagnostic.message 80 | if source is None and code is None: 81 | return None 82 | return f"- line {line}, col {col}:{extra_info}\n{message}" 83 | 84 | def _make_file_output( 85 | self, file: pathlib.Path, diagnostic_outputs: Iterable[str] 86 | ) -> str: 87 | head = f"----- {os.path.relpath(file)} -----" 88 | return "\n\n".join([head, *diagnostic_outputs]) 89 | 90 | def _make_whole_output(self, file_outputs: Iterable[str]) -> str: 91 | return "\n\n\n".join(file_outputs) 92 | 93 | 94 | class GithubActionWorkflowCommandDiagnosticFormatter(DiagnosticFormatter): 95 | SEVERITY_GITHUB = { 96 | 1: "error", 97 | 2: "warning", 98 | 3: "notice", 99 | 4: "notice", 100 | } 101 | 102 | def __init__(self, git_root: str): 103 | self._git_root = git_root 104 | 105 | def _format_one_diagnostic( 106 | self, file: pathlib.Path, diagnostic: Diagnostic 107 | ) -> Optional[str]: 108 | source = diagnostic.source 109 | severity = diagnostic.severity 110 | code = diagnostic.code 111 | extra_info = "{}{}{}".format( 112 | f"{source}" if source else "", 113 | f" {self.SEVERITY[severity.value]}" if severity is not None else "", 114 | f" [{code}]" if code is not None else "", 115 | ) 116 | line = diagnostic.range.start.line + 1 117 | end_line = diagnostic.range.end.line + 1 118 | col = diagnostic.range.start.character + 1 119 | end_col = diagnostic.range.end.character + 1 120 | message = diagnostic.message 121 | if source is None and code is None: 122 | return None 123 | if severity is None: 124 | severity = DiagnosticSeverity.INFORMATION 125 | command = self.SEVERITY_GITHUB[severity.value] 126 | rel_file = os.path.relpath(file, self._git_root) 127 | return f"::{command} file={rel_file},line={line},endLine={end_line},col={col},endCol={end_col},title={extra_info}::{message}" 128 | 129 | def _make_file_output( 130 | self, file: pathlib.Path, diagnostic_outputs: Iterable[str] 131 | ) -> str: 132 | del file 133 | return "\n".join(diagnostic_outputs) 134 | 135 | def _make_whole_output(self, file_outputs: Iterable[str]) -> str: 136 | head = "::group::{workflow commands}" 137 | tail = "::endgroup::" 138 | return "\n".join(["", head, *file_outputs, tail]) 139 | 140 | 141 | class FancyDiagnosticFormatter(DiagnosticFormatter): 142 | class Colorizer: 143 | class ColorSeqTty: 144 | ERROR = "\033[91m" 145 | WARNING = "\033[93m" 146 | INFO = "\033[96m" 147 | HINT = "\033[94m" 148 | NOTE = "\033[90m" 149 | GREEN = "\033[92m" 150 | MAGENTA = "\033[95m" 151 | BOLD = "\033[1m" 152 | ENDC = "\033[0m" 153 | START_LINK = "\033]8;;" 154 | END_LINK = "\033\\" 155 | 156 | class ColorSeqNoTty: 157 | ERROR = "" 158 | WARNING = "" 159 | INFO = "" 160 | HINT = "" 161 | NOTE = "" 162 | GREEN = "" 163 | MAGENTA = "" 164 | BOLD = "" 165 | ENDC = "" 166 | 167 | def __init__(self, enable_color: bool): 168 | self._color_seq = self.ColorSeqTty if enable_color else self.ColorSeqNoTty 169 | 170 | def per_severity(self, severity: int, message: str): 171 | if severity == 1: 172 | return f"{self._color_seq.ERROR}{message}{self._color_seq.ENDC}" 173 | if severity == 2: 174 | return f"{self._color_seq.WARNING}{message}{self._color_seq.ENDC}" 175 | if severity == 3: 176 | return f"{self._color_seq.INFO}{message}{self._color_seq.ENDC}" 177 | if severity == 4: 178 | return f"{self._color_seq.HINT}{message}{self._color_seq.ENDC}" 179 | return message 180 | 181 | def highlight(self, message: str): 182 | return f"{self._color_seq.GREEN}{message}{self._color_seq.ENDC}" 183 | 184 | def note(self, message: str): 185 | return f"{self._color_seq.NOTE}{message}{self._color_seq.ENDC}" 186 | 187 | def format(self, message: str): 188 | return f"{self._color_seq.MAGENTA}{message}{self._color_seq.ENDC}" 189 | 190 | def link(self, message: str, url: str): 191 | if not url or self._color_seq is not self.ColorSeqTty: 192 | return message 193 | return ( 194 | f"{self._color_seq.START_LINK}{url}{self._color_seq.END_LINK}" 195 | f"{message}{self._color_seq.START_LINK}{self._color_seq.END_LINK}" 196 | ) 197 | 198 | def __init__(self, extra_context: int, enable_color: bool): 199 | self._extra_context = extra_context 200 | self._colorizer = self.Colorizer(enable_color) 201 | 202 | def _colorized_severity(self, severity: int): 203 | return self._colorizer.per_severity(severity, self.SEVERITY[severity]) 204 | 205 | @staticmethod 206 | def _prepend_line_number(line: str, lino: Optional[int]) -> str: 207 | LINO_WIDTH = 5 208 | LINO_SEP = " | " 209 | lino_str = str(lino + 1) if lino is not None else "" 210 | return f"{lino_str:>{LINO_WIDTH}}{LINO_SEP}{line.rstrip()}\n" 211 | 212 | def _code_context( 213 | self, 214 | file: str, 215 | line_start: int, 216 | line_end: int, 217 | col_start: int, 218 | col_end: int, 219 | extra_context: Optional[int] = None, 220 | ) -> str: 221 | UNDERLINE = "~" 222 | UNDERLINE_START = "^" 223 | if extra_context is None: 224 | extra_context = self._extra_context 225 | 226 | # get context code 227 | with open(file, "r") as f: 228 | content = f.readlines() 229 | context_start_line = max(0, line_start - extra_context) 230 | context_end_line = min(len(content), line_end + extra_context + 1) 231 | code = content[context_start_line:context_end_line] 232 | 233 | context = "" 234 | for lino, line in enumerate(code, start=context_start_line): 235 | # prepend line numbers 236 | context += self._prepend_line_number(line, lino) 237 | 238 | # add diagnostic indicator line 239 | if lino < line_start or lino > line_end: 240 | continue 241 | line_col_start = ( 242 | col_start if lino == line_start else len(line) - len(line.lstrip()) 243 | ) 244 | line_col_end = col_end if lino == line_end else len(line.rstrip()) 245 | indicator = UNDERLINE_START if lino == line_start else UNDERLINE 246 | indicator = indicator.rjust(line_col_start + 1) 247 | indicator = indicator.ljust(line_col_end, UNDERLINE) 248 | indicator = self._colorizer.highlight(indicator) 249 | context += self._prepend_line_number(indicator, lino=None) 250 | 251 | return context.rstrip() 252 | 253 | @staticmethod 254 | def _diagnostic_message( 255 | file: str, 256 | line_start: int, 257 | col_start: int, 258 | severity: str, 259 | message: str, 260 | code: str, 261 | context: str, 262 | ) -> str: 263 | return f"{file}:{line_start + 1}:{col_start + 1}: {severity}: {message} {code}\n{context}" 264 | 265 | def _formatting_message(self, file: str, message: str) -> str: 266 | return self._colorizer.format(f"{file}: {message}") 267 | 268 | def _format_one_diagnostic( 269 | self, file: pathlib.Path, diagnostic: Diagnostic 270 | ) -> Optional[str]: 271 | rel_file = os.path.relpath(file) 272 | 273 | if diagnostic.source == "clang-format": 274 | return self._formatting_message(rel_file, diagnostic.message) 275 | 276 | message: str = diagnostic.message.replace(" (fix available)", "") 277 | message_list = [line for line in message.splitlines() if line.strip()] 278 | message, extra_messages = message_list[0], message_list[1:] 279 | 280 | if diagnostic.code is None: 281 | return None 282 | 283 | code_url = diagnostic.codeDescription.href if diagnostic.codeDescription else "" 284 | code = f"[{self._colorizer.link(diagnostic.code, code_url)}]" 285 | 286 | severity = ( 287 | self._colorized_severity(diagnostic.severity.value) 288 | if diagnostic.severity is not None 289 | else "" 290 | ) 291 | 292 | line_start = diagnostic.range.start.line 293 | line_end = diagnostic.range.end.line 294 | 295 | col_start = diagnostic.range.start.character 296 | col_end = diagnostic.range.end.character 297 | 298 | context = self._code_context(rel_file, line_start, line_end, col_start, col_end) 299 | 300 | fancy_output = self._diagnostic_message( 301 | rel_file, line_start, col_start, severity, message, code, context 302 | ) 303 | 304 | for extra_message in extra_messages: 305 | match_code_loc = re.match(r".*:(\d+):(\d+):.*", extra_message) 306 | if not match_code_loc: 307 | continue 308 | line = int(match_code_loc.group(1)) - 1 309 | col = int(match_code_loc.group(2)) - 1 310 | extra_message = " ".join(extra_message.split(" ")[2:]) 311 | context = self._code_context( 312 | rel_file, line, line, col, col + 1, extra_context=0 313 | ) 314 | note = self._colorizer.note("Note") 315 | fancy_output += "\n" + self._diagnostic_message( 316 | rel_file, line, col, note, extra_message, "", context 317 | ) 318 | 319 | return fancy_output 320 | 321 | def _make_file_output( 322 | self, file: pathlib.Path, diagnostic_outputs: Iterable[str] 323 | ) -> str: 324 | del file 325 | return "\n\n".join(diagnostic_outputs) 326 | 327 | def _make_whole_output(self, file_outputs: Iterable[str]) -> str: 328 | return "\n\n".join(file_outputs) 329 | -------------------------------------------------------------------------------- /clangd_tidy/line_filter.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, List 3 | 4 | import cattrs 5 | from attr import Factory 6 | from attrs import define 7 | 8 | from .lsp.messages import Diagnostic 9 | 10 | __all__ = ["LineFilter"] 11 | 12 | 13 | @define 14 | class LineRange: 15 | start: int 16 | end: int 17 | 18 | def intersect_with(self, other: "LineRange") -> bool: 19 | return max(self.start, other.start) <= min(self.end, other.end) 20 | 21 | 22 | @cattrs.register_structure_hook 23 | def range_structure_hook(val: List[int], _: type) -> LineRange: 24 | if len(val) != 2: 25 | raise ValueError("Range must be a list of two integers.") 26 | return LineRange(val[0], val[1]) 27 | 28 | 29 | @cattrs.register_unstructure_hook 30 | def range_unstructure_hook(obj: LineRange) -> List[int]: 31 | return [obj.start, obj.end] 32 | 33 | 34 | @define 35 | class FileLineFilter: 36 | """ 37 | Filters diagnostics in line ranges of a specific file 38 | """ 39 | 40 | name: Path 41 | """ 42 | File path 43 | """ 44 | 45 | lines: List[LineRange] = Factory(list) 46 | """ 47 | List of inclusive line ranges where diagnostics will be emitted 48 | 49 | If empty, all diagnostics will be emitted 50 | """ 51 | 52 | def matches_file(self, file: Path) -> bool: 53 | return str(file.resolve()).endswith(str(self.name)) 54 | 55 | def matches_range(self, start: int, end: int) -> bool: 56 | return not self.lines or any( 57 | LineRange(start, end).intersect_with(line_range) 58 | for line_range in self.lines 59 | ) 60 | 61 | 62 | @define 63 | class LineFilter: 64 | """ 65 | Filters diagnostics by line ranges. 66 | This is meant to be compatible with clang-tidy --line-filter syntax. 67 | """ 68 | 69 | file_line_filters: List[FileLineFilter] 70 | """ 71 | The format of the list is a JSON array of objects: 72 | [ 73 | {"name":"file1.cpp","lines":[[1,3],[5,7]]}, 74 | {"name":"file2.h"} 75 | ] 76 | """ 77 | 78 | def passes_line_filter(self, file: Path, diagnostic: Diagnostic) -> bool: 79 | """ 80 | Check if a diagnostic passes the line filter. 81 | 82 | @see https://github.com/llvm/llvm-project/blob/980d66caae62de9b56422a2fdce3f535c2ab325f/clang-tools-extra/clang-tidy/ClangTidyDiagnosticConsumer.cpp#L463-L479 83 | """ 84 | if not self.file_line_filters: 85 | return True 86 | first_match_filter = next( 87 | (f for f in self.file_line_filters if f.matches_file(file)), 88 | None, 89 | ) 90 | if first_match_filter is None: 91 | return False 92 | 93 | # EXTRA: keep clang-format diagnostics unfiltered 94 | if diagnostic.source is not None and diagnostic.source == "clang-format": 95 | return True 96 | # EXTRA: filter out clang-tidy diagnostics without source and code 97 | if diagnostic.source is None and diagnostic.code is None: 98 | return False 99 | 100 | return first_match_filter.matches_range( 101 | diagnostic.range.start.line + 1, diagnostic.range.end.line + 1 102 | ) 103 | 104 | 105 | @cattrs.register_structure_hook 106 | def line_filter_structure_hook(val: List[Any], _: type) -> LineFilter: 107 | return LineFilter([cattrs.structure(f, FileLineFilter) for f in val]) 108 | 109 | 110 | @cattrs.register_unstructure_hook 111 | def line_filter_unstructure_hook(obj: LineFilter) -> List[FileLineFilter]: 112 | return [cattrs.unstructure(f) for f in obj.file_line_filters] 113 | -------------------------------------------------------------------------------- /clangd_tidy/lsp/__init__.py: -------------------------------------------------------------------------------- 1 | from .clangd import ClangdAsync 2 | from .client import RequestResponsePair 3 | from . import messages 4 | 5 | __all__ = ["ClangdAsync", "RequestResponsePair", "messages"] 6 | -------------------------------------------------------------------------------- /clangd_tidy/lsp/clangd.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import pathlib 4 | import sys 5 | from typing import Union 6 | 7 | from .client import ClientAsync, RequestResponsePair 8 | from .messages import ( 9 | DidOpenTextDocumentParams, 10 | DocumentFormattingParams, 11 | InitializeParams, 12 | LanguageId, 13 | LspNotificationMessage, 14 | NotificationMethod, 15 | RequestMethod, 16 | TextDocumentIdentifier, 17 | TextDocumentItem, 18 | WorkspaceFolder, 19 | ) 20 | from .rpc import RpcEndpointAsync 21 | 22 | __all__ = ["ClangdAsync"] 23 | 24 | 25 | class ClangdAsync: 26 | def __init__( 27 | self, 28 | clangd_executable: str, 29 | *, 30 | compile_commands_dir: str, 31 | jobs: int, 32 | verbose: bool, 33 | query_driver: str, 34 | ): 35 | self._clangd_cmd = [ 36 | clangd_executable, 37 | f"--compile-commands-dir={compile_commands_dir}", 38 | "--clang-tidy", 39 | f"--j={jobs}", 40 | "--pch-storage=memory", 41 | "--enable-config", 42 | ] 43 | if query_driver: 44 | self._clangd_cmd.append(f"--query-driver={query_driver}") 45 | self._stderr = sys.stderr if verbose else open(os.devnull, "w") 46 | 47 | async def start(self) -> None: 48 | self._process = await asyncio.create_subprocess_exec( 49 | *self._clangd_cmd, 50 | stdin=asyncio.subprocess.PIPE, 51 | stdout=asyncio.subprocess.PIPE, 52 | stderr=self._stderr, 53 | ) 54 | assert self._process.stdin is not None and self._process.stdout is not None 55 | rpc = RpcEndpointAsync(self._process.stdout, self._process.stdin) 56 | self._client = ClientAsync(rpc) 57 | 58 | async def recv_response_or_notification( 59 | self, 60 | ) -> Union[RequestResponsePair, LspNotificationMessage]: 61 | return await self._client.recv() 62 | 63 | async def initialize(self, root: pathlib.Path) -> None: 64 | assert root.is_dir() 65 | await self._client.request( 66 | RequestMethod.INITIALIZE, 67 | InitializeParams( 68 | processId=self._process.pid, 69 | workspaceFolders=[ 70 | WorkspaceFolder(name="foo", uri=root.as_uri()), 71 | ], 72 | ), 73 | ) 74 | 75 | async def initialized(self) -> None: 76 | await self._client.notify(NotificationMethod.INITIALIZED) 77 | 78 | async def did_open(self, path: pathlib.Path) -> None: 79 | assert path.is_file() 80 | await self._client.notify( 81 | NotificationMethod.DID_OPEN, 82 | DidOpenTextDocumentParams( 83 | TextDocumentItem( 84 | uri=path.as_uri(), 85 | languageId=LanguageId.CPP, 86 | version=1, 87 | text=path.read_text(), 88 | ) 89 | ), 90 | ) 91 | 92 | async def formatting(self, path: pathlib.Path) -> None: 93 | assert path.is_file() 94 | await self._client.request( 95 | RequestMethod.FORMATTING, 96 | DocumentFormattingParams( 97 | textDocument=TextDocumentIdentifier(uri=path.as_uri()), options={} 98 | ), 99 | ) 100 | 101 | async def shutdown(self) -> None: 102 | await self._client.request(RequestMethod.SHUTDOWN) 103 | 104 | async def exit(self) -> None: 105 | await self._client.notify(NotificationMethod.EXIT) 106 | self._process.kill() # PERF: much faster than waiting for clangd to exit 107 | await self._process.wait() 108 | 109 | # HACK: prevent RuntimeError('Event loop is closed') before Python 3.11 110 | # see https://github.com/python/cpython/issues/88050 111 | if sys.version_info < (3, 11): 112 | self._process._transport.close() # type: ignore 113 | 114 | self._stderr.close() 115 | -------------------------------------------------------------------------------- /clangd_tidy/lsp/client.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import itertools 3 | from typing import Dict, Union 4 | 5 | import cattrs 6 | 7 | from .messages import ( 8 | LspNotificationMessage, 9 | NotificationMethod, 10 | Params, 11 | RequestMessage, 12 | ResponseMessage, 13 | RequestMethod, 14 | ) 15 | from .rpc import RpcEndpointAsync 16 | 17 | 18 | __all__ = ["ClientAsync", "RequestResponsePair"] 19 | 20 | 21 | @dataclass 22 | class RequestResponsePair: 23 | request: RequestMessage 24 | response: ResponseMessage 25 | 26 | 27 | class ClientAsync: 28 | def __init__(self, rpc: RpcEndpointAsync): 29 | self._rpc = rpc 30 | self._id = itertools.count() 31 | self._requests: Dict[int, RequestMessage] = {} 32 | 33 | async def request(self, method: RequestMethod, params: Params = Params()) -> None: 34 | id = next(self._id) 35 | message = RequestMessage( 36 | id=id, method=method, params=cattrs.unstructure(params) 37 | ) 38 | self._requests[id] = message 39 | await self._rpc.send(cattrs.unstructure(message)) 40 | 41 | async def notify( 42 | self, method: NotificationMethod, params: Params = Params() 43 | ) -> None: 44 | message = LspNotificationMessage( 45 | method=method, params=cattrs.unstructure(params) 46 | ) 47 | await self._rpc.send(cattrs.unstructure(message)) 48 | 49 | async def recv(self) -> Union[RequestResponsePair, LspNotificationMessage]: 50 | content = await self._rpc.recv() 51 | if "method" in content: 52 | return cattrs.structure(content, LspNotificationMessage) 53 | else: 54 | resp = cattrs.structure(content, ResponseMessage) 55 | req = self._requests.pop(resp.id) 56 | return RequestResponsePair(request=req, response=resp) 57 | -------------------------------------------------------------------------------- /clangd_tidy/lsp/messages.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | from functools import total_ordering 3 | from typing import Any, Dict, List, Optional 4 | 5 | from attrs import Factory, define 6 | from typing_extensions import Self 7 | 8 | 9 | @define 10 | class Message: 11 | jsonrpc: str = "2.0" 12 | 13 | 14 | @unique 15 | class RequestMethod(Enum): 16 | INITIALIZE = "initialize" 17 | SHUTDOWN = "shutdown" 18 | FORMATTING = "textDocument/formatting" 19 | 20 | 21 | @unique 22 | class NotificationMethod(Enum): 23 | INITIALIZED = "initialized" 24 | EXIT = "exit" 25 | DID_OPEN = "textDocument/didOpen" 26 | PUBLISH_DIAGNOSTICS = "textDocument/publishDiagnostics" 27 | 28 | 29 | @unique 30 | class LanguageId(Enum): 31 | CPP = "cpp" 32 | 33 | 34 | @define 35 | class Params: 36 | pass 37 | 38 | 39 | @define(kw_only=True) 40 | class RequestMessage(Message): 41 | id: int 42 | method: RequestMethod 43 | params: Dict[str, Any] = Factory(dict) 44 | 45 | 46 | @define 47 | class ResponseError: 48 | code: int 49 | message: str 50 | data: Optional[Dict[str, Any]] = None 51 | 52 | 53 | @define(kw_only=True) 54 | class ResponseMessage(Message): 55 | id: int 56 | result: Any = None 57 | error: Optional[ResponseError] = None 58 | 59 | 60 | @define(kw_only=True) 61 | class LspNotificationMessage(Message): 62 | method: NotificationMethod 63 | params: Dict[str, Any] = Factory(dict) 64 | 65 | 66 | @define 67 | class WorkspaceFolder: 68 | uri: str 69 | name: str 70 | 71 | 72 | @define 73 | class InitializeParams(Params): 74 | processId: Optional[int] = None 75 | rootUri: Optional[str] = None 76 | initializationOptions: Any = None 77 | capabilities: Any = None 78 | workspaceFolders: List[WorkspaceFolder] = Factory(list) 79 | 80 | 81 | @define 82 | class TextDocumentItem: 83 | uri: str 84 | languageId: LanguageId 85 | version: int 86 | text: str 87 | 88 | 89 | @define 90 | class DidOpenTextDocumentParams(Params): 91 | textDocument: TextDocumentItem 92 | 93 | 94 | @define 95 | class Position: 96 | line: int 97 | character: int 98 | 99 | 100 | @define 101 | class Range: 102 | start: Position 103 | end: Position 104 | 105 | 106 | @unique 107 | @total_ordering 108 | class DiagnosticSeverity(Enum): 109 | ERROR = 1 110 | WARNING = 2 111 | INFORMATION = 3 112 | HINT = 4 113 | 114 | def __lt__(self, other: Self) -> bool: 115 | if self.__class__ is other.__class__: 116 | return self.value < other.value 117 | return NotImplemented 118 | 119 | 120 | @define 121 | class CodeDescription: 122 | href: str 123 | 124 | 125 | @define 126 | class Diagnostic: 127 | range: Range 128 | message: str 129 | severity: Optional[DiagnosticSeverity] = None 130 | code: Any = None 131 | codeDescription: Optional[CodeDescription] = None 132 | source: Optional[str] = None 133 | tags: Optional[List[Any]] = None 134 | relatedInformation: Optional[List[Any]] = None 135 | data: Any = None 136 | uri: Optional[str] = None # not in LSP spec, but clangd sends it 137 | 138 | 139 | @define 140 | class PublishDiagnosticsParams(Params): 141 | uri: str 142 | diagnostics: List[Diagnostic] 143 | version: Optional[int] = None 144 | 145 | 146 | @define 147 | class WorkDoneProgressParams(Params): 148 | workDoneToken: Any = None 149 | 150 | 151 | @define 152 | class TextDocumentIdentifier: 153 | uri: str 154 | 155 | 156 | @define(kw_only=True) 157 | class DocumentFormattingParams(WorkDoneProgressParams): 158 | textDocument: TextDocumentIdentifier 159 | options: Dict[str, Any] = Factory(dict) 160 | -------------------------------------------------------------------------------- /clangd_tidy/lsp/rpc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from dataclasses import dataclass 4 | from typing import Any, Dict, Optional 5 | 6 | __all__ = ["RpcEndpointAsync"] 7 | 8 | 9 | @dataclass 10 | class ProtocolHeader: 11 | content_length: Optional[int] = None 12 | content_type: Optional[str] = None 13 | complete: bool = False 14 | 15 | 16 | class Protocol: 17 | _HEADER_SEP = "\r\n" 18 | _HEADER_CONTENT_SEP = _HEADER_SEP * 2 19 | _LEN_HEADER = "Content-Length: " 20 | _TYPE_HEADER = "Content-Type: " 21 | 22 | @classmethod 23 | def encode(cls, data: Dict[str, Any]) -> bytes: 24 | content = json.dumps(data) 25 | header = f"{cls._LEN_HEADER}{len(content.encode())}" 26 | message = f"{header}{cls._HEADER_CONTENT_SEP}{content}" 27 | return message.encode() 28 | 29 | @classmethod 30 | def parse_header( 31 | cls, header_line_bin: bytes, header_to_update: ProtocolHeader 32 | ) -> None: 33 | header_line = header_line_bin.decode() 34 | if not header_line.endswith(cls._HEADER_SEP): 35 | raise ValueError("Invalid header end") 36 | header_line = header_line[: -len(cls._HEADER_SEP)] 37 | if not header_line: 38 | header_to_update.complete = True 39 | elif header_line.startswith(cls._LEN_HEADER): 40 | try: 41 | header_to_update.content_length = int( 42 | header_line[len(cls._LEN_HEADER) :] 43 | ) 44 | except ValueError: 45 | raise ValueError(f"Invalid Content-Length header field: {header_line}") 46 | elif header_line.startswith(cls._TYPE_HEADER): 47 | header_to_update.content_type = header_line[len(cls._TYPE_HEADER) :] 48 | else: 49 | raise ValueError(f"Unknown header: {header_line}") 50 | 51 | 52 | class RpcEndpointAsync: 53 | def __init__( 54 | self, in_stream: asyncio.StreamReader, out_stream: asyncio.StreamWriter 55 | ): 56 | self._in_stream = in_stream 57 | self._out_stream = out_stream 58 | 59 | async def send(self, data: Dict[str, Any]) -> None: 60 | self._out_stream.write(Protocol.encode(data)) 61 | await self._out_stream.drain() 62 | 63 | async def recv(self) -> Dict[str, Any]: 64 | header = ProtocolHeader() 65 | while True: 66 | header_line = await self._in_stream.readline() 67 | Protocol.parse_header(header_line, header) 68 | if header.complete: 69 | break 70 | if header.content_length is None: 71 | raise ValueError("Missing Content-Length header field") 72 | content = await self._in_stream.readexactly(header.content_length) 73 | return json.loads(content.decode()) 74 | -------------------------------------------------------------------------------- /clangd_tidy/lsp/server.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /clangd_tidy/main_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import asyncio 4 | import pathlib 5 | import sys 6 | from typing import Collection, List, Optional, TextIO 7 | from unittest.mock import MagicMock 8 | from urllib.parse import unquote, urlparse 9 | 10 | import cattrs 11 | 12 | from .args import SEVERITY_INT, parse_args 13 | from .diagnostic_formatter import ( 14 | CompactDiagnosticFormatter, 15 | DiagnosticCollection, 16 | FancyDiagnosticFormatter, 17 | GithubActionWorkflowCommandDiagnosticFormatter, 18 | ) 19 | from .line_filter import LineFilter 20 | from .lsp import ClangdAsync, RequestResponsePair 21 | from .lsp.messages import ( 22 | Diagnostic, 23 | DocumentFormattingParams, 24 | LspNotificationMessage, 25 | NotificationMethod, 26 | Position, 27 | PublishDiagnosticsParams, 28 | Range, 29 | RequestMethod, 30 | ) 31 | 32 | __all__ = ["main_cli"] 33 | 34 | 35 | def _uri_to_path(uri: str) -> pathlib.Path: 36 | return pathlib.Path(unquote(urlparse(uri).path)) 37 | 38 | 39 | def _is_output_supports_color(output: TextIO) -> bool: 40 | return hasattr(output, "isatty") and output.isatty() 41 | 42 | 43 | def _try_import_tqdm(enabled: bool): 44 | if enabled: 45 | try: 46 | from tqdm import tqdm # type: ignore 47 | 48 | return tqdm 49 | except ImportError: 50 | print( 51 | "tqdm is not installed. The progress bar feature is disabled.", 52 | file=sys.stderr, 53 | ) 54 | return MagicMock() 55 | 56 | 57 | class ClangdRunner: 58 | def __init__( 59 | self, 60 | clangd: ClangdAsync, 61 | files: Collection[pathlib.Path], 62 | run_format: bool, 63 | tqdm: bool, 64 | max_pending_requests: int, 65 | ): 66 | self._clangd = clangd 67 | self._files = files 68 | self._run_format = run_format 69 | self._tqdm = tqdm 70 | self._max_pending_requests = max_pending_requests 71 | 72 | def acquire_diagnostics(self) -> DiagnosticCollection: 73 | return asyncio.run(self._acquire_diagnostics()) 74 | 75 | async def _request_diagnostics(self) -> None: 76 | self._sem = asyncio.Semaphore(self._max_pending_requests) 77 | for file in self._files: 78 | await self._sem.acquire() 79 | await self._clangd.did_open(file) 80 | if self._run_format: 81 | await self._sem.acquire() 82 | await self._clangd.formatting(file) 83 | 84 | async def _collect_diagnostics(self) -> DiagnosticCollection: 85 | diagnostics: DiagnosticCollection = {} 86 | formatting_diagnostics: DiagnosticCollection = ( 87 | {} if self._run_format else {file: [] for file in self._files} 88 | ) 89 | nfiles = len(self._files) 90 | tqdm = _try_import_tqdm(self._tqdm) 91 | with tqdm( 92 | total=nfiles, 93 | desc="Collecting diagnostics", 94 | ) as pbar: 95 | while len(diagnostics) < nfiles or len(formatting_diagnostics) < nfiles: 96 | resp = await self._clangd.recv_response_or_notification() 97 | if isinstance(resp, LspNotificationMessage): 98 | if resp.method == NotificationMethod.PUBLISH_DIAGNOSTICS: 99 | params = cattrs.structure(resp.params, PublishDiagnosticsParams) 100 | file = _uri_to_path(params.uri) 101 | if file in self._files: 102 | diagnostics[file] = params.diagnostics 103 | tqdm.update(pbar) # type: ignore 104 | self._sem.release() 105 | else: 106 | assert resp.request.method == RequestMethod.FORMATTING 107 | assert resp.response.error is None, "Formatting failed" 108 | params = cattrs.structure( 109 | resp.request.params, DocumentFormattingParams 110 | ) 111 | file = _uri_to_path(params.textDocument.uri) 112 | formatting_diagnostics[file] = ( 113 | [ 114 | Diagnostic( 115 | range=Range(start=Position(0, 0), end=Position(0, 0)), 116 | message="File does not conform to the formatting rules (run `clang-format` to fix)", 117 | source="clang-format", 118 | ) 119 | ] 120 | if resp.response.result 121 | else [] 122 | ) 123 | self._sem.release() 124 | return { 125 | file: formatting_diagnostics[file] + diagnostics[file] 126 | for file in self._files 127 | } 128 | 129 | async def _acquire_diagnostics(self) -> DiagnosticCollection: 130 | await self._clangd.start() 131 | await self._clangd.initialize(pathlib.Path.cwd()) 132 | init_resp = await self._clangd.recv_response_or_notification() 133 | assert isinstance(init_resp, RequestResponsePair) 134 | assert init_resp.request.method == RequestMethod.INITIALIZE 135 | assert init_resp.response.error is None, "Initialization failed" 136 | await self._clangd.initialized() 137 | _, file_diagnostics = await asyncio.gather( 138 | self._request_diagnostics(), self._collect_diagnostics() 139 | ) 140 | await self._clangd.shutdown() 141 | await self._clangd.exit() 142 | return file_diagnostics 143 | 144 | 145 | def main_cli(): 146 | args = parse_args() 147 | 148 | files: List[pathlib.Path] = args.filename 149 | files = [ 150 | file.resolve() for file in files if file.suffix[1:] in args.allow_extensions 151 | ] 152 | missing_files = [str(file) for file in files if not file.is_file()] 153 | if missing_files: 154 | print(f"File(s) not found: {', '.join(missing_files)}", file=sys.stderr) 155 | sys.exit(1) 156 | 157 | file_diagnostics = ClangdRunner( 158 | clangd=ClangdAsync( 159 | args.clangd_executable, 160 | compile_commands_dir=args.compile_commands_dir, 161 | jobs=args.jobs, 162 | verbose=args.verbose, 163 | query_driver=args.query_driver, 164 | ), 165 | files=files, 166 | run_format=args.format, 167 | tqdm=args.tqdm, 168 | max_pending_requests=args.jobs * 2, 169 | ).acquire_diagnostics() 170 | 171 | line_filter: Optional[LineFilter] = args.line_filter 172 | if line_filter is not None: 173 | file_diagnostics = { 174 | file: [ 175 | diagnostic 176 | for diagnostic in diagnostics 177 | if line_filter.passes_line_filter(file, diagnostic) 178 | ] 179 | for file, diagnostics in file_diagnostics.items() 180 | } 181 | 182 | formatter = ( 183 | FancyDiagnosticFormatter( 184 | extra_context=args.context, 185 | enable_color=( 186 | _is_output_supports_color(args.output) 187 | if args.color == "auto" 188 | else args.color == "always" 189 | ), 190 | ) 191 | if not args.compact 192 | else CompactDiagnosticFormatter() 193 | ) 194 | print(formatter.format(file_diagnostics), file=args.output) 195 | if args.github: 196 | print( 197 | GithubActionWorkflowCommandDiagnosticFormatter(args.git_root).format( 198 | file_diagnostics 199 | ), 200 | file=args.output, 201 | ) 202 | if any( 203 | any( 204 | ( 205 | diagnostic.severity 206 | and diagnostic.severity <= SEVERITY_INT[args.fail_on_severity] 207 | ) 208 | or diagnostic.source == "clang-format" 209 | for diagnostic in diagnostics 210 | ) 211 | for diagnostics in file_diagnostics.values() 212 | ): 213 | exit(1) 214 | -------------------------------------------------------------------------------- /clangd_tidy/version.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._dist_ver import __version__ # type: ignore 3 | except ImportError: 4 | try: 5 | from setuptools_scm import get_version # type: ignore 6 | 7 | __version__ = get_version(root="..", relative_to=__file__) # type: ignore 8 | except (ImportError, LookupError): 9 | __version__ = "UNKNOWN" 10 | 11 | __all__ = ["__version__"] 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64", "setuptools-scm>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "clangd-tidy" 7 | dynamic = ["version"] 8 | dependencies = ["attrs", "cattrs", "typing-extensions"] 9 | requires-python = ">=3.8" 10 | authors = [{ name = "lljbash", email = "lljbash@gmail.com" }] 11 | description = "A faster alternative to clang-tidy" 12 | readme = "README.md" 13 | keywords = ["clang-tidy", "clangd", "static-analysis", "cpp"] 14 | classifiers = [ 15 | "Development Status :: 4 - Beta", 16 | "Environment :: Console", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: POSIX", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Topic :: Software Development :: Quality Assurance", 22 | ] 23 | 24 | [project.scripts] 25 | clangd-tidy = "clangd_tidy:main_cli" 26 | clangd-tidy-diff = "clangd_tidy:clang_tidy_diff" 27 | 28 | [project.urls] 29 | "Homepage" = "https://github.com/lljbash/clangd-tidy" 30 | "Bug Tracker" = "https://github.com/lljbash/clangd-tidy/issues" 31 | 32 | [tool.setuptools_scm] 33 | write_to = "clangd_tidy/_dist_ver.py" 34 | 35 | [tool.black] 36 | include = '\.pyi?$' 37 | required-version = "25" 38 | 39 | [tool.basedpyright] 40 | include = ["clangd_tidy"] 41 | pythonVersion = "3.8" 42 | pythonPlatform = "Linux" 43 | typeCheckingMode = "strict" 44 | 45 | [dependency-groups] 46 | dev = [ 47 | "basedpyright" 48 | ] 49 | -------------------------------------------------------------------------------- /test/.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: Cpp 3 | BasedOnStyle: Google 4 | BreakAfterAttributes: Leave 5 | CommentPragmas: '^ (IWYU pragma:|NOLINT(BEGIN|END|NEXTLINE)?(\(.+\))?:? )' 6 | DerivePointerAlignment: false 7 | InsertNewlineAtEOF: true 8 | -------------------------------------------------------------------------------- /test/.clang-tidy: -------------------------------------------------------------------------------- 1 | --- 2 | Checks: ' 3 | bugprone-*, 4 | -bugprone-easily-swappable-parameters, 5 | clang-analyzer-*, 6 | clang-diagnostic-*, 7 | cppcoreguidelines-*, 8 | google-*, 9 | -google-*googletest*, 10 | hicpp-avoid-goto, 11 | hicpp-exception-baseclass, 12 | misc-header-include-cycle, 13 | misc-static-assert, 14 | misc-unused-alias-decls, 15 | misc-unused-using-decls, 16 | modernize-*, 17 | performance-*, 18 | readability-*, 19 | -readability-qualified-auto, 20 | -readability-static-accessed-through-instance' 21 | # AnalyzeTemporaryDtors: false 22 | FormatStyle: file 23 | HeaderFilterRegex: '.*' 24 | CheckOptions: 25 | - key: cppcoreguidelines-avoid-do-while.IgnoreMacros 26 | value: true 27 | - key: cppcoreguidelines-narrowing-conversions.IgnoreConversionFromTypes 28 | value: 'size_t;ptrdiff_t;size_type;difference_type' 29 | - key: readability-function-cognitive-complexity.IgnoreMacros 30 | value: true 31 | - key: readability-identifier-length.MinimumVariableNameLength 32 | value: 2 33 | - key: readability-identifier-length.MinimumParameterNameLength 34 | value: 2 35 | # --- Google's naming convention BEGIN --- 36 | # modified part is marked as comment 37 | - key: readability-identifier-naming.ClassCase 38 | value: CamelCase 39 | - key: readability-identifier-naming.ClassMemberCase 40 | value: lower_case 41 | - key: readability-identifier-naming.ConstexprVariableCase 42 | value: CamelCase 43 | - key: readability-identifier-naming.ConstexprVariablePrefix 44 | value: k 45 | - key: readability-identifier-naming.EnumCase 46 | value: CamelCase 47 | - key: readability-identifier-naming.EnumConstantCase 48 | value: CamelCase 49 | - key: readability-identifier-naming.EnumConstantPrefix 50 | value: k 51 | - key: readability-identifier-naming.FunctionCase 52 | # value: CamelCase 53 | value: lower_case 54 | - key: readability-identifier-naming.GlobalConstantCase 55 | value: CamelCase 56 | - key: readability-identifier-naming.GlobalConstantPrefix 57 | value: k 58 | - key: readability-identifier-naming.StaticConstantCase 59 | value: CamelCase 60 | - key: readability-identifier-naming.StaticConstantPrefix 61 | value: k 62 | - key: readability-identifier-naming.StaticVariableCase 63 | value: lower_case 64 | - key: readability-identifier-naming.MacroDefinitionCase 65 | value: UPPER_CASE 66 | - key: readability-identifier-naming.MacroDefinitionIgnoredRegexp 67 | value: '^[A-Z]+(_[A-Z]+)*_$' 68 | - key: readability-identifier-naming.MemberCase 69 | value: lower_case 70 | - key: readability-identifier-naming.PrivateMemberSuffix 71 | value: _ 72 | - key: readability-identifier-naming.PublicMemberSuffix 73 | value: '' 74 | - key: readability-identifier-naming.NamespaceCase 75 | value: lower_case 76 | - key: readability-identifier-naming.ParameterCase 77 | value: lower_case 78 | - key: readability-identifier-naming.TypeAliasCase 79 | value: CamelCase 80 | - key: readability-identifier-naming.TypedefCase 81 | value: CamelCase 82 | - key: readability-identifier-naming.VariableCase 83 | value: lower_case 84 | - key: readability-identifier-naming.IgnoreMainLikeFunctions 85 | value: 1 86 | # --- Google's naming convention END --- 87 | ... 88 | -------------------------------------------------------------------------------- /test/a.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | class AA { 4 | public: 5 | public: 6 | }; 7 | -------------------------------------------------------------------------------- /test/b.cpp: -------------------------------------------------------------------------------- 1 | int 2 | main() { int a; int b; c=1 } 3 | -------------------------------------------------------------------------------- /test/c.cpp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lljbash/clangd-tidy/e5425af8deccfc2965424bbc42c7f1f5c57fe2c3/test/c.cpp -------------------------------------------------------------------------------- /test/d.cpp: -------------------------------------------------------------------------------- 1 | auto 2 | f() -> int; 3 | --------------------------------------------------------------------------------