diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index e148eb8..0e84c1f 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -4,6 +4,8 @@ on: workflow_dispatch: pull_request: push: + branches: + - master jobs: python-black: @@ -12,4 +14,4 @@ jobs: - uses: actions/checkout@v4 - uses: psf/black@stable with: # see: https://black.readthedocs.io/en/stable/integrations/github_actions.html - version: "~= 24.0" + version: "~= 25.0" diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..4291274 --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,23 @@ +name: python + +on: + workflow_dispatch: + pull_request: + push: + branches: + - master + +jobs: + basedpyright: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install the project + run: uv sync --all-extras --dev + + - name: Run basedpyright + run: uv run basedpyright **/*.py diff --git a/.gitignore b/.gitignore index 43ac716..aa4d7ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Packages /clangd_tidy/_dist_ver.py +# uv +uv.lock + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.md b/README.md index ca2183d..fad5f59 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Unfortunately, there seems to be no plan within LLVM to accelerate the standalon - clangd-tidy is significantly faster than clang-tidy (over 10x in my experience). - clangd-tidy can check header files individually, even if they are not included in the compilation database. - clangd-tidy groups diagnostics by files -- no more duplicated diagnostics from the same header! +- clangd-tidy provides an optional code format checking feature, eliminating the need to run clang-format separately. - clangd-tidy supports [`.clangd` configuration files](https://clangd.llvm.org/config), offering features not supported by clang-tidy. - Example: Removing unknown compiler flags from the compilation database. ```yaml @@ -29,19 +30,22 @@ Unfortunately, there seems to be no plan within LLVM to accelerate the standalon # Require clangd-17 MissingIncludes: Strict ``` +- Hyperlinks on diagnostic check names in supported terminals. - Refer to [Usage](#usage) for more features. **Cons:** - 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.) -- clangd-tidy silently disables [several](https://searchfox.org/llvm/rev/cb7bda2ace81226c5b33165411dd0316f93fa57e/clang-tools-extra/clangd/TidyProvider.cpp#199-227) checks not supported by clangd. +- 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. - Diagnostics generated by clangd-tidy might be marginally less aesthetically pleasing compared to clang-tidy. +- Other known discrepancies between clangd and clang-tidy behavior: #7, #15, #16. ## Prerequisites - [clangd](https://clangd.llvm.org/) - Python 3.8+ (may work on older versions, but not tested) -- [tqdm](https://github.com/tqdm/tqdm) (optional) +- [attrs](https://www.attrs.org/) and [cattrs](https://catt.rs/) (automatically installed if clangd-tidy is installed via pip) +- [tqdm](https://github.com/tqdm/tqdm) (optional, required for progress bar support) ## Installation @@ -51,47 +55,49 @@ pip install clangd-tidy ## Usage +### clang-tidy + ``` -usage: clangd-tidy [-h] [-p COMPILE_COMMANDS_DIR] [-j JOBS] [-o OUTPUT] - [--clangd-executable CLANGD_EXECUTABLE] - [--allow-extensions ALLOW_EXTENSIONS] - [--fail-on-severity SEVERITY] [--tqdm] [--github] +usage: clangd-tidy [--allow-extensions ALLOW_EXTENSIONS] + [--fail-on-severity SEVERITY] [-f] [-o OUTPUT] + [--line-filter LINE_FILTER] [--tqdm] [--github] [--git-root GIT_ROOT] [-c] [--context CONTEXT] [--color {auto,always,never}] [-v] + [-p COMPILE_COMMANDS_DIR] [-j JOBS] + [--clangd-executable CLANGD_EXECUTABLE] + [--query-driver QUERY_DRIVER] [-V] [-h] filename [filename ...] Run clangd with clang-tidy and output diagnostics. This aims to serve as a faster alternative to clang-tidy. -positional arguments: - filename Files to check. Files whose extensions are not in - ALLOW_EXTENSIONS will be ignored. - -options: - -h, --help show this help message and exit - -V, --version show program's version number and exit - -p COMPILE_COMMANDS_DIR, --compile-commands-dir COMPILE_COMMANDS_DIR - 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] - -j JOBS, --jobs JOBS Number of async workers used by clangd. Background - index also uses this many workers. [default: 1] - -o OUTPUT, --output OUTPUT - Output file for diagnostics. [default: stdout] - --clangd-executable CLANGD_EXECUTABLE - Path to clangd executable. [default: clangd] +input options: + filename Files to analyze. Ignores files with extensions not + listed in ALLOW_EXTENSIONS. --allow-extensions ALLOW_EXTENSIONS A comma-separated list of file extensions to allow. [default: c,h,cpp,cc,cxx,hpp,hh,hxx,cu,cuh] + +check options: --fail-on-severity SEVERITY - On which severity of diagnostics this program should - exit with a non-zero status. Candidates: error, warn, - info, hint. [default: hint] + Specifies the diagnostic severity level at which the + program exits with a non-zero status. Possible values: + error, warn, info, hint. [default: hint] + -f, --format Also check code formatting with clang-format. Exits + with a non-zero status if any file violates formatting + rules. + +output options: + -o OUTPUT, --output OUTPUT + Output file for diagnostics. [default: stdout] + --line-filter LINE_FILTER + A JSON with a list of files and line ranges that will + act as a filter for diagnostics. Compatible with + clang-tidy --line-filter parameter format. --tqdm Show a progress bar (tqdm required). --github Append workflow commands for GitHub Actions to output. - --git-root GIT_ROOT Root directory of the git repository. Only works with - --github. [default: current directory] + --git-root GIT_ROOT Specifies the root directory of the Git repository. + Only works with --github. [default: current directory] -c, --compact Print compact diagnostics (legacy). --context CONTEXT Number of additional lines to display on both sides of each diagnostic. This option is ineffective with @@ -99,17 +105,71 @@ options: --color {auto,always,never} Colorize the output. This option is ineffective with --compact. [default: auto] - -v, --verbose Show verbose output from clangd. + -v, --verbose Stream verbose output from clangd to stderr. + +clangd options: + -p COMPILE_COMMANDS_DIR, --compile-commands-dir COMPILE_COMMANDS_DIR + 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] + -j JOBS, --jobs JOBS Number of async workers used by clangd. Background + index also uses this many workers. [default: 1] + --clangd-executable CLANGD_EXECUTABLE + Clangd executable. [default: clangd] + --query-driver QUERY_DRIVER + 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++-*`. + +generic options: + -V, --version Show program's version number and exit. + -h, --help Show this help message and exit. Find more information on https://github.com/lljbash/clangd-tidy. ``` +### clangd-tidy-diff + +``` +usage: clangd-tidy-diff [-h] [-V] [-p COMPILE_COMMANDS_DIR] + [--pass-arg PASS_ARG] + +Run clangd-tidy on modified files, reporting diagnostics only for changed lines. + +optional arguments: + -h, --help show this help message and exit + -V, --version show program's version number and exit + -p COMPILE_COMMANDS_DIR, --compile-commands-dir COMPILE_COMMANDS_DIR + 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. + --pass-arg PASS_ARG Pass this argument to clangd-tidy (can be used + multiple times) + +Receives a diff on stdin and runs clangd-tidy only on the changed lines. +This is useful to slowly onboard a codebase to linting or to find regressions. +Inspired by clang-tidy-diff.py from the LLVM project. + +Example usage with git: + git diff -U0 HEAD^^..HEAD | clangd-tidy-diff -p my/build + +``` + ## Acknowledgement -Special thanks to [@yeger00](https://github.com/yeger00) for his [pylspclient](https://github.com/yeger00/pylspclient). +Special thanks to [@yeger00](https://github.com/yeger00) for his [pylspclient](https://github.com/yeger00/pylspclient), which inspired earlier versions of this project. A big shoutout to [clangd](https://clangd.llvm.org/) and [clang-tidy](https://clang.llvm.org/extra/clang-tidy/) for their great work! -Claps to [@ArchieAtkinson](https://github.com/ArchieAtkinson) for his artistic flair in the fancy diagnostic formatter. +Claps to +- [@ArchieAtkinson](https://github.com/ArchieAtkinson) for his artistic flair in the fancy diagnostic formatter. +- [@jmpfar](https://github.com/jmpfar) for his contribution to hyperlink support and `clangd-tidy-diff`. +- And all other contributors who have helped improve this project: + [@mateosss](https://github.com/mateosss) + [@kammce](https://github.com/kammce) Contributions are welcome! Feel free to open an issue or a pull request. diff --git a/clangd_tidy/__init__.py b/clangd_tidy/__init__.py index eb89611..7f401a4 100644 --- a/clangd_tidy/__init__.py +++ b/clangd_tidy/__init__.py @@ -1,4 +1,5 @@ +from .clangd_tidy_diff_cli import clang_tidy_diff from .main_cli import main_cli from .version import __version__ -__all__ = ["main_cli", "__version__"] +__all__ = ["main_cli", "clang_tidy_diff", "__version__"] diff --git a/clangd_tidy/args.py b/clangd_tidy/args.py new file mode 100644 index 0000000..0753a77 --- /dev/null +++ b/clangd_tidy/args.py @@ -0,0 +1,165 @@ +import argparse +import json +import os +import pathlib +import sys + +import cattrs + +from .line_filter import LineFilter +from .lsp.messages import DiagnosticSeverity +from .version import __version__ + +__all__ = ["SEVERITY_INT", "parse_args"] + + +SEVERITY_INT = dict( + error=DiagnosticSeverity.ERROR, + warn=DiagnosticSeverity.WARNING, + info=DiagnosticSeverity.INFORMATION, + hint=DiagnosticSeverity.HINT, +) + + +def parse_args() -> argparse.Namespace: + DEFAULT_ALLOWED_EXTENSIONS = [ + "c", + "h", + "cpp", + "cc", + "cxx", + "hpp", + "hh", + "hxx", + "cu", + "cuh", + ] + + parser = argparse.ArgumentParser( + prog="clangd-tidy", + description="Run clangd with clang-tidy and output diagnostics. This aims to serve as a faster alternative to clang-tidy.", + epilog="Find more information on https://github.com/lljbash/clangd-tidy.", + add_help=False, + ) + + input_group = parser.add_argument_group("input options") + input_group.add_argument( + "filename", + nargs="+", + type=pathlib.Path, + help="Files to analyze. Ignores files with extensions not listed in ALLOW_EXTENSIONS.", + ) + input_group.add_argument( + "--allow-extensions", + type=lambda x: x.strip().split(","), + default=DEFAULT_ALLOWED_EXTENSIONS, + help=f"A comma-separated list of file extensions to allow. [default: {','.join(DEFAULT_ALLOWED_EXTENSIONS)}]", + ) + + check_group = parser.add_argument_group("check options") + check_group.add_argument( + "--fail-on-severity", + metavar="SEVERITY", + choices=SEVERITY_INT.keys(), + default="hint", + 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]", + ) + check_group.add_argument( + "-f", + "--format", + action="store_true", + help="Also check code formatting with clang-format. Exits with a non-zero status if any file violates formatting rules.", + ) + + output_group = parser.add_argument_group("output options") + output_group.add_argument( + "-o", + "--output", + type=argparse.FileType("w"), + default=sys.stdout, + help="Output file for diagnostics. [default: stdout]", + ) + output_group.add_argument( + "--line-filter", + type=lambda x: cattrs.structure(json.loads(x), LineFilter), + help=( + "A JSON with a list of files and line ranges that will act as a filter for diagnostics." + " Compatible with clang-tidy --line-filter parameter format." + ), + ) + output_group.add_argument( + "--tqdm", action="store_true", help="Show a progress bar (tqdm required)." + ) + output_group.add_argument( + "--github", + action="store_true", + help="Append workflow commands for GitHub Actions to output.", + ) + output_group.add_argument( + "--git-root", + default=os.getcwd(), + help="Specifies the root directory of the Git repository. Only works with --github. [default: current directory]", + ) + output_group.add_argument( + "-c", + "--compact", + action="store_true", + help="Print compact diagnostics (legacy).", + ) + output_group.add_argument( + "--context", + type=int, + default=2, + help="Number of additional lines to display on both sides of each diagnostic. This option is ineffective with --compact. [default: 2]", + ) + output_group.add_argument( + "--color", + choices=["auto", "always", "never"], + default="auto", + help="Colorize the output. This option is ineffective with --compact. [default: auto]", + ) + output_group.add_argument( + "-v", + "--verbose", + action="store_true", + help="Stream verbose output from clangd to stderr.", + ) + + clangd_group = parser.add_argument_group("clangd options") + clangd_group.add_argument( + "-p", + "--compile-commands-dir", + default="build", + 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]", + ) + clangd_group.add_argument( + "-j", + "--jobs", + type=int, + default=1, + help="Number of async workers used by clangd. Background index also uses this many workers. [default: 1]", + ) + clangd_group.add_argument( + "--clangd-executable", + default="clangd", + help="Clangd executable. [default: clangd]", + ) + clangd_group.add_argument( + "--query-driver", + default="", + 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++-*`.", + ) + + misc_group = parser.add_argument_group("generic options") + misc_group.add_argument( + "-V", + "--version", + action="version", + version=f"%(prog)s {__version__}", + help="Show program's version number and exit.", + ) + misc_group.add_argument( + "-h", "--help", action="help", help="Show this help message and exit." + ) + + return parser.parse_args() diff --git a/clangd_tidy/clangd_tidy_diff_cli.py b/clangd_tidy/clangd_tidy_diff_cli.py new file mode 100644 index 0000000..a7f75df --- /dev/null +++ b/clangd_tidy/clangd_tidy_diff_cli.py @@ -0,0 +1,107 @@ +""" +Receives a diff on stdin and runs clangd-tidy only on the changed lines. +This is useful to slowly onboard a codebase to linting or to find regressions. +Inspired by clang-tidy-diff.py from the LLVM project. + +Example usage with git: + git diff -U0 HEAD^^..HEAD | clangd-tidy-diff -p my/build +""" + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Callable, Dict, List, NoReturn, Optional, TextIO, Union + +import cattrs + +from .line_filter import FileLineFilter, LineFilter, LineRange +from .version import __version__ + + +def _parse_gitdiff( + text: TextIO, add_file_range_callback: Callable[[Path, int, int], None] +) -> None: + """ + Parses a git diff and calls add_file_range_callback for each added line range. + """ + ADDED_FILE_NAME_REGEX = re.compile(r'^\+\+\+ "?(?P.*?/)(?P[^\s"]*)') + ADDED_LINES_REGEX = re.compile(r"^@@.*\+(?P\d+)(,(?P\d+))?") + + last_file: Optional[str] = None + for line in text: + m = re.search(ADDED_FILE_NAME_REGEX, line) + if m is not None: + last_file = m.group("file") + if last_file is None: + continue + + m = re.search(ADDED_LINES_REGEX, line) + if m is None: + continue + start_line = int(m.group("line")) + line_count = int(m.group("count")) if m.group("count") else 1 + if line_count == 0: + continue + end_line = start_line + line_count - 1 + + add_file_range_callback(Path(last_file), start_line, end_line) + + +def clang_tidy_diff() -> NoReturn: + parser = argparse.ArgumentParser( + description="Run clangd-tidy on modified files, reporting diagnostics only for changed lines.", + epilog=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-V", "--version", action="version", version=f"%(prog)s {__version__}" + ) + parser.add_argument( + "-p", + "--compile-commands-dir", + 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.", + ) + parser.add_argument( + "--pass-arg", + action="append", + help="Pass this argument to clangd-tidy (can be used multiple times)", + ) + args = parser.parse_args() + + line_filter_map: Dict[Path, FileLineFilter] = {} + _parse_gitdiff( + sys.stdin, + lambda file, start, end: line_filter_map.setdefault( + file.resolve(), FileLineFilter(file.resolve(), []) + ).lines.append(LineRange(start, end)), + ) + if not line_filter_map: + print("No relevant changes found.", file=sys.stderr) + sys.exit(0) + + line_filter = LineFilter(list(line_filter_map.values())) + filters_json = json.dumps(cattrs.unstructure(line_filter)) + command: List[Union[str, bytes, Path]] = [ + "clangd-tidy", + "--line-filter", + filters_json, + ] + + if args.compile_commands_dir: + command.extend(["--compile-commands-dir", args.compile_commands_dir]) + + if args.pass_arg: + command.extend(args.pass_arg) + + files = line_filter_map.keys() + command.append("--") + command.extend(files) + + sys.exit(subprocess.run(command).returncode) + + +if __name__ == "__main__": + clang_tidy_diff() diff --git a/clangd_tidy/diagnostic_formatter.py b/clangd_tidy/diagnostic_formatter.py index e1949a2..a65ae1b 100644 --- a/clangd_tidy/diagnostic_formatter.py +++ b/clangd_tidy/diagnostic_formatter.py @@ -1,10 +1,21 @@ -from abc import ABC, abstractmethod import os +import pathlib import re -from typing import Any, Iterable, Optional, Tuple +from abc import ABC, abstractmethod +from typing import Dict, Iterable, List, Optional + +from .lsp.messages import Diagnostic, DiagnosticSeverity +__all__ = [ + "DiagnosticCollection", + "DiagnosticFormatter", + "CompactDiagnosticFormatter", + "FancyDiagnosticFormatter", + "GithubActionWorkflowCommandDiagnosticFormatter", +] -DiagnosticCollection = Iterable[Tuple[str, Any]] + +DiagnosticCollection = Dict[pathlib.Path, List[Diagnostic]] class DiagnosticFormatter(ABC): @@ -15,35 +26,69 @@ class DiagnosticFormatter(ABC): 4: "Hint", } - @abstractmethod def format(self, diagnostic_collection: DiagnosticCollection) -> str: + file_outputs: List[str] = [] + for file, diagnostics in sorted( + diagnostic_collection.items(), key=lambda fd: fd[0].as_posix() + ): + diagnostic_outputs = [ + o + for o in [ + self._format_one_diagnostic(file, diagnostic) + for diagnostic in diagnostics + ] + if o is not None + ] + if len(diagnostic_outputs) == 0: + continue + file_outputs.append(self._make_file_output(file, diagnostic_outputs)) + return self._make_whole_output(file_outputs) + + @abstractmethod + def _format_one_diagnostic( + self, file: pathlib.Path, diagnostic: Diagnostic + ) -> Optional[str]: + pass + + @abstractmethod + def _make_file_output( + self, file: pathlib.Path, diagnostic_outputs: Iterable[str] + ) -> str: + pass + + @abstractmethod + def _make_whole_output(self, file_outputs: Iterable[str]) -> str: pass class CompactDiagnosticFormatter(DiagnosticFormatter): - def format(self, diagnostic_collection: DiagnosticCollection) -> str: - output = "" - for file, diagnostics in diagnostic_collection: - if len(diagnostics) == 0: - continue - output += "----- {} -----\n\n".format(os.path.relpath(file)) - for diagnostic in diagnostics: - source = diagnostic.get("source", None) - severity = diagnostic.get("severity", None) - code = diagnostic.get("code", None) - extra_info = "{}{}{}".format( - f" {source}" if source else "", - f" {self.SEVERITY[severity]}" if severity else "", - f" [{code}]" if code else "", - ) - line = diagnostic["range"]["start"]["line"] + 1 - col = diagnostic["range"]["start"]["character"] + 1 - message = diagnostic["message"] - if source is None and code is None: - continue - output += f"- line {line}, col {col}:{extra_info}\n{message}\n\n" - output += "\n" - return output + def _format_one_diagnostic( + self, file: pathlib.Path, diagnostic: Diagnostic + ) -> Optional[str]: + del file + source = diagnostic.source + severity = diagnostic.severity + code = diagnostic.code + extra_info = "{}{}{}".format( + f" {source}" if source is not None else "", + f" {self.SEVERITY[severity.value]}" if severity is not None else "", + f" [{code}]" if code is not None else "", + ) + line = diagnostic.range.start.line + 1 + col = diagnostic.range.start.character + 1 + message = diagnostic.message + if source is None and code is None: + return None + return f"- line {line}, col {col}:{extra_info}\n{message}" + + def _make_file_output( + self, file: pathlib.Path, diagnostic_outputs: Iterable[str] + ) -> str: + head = f"----- {os.path.relpath(file)} -----" + return "\n\n".join([head, *diagnostic_outputs]) + + def _make_whole_output(self, file_outputs: Iterable[str]) -> str: + return "\n\n\n".join(file_outputs) class GithubActionWorkflowCommandDiagnosticFormatter(DiagnosticFormatter): @@ -57,32 +102,40 @@ class GithubActionWorkflowCommandDiagnosticFormatter(DiagnosticFormatter): def __init__(self, git_root: str): self._git_root = git_root - def format(self, diagnostic_collection: DiagnosticCollection) -> str: - commands = "::group::{workflow commands}\n" - for file, diagnostics in diagnostic_collection: - if len(diagnostics) == 0: - continue - for diagnostic in diagnostics: - source = diagnostic.get("source", None) - severity = diagnostic.get("severity", None) - code = diagnostic.get("code", None) - extra_info = "{}{}{}".format( - f"{source}" if source else "", - f" {self.SEVERITY[severity]}" if severity else "", - f" [{code}]" if code else "", - ) - line = diagnostic["range"]["start"]["line"] + 1 - end_line = diagnostic["range"]["end"]["line"] + 1 - col = diagnostic["range"]["start"]["character"] + 1 - end_col = diagnostic["range"]["end"]["character"] + 1 - message = diagnostic["message"] - if source is None and code is None: - continue - command = self.SEVERITY_GITHUB[severity] - rel_file = os.path.relpath(file, self._git_root) - commands += f"::{command} file={rel_file},line={line},endLine={end_line},col={col},endCol={end_col},title={extra_info}::{message}\n" - commands += "::endgroup::" - return commands + def _format_one_diagnostic( + self, file: pathlib.Path, diagnostic: Diagnostic + ) -> Optional[str]: + source = diagnostic.source + severity = diagnostic.severity + code = diagnostic.code + extra_info = "{}{}{}".format( + f"{source}" if source else "", + f" {self.SEVERITY[severity.value]}" if severity is not None else "", + f" [{code}]" if code is not None else "", + ) + line = diagnostic.range.start.line + 1 + end_line = diagnostic.range.end.line + 1 + col = diagnostic.range.start.character + 1 + end_col = diagnostic.range.end.character + 1 + message = diagnostic.message + if source is None and code is None: + return None + if severity is None: + severity = DiagnosticSeverity.INFORMATION + command = self.SEVERITY_GITHUB[severity.value] + rel_file = os.path.relpath(file, self._git_root) + return f"::{command} file={rel_file},line={line},endLine={end_line},col={col},endCol={end_col},title={extra_info}::{message}" + + def _make_file_output( + self, file: pathlib.Path, diagnostic_outputs: Iterable[str] + ) -> str: + del file + return "\n".join(diagnostic_outputs) + + def _make_whole_output(self, file_outputs: Iterable[str]) -> str: + head = "::group::{workflow commands}" + tail = "::endgroup::" + return "\n".join(["", head, *file_outputs, tail]) class FancyDiagnosticFormatter(DiagnosticFormatter): @@ -94,8 +147,11 @@ class ColorSeqTty: HINT = "\033[94m" NOTE = "\033[90m" GREEN = "\033[92m" + MAGENTA = "\033[95m" BOLD = "\033[1m" ENDC = "\033[0m" + START_LINK = "\033]8;;" + END_LINK = "\033\\" class ColorSeqNoTty: ERROR = "" @@ -104,28 +160,40 @@ class ColorSeqNoTty: HINT = "" NOTE = "" GREEN = "" + MAGENTA = "" BOLD = "" ENDC = "" def __init__(self, enable_color: bool): - self.color_seq = self.ColorSeqTty if enable_color else self.ColorSeqNoTty + self._color_seq = self.ColorSeqTty if enable_color else self.ColorSeqNoTty def per_severity(self, severity: int, message: str): if severity == 1: - return f"{self.color_seq.ERROR}{message}{self.color_seq.ENDC}" + return f"{self._color_seq.ERROR}{message}{self._color_seq.ENDC}" if severity == 2: - return f"{self.color_seq.WARNING}{message}{self.color_seq.ENDC}" + return f"{self._color_seq.WARNING}{message}{self._color_seq.ENDC}" if severity == 3: - return f"{self.color_seq.INFO}{message}{self.color_seq.ENDC}" + return f"{self._color_seq.INFO}{message}{self._color_seq.ENDC}" if severity == 4: - return f"{self.color_seq.HINT}{message}{self.color_seq.ENDC}" + return f"{self._color_seq.HINT}{message}{self._color_seq.ENDC}" return message def highlight(self, message: str): - return f"{self.color_seq.GREEN}{message}{self.color_seq.ENDC}" + return f"{self._color_seq.GREEN}{message}{self._color_seq.ENDC}" def note(self, message: str): - return f"{self.color_seq.NOTE}{message}{self.color_seq.ENDC}" + return f"{self._color_seq.NOTE}{message}{self._color_seq.ENDC}" + + def format(self, message: str): + return f"{self._color_seq.MAGENTA}{message}{self._color_seq.ENDC}" + + def link(self, message: str, url: str): + if not url or self._color_seq is not self.ColorSeqTty: + return message + return ( + f"{self._color_seq.START_LINK}{url}{self._color_seq.END_LINK}" + f"{message}{self._color_seq.START_LINK}{self._color_seq.END_LINK}" + ) def __init__(self, extra_context: int, enable_color: bool): self._extra_context = extra_context @@ -138,8 +206,8 @@ def _colorized_severity(self, severity: int): def _prepend_line_number(line: str, lino: Optional[int]) -> str: LINO_WIDTH = 5 LINO_SEP = " | " - lino_str = str(lino) if lino else "" - return f"{lino_str :{LINO_WIDTH}}{LINO_SEP}{line.rstrip()}\n" + lino_str = str(lino + 1) if lino is not None else "" + return f"{lino_str:>{LINO_WIDTH}}{LINO_SEP}{line.rstrip()}\n" def _code_context( self, @@ -180,7 +248,7 @@ def _code_context( indicator = self._colorizer.highlight(indicator) context += self._prepend_line_number(indicator, lino=None) - return context + return context.rstrip() @staticmethod def _diagnostic_message( @@ -194,57 +262,67 @@ def _diagnostic_message( ) -> str: return f"{file}:{line_start + 1}:{col_start + 1}: {severity}: {message} {code}\n{context}" - def format(self, diagnostic_collection: DiagnosticCollection) -> str: - fancy_output = "" + def _formatting_message(self, file: str, message: str) -> str: + return self._colorizer.format(f"{file}: {message}") + + def _format_one_diagnostic( + self, file: pathlib.Path, diagnostic: Diagnostic + ) -> Optional[str]: + rel_file = os.path.relpath(file) + + if diagnostic.source == "clang-format": + return self._formatting_message(rel_file, diagnostic.message) + + message: str = diagnostic.message.replace(" (fix available)", "") + message_list = [line for line in message.splitlines() if line.strip()] + message, extra_messages = message_list[0], message_list[1:] + + if diagnostic.code is None: + return None + + code_url = diagnostic.codeDescription.href if diagnostic.codeDescription else "" + code = f"[{self._colorizer.link(diagnostic.code, code_url)}]" - for file, diagnostics in diagnostic_collection: - if len(diagnostics) == 0: + severity = ( + self._colorized_severity(diagnostic.severity.value) + if diagnostic.severity is not None + else "" + ) + + line_start = diagnostic.range.start.line + line_end = diagnostic.range.end.line + + col_start = diagnostic.range.start.character + col_end = diagnostic.range.end.character + + context = self._code_context(rel_file, line_start, line_end, col_start, col_end) + + fancy_output = self._diagnostic_message( + rel_file, line_start, col_start, severity, message, code, context + ) + + for extra_message in extra_messages: + match_code_loc = re.match(r".*:(\d+):(\d+):.*", extra_message) + if not match_code_loc: continue - file = os.path.relpath(file) - for diagnostic in diagnostics: - message: str = diagnostic["message"].replace(" (fix available)", "") - message_list = [line for line in message.splitlines() if line.strip()] - message, extra_messages = message_list[0], message_list[1:] - - raw_code = diagnostic.get("code", None) - if not raw_code: - continue - code = f"[{raw_code}]" if raw_code else "" - - raw_severity = diagnostic.get("severity", None) - severity = ( - self._colorized_severity(raw_severity) if raw_severity else "" - ) - - line_start = diagnostic["range"]["start"]["line"] - line_end = diagnostic["range"]["end"]["line"] - - col_start = diagnostic["range"]["start"]["character"] - col_end = diagnostic["range"]["end"]["character"] - - context = self._code_context( - file, line_start, line_end, col_start, col_end - ) - - fancy_output += self._diagnostic_message( - file, line_start, col_start, severity, message, code, context - ) - - for extra_message in extra_messages: - match_code_loc = re.match(r".*:(\d+):(\d+):.*", extra_message) - if not match_code_loc: - continue - line = int(match_code_loc.group(1)) - 1 - col = int(match_code_loc.group(2)) - 1 - extra_message = " ".join(extra_message.split(" ")[2:]) - context = self._code_context( - file, line, line, col, col + 1, extra_context=0 - ) - note = self._colorizer.note("Note") - fancy_output += self._diagnostic_message( - file, line, col, note, extra_message, "", context - ) - - fancy_output += "\n" + line = int(match_code_loc.group(1)) - 1 + col = int(match_code_loc.group(2)) - 1 + extra_message = " ".join(extra_message.split(" ")[2:]) + context = self._code_context( + rel_file, line, line, col, col + 1, extra_context=0 + ) + note = self._colorizer.note("Note") + fancy_output += "\n" + self._diagnostic_message( + rel_file, line, col, note, extra_message, "", context + ) return fancy_output + + def _make_file_output( + self, file: pathlib.Path, diagnostic_outputs: Iterable[str] + ) -> str: + del file + return "\n\n".join(diagnostic_outputs) + + def _make_whole_output(self, file_outputs: Iterable[str]) -> str: + return "\n\n".join(file_outputs) diff --git a/clangd_tidy/line_filter.py b/clangd_tidy/line_filter.py new file mode 100644 index 0000000..7f1e6e3 --- /dev/null +++ b/clangd_tidy/line_filter.py @@ -0,0 +1,112 @@ +from pathlib import Path +from typing import Any, List + +import cattrs +from attr import Factory +from attrs import define + +from .lsp.messages import Diagnostic + +__all__ = ["LineFilter"] + + +@define +class LineRange: + start: int + end: int + + def intersect_with(self, other: "LineRange") -> bool: + return max(self.start, other.start) <= min(self.end, other.end) + + +@cattrs.register_structure_hook +def range_structure_hook(val: List[int], _: type) -> LineRange: + if len(val) != 2: + raise ValueError("Range must be a list of two integers.") + return LineRange(val[0], val[1]) + + +@cattrs.register_unstructure_hook +def range_unstructure_hook(obj: LineRange) -> List[int]: + return [obj.start, obj.end] + + +@define +class FileLineFilter: + """ + Filters diagnostics in line ranges of a specific file + """ + + name: Path + """ + File path + """ + + lines: List[LineRange] = Factory(list) + """ + List of inclusive line ranges where diagnostics will be emitted + + If empty, all diagnostics will be emitted + """ + + def matches_file(self, file: Path) -> bool: + return str(file.resolve()).endswith(str(self.name)) + + def matches_range(self, start: int, end: int) -> bool: + return not self.lines or any( + LineRange(start, end).intersect_with(line_range) + for line_range in self.lines + ) + + +@define +class LineFilter: + """ + Filters diagnostics by line ranges. + This is meant to be compatible with clang-tidy --line-filter syntax. + """ + + file_line_filters: List[FileLineFilter] + """ + The format of the list is a JSON array of objects: + [ + {"name":"file1.cpp","lines":[[1,3],[5,7]]}, + {"name":"file2.h"} + ] + """ + + def passes_line_filter(self, file: Path, diagnostic: Diagnostic) -> bool: + """ + Check if a diagnostic passes the line filter. + + @see https://github.com/llvm/llvm-project/blob/980d66caae62de9b56422a2fdce3f535c2ab325f/clang-tools-extra/clang-tidy/ClangTidyDiagnosticConsumer.cpp#L463-L479 + """ + if not self.file_line_filters: + return True + first_match_filter = next( + (f for f in self.file_line_filters if f.matches_file(file)), + None, + ) + if first_match_filter is None: + return False + + # EXTRA: keep clang-format diagnostics unfiltered + if diagnostic.source is not None and diagnostic.source == "clang-format": + return True + # EXTRA: filter out clang-tidy diagnostics without source and code + if diagnostic.source is None and diagnostic.code is None: + return False + + return first_match_filter.matches_range( + diagnostic.range.start.line + 1, diagnostic.range.end.line + 1 + ) + + +@cattrs.register_structure_hook +def line_filter_structure_hook(val: List[Any], _: type) -> LineFilter: + return LineFilter([cattrs.structure(f, FileLineFilter) for f in val]) + + +@cattrs.register_unstructure_hook +def line_filter_unstructure_hook(obj: LineFilter) -> List[FileLineFilter]: + return [cattrs.unstructure(f) for f in obj.file_line_filters] diff --git a/clangd_tidy/lsp/__init__.py b/clangd_tidy/lsp/__init__.py new file mode 100644 index 0000000..effdea4 --- /dev/null +++ b/clangd_tidy/lsp/__init__.py @@ -0,0 +1,5 @@ +from .clangd import ClangdAsync +from .client import RequestResponsePair +from . import messages + +__all__ = ["ClangdAsync", "RequestResponsePair", "messages"] diff --git a/clangd_tidy/lsp/clangd.py b/clangd_tidy/lsp/clangd.py new file mode 100644 index 0000000..347999d --- /dev/null +++ b/clangd_tidy/lsp/clangd.py @@ -0,0 +1,114 @@ +import asyncio +import os +import pathlib +import sys +from typing import Union + +from .client import ClientAsync, RequestResponsePair +from .messages import ( + DidOpenTextDocumentParams, + DocumentFormattingParams, + InitializeParams, + LanguageId, + LspNotificationMessage, + NotificationMethod, + RequestMethod, + TextDocumentIdentifier, + TextDocumentItem, + WorkspaceFolder, +) +from .rpc import RpcEndpointAsync + +__all__ = ["ClangdAsync"] + + +class ClangdAsync: + def __init__( + self, + clangd_executable: str, + *, + compile_commands_dir: str, + jobs: int, + verbose: bool, + query_driver: str, + ): + self._clangd_cmd = [ + clangd_executable, + f"--compile-commands-dir={compile_commands_dir}", + "--clang-tidy", + f"--j={jobs}", + "--pch-storage=memory", + "--enable-config", + ] + if query_driver: + self._clangd_cmd.append(f"--query-driver={query_driver}") + self._stderr = sys.stderr if verbose else open(os.devnull, "w") + + async def start(self) -> None: + self._process = await asyncio.create_subprocess_exec( + *self._clangd_cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=self._stderr, + ) + assert self._process.stdin is not None and self._process.stdout is not None + rpc = RpcEndpointAsync(self._process.stdout, self._process.stdin) + self._client = ClientAsync(rpc) + + async def recv_response_or_notification( + self, + ) -> Union[RequestResponsePair, LspNotificationMessage]: + return await self._client.recv() + + async def initialize(self, root: pathlib.Path) -> None: + assert root.is_dir() + await self._client.request( + RequestMethod.INITIALIZE, + InitializeParams( + processId=self._process.pid, + workspaceFolders=[ + WorkspaceFolder(name="foo", uri=root.as_uri()), + ], + ), + ) + + async def initialized(self) -> None: + await self._client.notify(NotificationMethod.INITIALIZED) + + async def did_open(self, path: pathlib.Path) -> None: + assert path.is_file() + await self._client.notify( + NotificationMethod.DID_OPEN, + DidOpenTextDocumentParams( + TextDocumentItem( + uri=path.as_uri(), + languageId=LanguageId.CPP, + version=1, + text=path.read_text(), + ) + ), + ) + + async def formatting(self, path: pathlib.Path) -> None: + assert path.is_file() + await self._client.request( + RequestMethod.FORMATTING, + DocumentFormattingParams( + textDocument=TextDocumentIdentifier(uri=path.as_uri()), options={} + ), + ) + + async def shutdown(self) -> None: + await self._client.request(RequestMethod.SHUTDOWN) + + async def exit(self) -> None: + await self._client.notify(NotificationMethod.EXIT) + self._process.kill() # PERF: much faster than waiting for clangd to exit + await self._process.wait() + + # HACK: prevent RuntimeError('Event loop is closed') before Python 3.11 + # see https://github.com/python/cpython/issues/88050 + if sys.version_info < (3, 11): + self._process._transport.close() # type: ignore + + self._stderr.close() diff --git a/clangd_tidy/lsp/client.py b/clangd_tidy/lsp/client.py new file mode 100644 index 0000000..69875c7 --- /dev/null +++ b/clangd_tidy/lsp/client.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass +import itertools +from typing import Dict, Union + +import cattrs + +from .messages import ( + LspNotificationMessage, + NotificationMethod, + Params, + RequestMessage, + ResponseMessage, + RequestMethod, +) +from .rpc import RpcEndpointAsync + + +__all__ = ["ClientAsync", "RequestResponsePair"] + + +@dataclass +class RequestResponsePair: + request: RequestMessage + response: ResponseMessage + + +class ClientAsync: + def __init__(self, rpc: RpcEndpointAsync): + self._rpc = rpc + self._id = itertools.count() + self._requests: Dict[int, RequestMessage] = {} + + async def request(self, method: RequestMethod, params: Params = Params()) -> None: + id = next(self._id) + message = RequestMessage( + id=id, method=method, params=cattrs.unstructure(params) + ) + self._requests[id] = message + await self._rpc.send(cattrs.unstructure(message)) + + async def notify( + self, method: NotificationMethod, params: Params = Params() + ) -> None: + message = LspNotificationMessage( + method=method, params=cattrs.unstructure(params) + ) + await self._rpc.send(cattrs.unstructure(message)) + + async def recv(self) -> Union[RequestResponsePair, LspNotificationMessage]: + content = await self._rpc.recv() + if "method" in content: + return cattrs.structure(content, LspNotificationMessage) + else: + resp = cattrs.structure(content, ResponseMessage) + req = self._requests.pop(resp.id) + return RequestResponsePair(request=req, response=resp) diff --git a/clangd_tidy/lsp/messages.py b/clangd_tidy/lsp/messages.py new file mode 100644 index 0000000..d5ca032 --- /dev/null +++ b/clangd_tidy/lsp/messages.py @@ -0,0 +1,159 @@ +from enum import Enum, unique +from functools import total_ordering +from typing import Any, Dict, List, Optional + +from attrs import Factory, define +from typing_extensions import Self + + +@define +class Message: + jsonrpc: str = "2.0" + + +@unique +class RequestMethod(Enum): + INITIALIZE = "initialize" + SHUTDOWN = "shutdown" + FORMATTING = "textDocument/formatting" + + +@unique +class NotificationMethod(Enum): + INITIALIZED = "initialized" + EXIT = "exit" + DID_OPEN = "textDocument/didOpen" + PUBLISH_DIAGNOSTICS = "textDocument/publishDiagnostics" + + +@unique +class LanguageId(Enum): + CPP = "cpp" + + +@define +class Params: + pass + + +@define(kw_only=True) +class RequestMessage(Message): + id: int + method: RequestMethod + params: Dict[str, Any] = Factory(dict) + + +@define +class ResponseError: + code: int + message: str + data: Optional[Dict[str, Any]] = None + + +@define(kw_only=True) +class ResponseMessage(Message): + id: int + result: Any = None + error: Optional[ResponseError] = None + + +@define(kw_only=True) +class LspNotificationMessage(Message): + method: NotificationMethod + params: Dict[str, Any] = Factory(dict) + + +@define +class WorkspaceFolder: + uri: str + name: str + + +@define +class InitializeParams(Params): + processId: Optional[int] = None + rootUri: Optional[str] = None + initializationOptions: Any = None + capabilities: Any = None + workspaceFolders: List[WorkspaceFolder] = Factory(list) + + +@define +class TextDocumentItem: + uri: str + languageId: LanguageId + version: int + text: str + + +@define +class DidOpenTextDocumentParams(Params): + textDocument: TextDocumentItem + + +@define +class Position: + line: int + character: int + + +@define +class Range: + start: Position + end: Position + + +@unique +@total_ordering +class DiagnosticSeverity(Enum): + ERROR = 1 + WARNING = 2 + INFORMATION = 3 + HINT = 4 + + def __lt__(self, other: Self) -> bool: + if self.__class__ is other.__class__: + return self.value < other.value + return NotImplemented + + +@define +class CodeDescription: + href: str + + +@define +class Diagnostic: + range: Range + message: str + severity: Optional[DiagnosticSeverity] = None + code: Any = None + codeDescription: Optional[CodeDescription] = None + source: Optional[str] = None + tags: Optional[List[Any]] = None + relatedInformation: Optional[List[Any]] = None + data: Any = None + uri: Optional[str] = None # not in LSP spec, but clangd sends it + + +@define +class PublishDiagnosticsParams(Params): + uri: str + diagnostics: List[Diagnostic] + version: Optional[int] = None + + +@define +class WorkDoneProgressParams(Params): + workDoneToken: Any = None + + +@define +class TextDocumentIdentifier: + uri: str + + +@define(kw_only=True) +class DocumentFormattingParams(WorkDoneProgressParams): + textDocument: TextDocumentIdentifier + options: Dict[str, Any] = Factory(dict) diff --git a/clangd_tidy/lsp/rpc.py b/clangd_tidy/lsp/rpc.py new file mode 100644 index 0000000..e349814 --- /dev/null +++ b/clangd_tidy/lsp/rpc.py @@ -0,0 +1,73 @@ +import asyncio +import json +from dataclasses import dataclass +from typing import Any, Dict, Optional + +__all__ = ["RpcEndpointAsync"] + + +@dataclass +class ProtocolHeader: + content_length: Optional[int] = None + content_type: Optional[str] = None + complete: bool = False + + +class Protocol: + _HEADER_SEP = "\r\n" + _HEADER_CONTENT_SEP = _HEADER_SEP * 2 + _LEN_HEADER = "Content-Length: " + _TYPE_HEADER = "Content-Type: " + + @classmethod + def encode(cls, data: Dict[str, Any]) -> bytes: + content = json.dumps(data) + header = f"{cls._LEN_HEADER}{len(content.encode())}" + message = f"{header}{cls._HEADER_CONTENT_SEP}{content}" + return message.encode() + + @classmethod + def parse_header( + cls, header_line_bin: bytes, header_to_update: ProtocolHeader + ) -> None: + header_line = header_line_bin.decode() + if not header_line.endswith(cls._HEADER_SEP): + raise ValueError("Invalid header end") + header_line = header_line[: -len(cls._HEADER_SEP)] + if not header_line: + header_to_update.complete = True + elif header_line.startswith(cls._LEN_HEADER): + try: + header_to_update.content_length = int( + header_line[len(cls._LEN_HEADER) :] + ) + except ValueError: + raise ValueError(f"Invalid Content-Length header field: {header_line}") + elif header_line.startswith(cls._TYPE_HEADER): + header_to_update.content_type = header_line[len(cls._TYPE_HEADER) :] + else: + raise ValueError(f"Unknown header: {header_line}") + + +class RpcEndpointAsync: + def __init__( + self, in_stream: asyncio.StreamReader, out_stream: asyncio.StreamWriter + ): + self._in_stream = in_stream + self._out_stream = out_stream + + async def send(self, data: Dict[str, Any]) -> None: + self._out_stream.write(Protocol.encode(data)) + await self._out_stream.drain() + + async def recv(self) -> Dict[str, Any]: + header = ProtocolHeader() + while True: + header_line = await self._in_stream.readline() + Protocol.parse_header(header_line, header) + if header.complete: + break + if header.content_length is None: + raise ValueError("Missing Content-Length header field") + content = await self._in_stream.read(header.content_length) + return json.loads(content.decode()) diff --git a/clangd_tidy/lsp/server.py b/clangd_tidy/lsp/server.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/clangd_tidy/lsp/server.py @@ -0,0 +1 @@ + diff --git a/clangd_tidy/main_cli.py b/clangd_tidy/main_cli.py index 8bb95ff..df2f1e5 100644 --- a/clangd_tidy/main_cli.py +++ b/clangd_tidy/main_cli.py @@ -1,306 +1,183 @@ #!/usr/bin/env python3 -import argparse -import os -import signal -import subprocess +import asyncio +import pathlib import sys -import threading -from typing import IO, Set, TextIO +from typing import Collection, List, Optional, TextIO +from unittest.mock import MagicMock +from urllib.parse import unquote, urlparse +import cattrs + +from .args import SEVERITY_INT, parse_args from .diagnostic_formatter import ( - DiagnosticFormatter, CompactDiagnosticFormatter, + DiagnosticCollection, FancyDiagnosticFormatter, GithubActionWorkflowCommandDiagnosticFormatter, ) -from .pylspclient.json_rpc_endpoint import JsonRpcEndpoint -from .pylspclient.lsp_endpoint import LspEndpoint -from .pylspclient.lsp_client import LspClient -from .pylspclient.lsp_structs import TextDocumentItem, LANGUAGE_IDENTIFIER -from .version import __version__ +from .line_filter import LineFilter +from .lsp import ClangdAsync, RequestResponsePair +from .lsp.messages import ( + Diagnostic, + DocumentFormattingParams, + LspNotificationMessage, + NotificationMethod, + Position, + PublishDiagnosticsParams, + Range, + RequestMethod, +) __all__ = ["main_cli"] -class ReadPipe(threading.Thread): - def __init__(self, pipe: IO[bytes], out: TextIO): - threading.Thread.__init__(self) - self.pipe = pipe - self.out = out - - def run(self): - line = self.pipe.readline().decode("utf-8") - while line: - print(line, file=self.out) - line = self.pipe.readline().decode("utf-8") - - -def kill_child_process(sig, _, child_processes, pbar): - """Kill child processes on SIGINT""" - assert sig == signal.SIGINT - if pbar is not None: - pbar.close() - for child in child_processes: - print(f"Terminating child process {child.pid}...", file=sys.stderr) - child.terminate() - child.wait() - print(f"Child process {child.pid} terminated.", file=sys.stderr) - sys.exit(1) - - -class FileExtensionFilter: - def __init__(self, extensions: Set[str]): - self.extensions = extensions - - def __call__(self, file_path): - return os.path.splitext(file_path)[1][1:] in self.extensions +def _uri_to_path(uri: str) -> pathlib.Path: + return pathlib.Path(unquote(urlparse(uri).path)) -def _file_uri(path: str): - return "file://" + path - - -def _uri_file(uri: str): - if not uri.startswith("file://"): - raise ValueError("Not a file URI: " + uri) - return uri[7:] - - -def _is_output_supports_color(output: TextIO): +def _is_output_supports_color(output: TextIO) -> bool: return hasattr(output, "isatty") and output.isatty() -class DiagnosticCollector: - SEVERITY_INT = { - "error": 1, - "warn": 2, - "info": 3, - "hint": 4, - } - - def __init__(self): - self.diagnostics = {} - self.requested_files = set() - self.cond = threading.Condition() - - def handle_publish_diagnostics(self, args): - file = _uri_file(args["uri"]) - if file not in self.requested_files: - return - self.cond.acquire() - self.diagnostics[file] = args["diagnostics"] - self.cond.notify() - self.cond.release() - - def request_diagnostics(self, lsp_client: LspClient, file_path: str): - file_path = os.path.abspath(file_path) - languageId = LANGUAGE_IDENTIFIER.CPP - version = 1 - text = open(file_path, "r").read() - self.requested_files.add(file_path) - lsp_client.didOpen( - TextDocumentItem(_file_uri(file_path), languageId, version, text) - ) - - def check_failed(self, fail_on_severity: str) -> bool: - severity_level = self.SEVERITY_INT[fail_on_severity] - for diagnostics in self.diagnostics.values(): - for diagnostic in diagnostics: - if diagnostic["severity"] <= severity_level: - return True - return False - - def format_diagnostics(self, formatter: DiagnosticFormatter) -> str: - return formatter.format(sorted(self.diagnostics.items())).rstrip() - - -def main_cli(): - DEFAULT_ALLOW_EXTENSIONS = [ - "c", - "h", - "cpp", - "cc", - "cxx", - "hpp", - "hh", - "hxx", - "cu", - "cuh", - ] - - parser = argparse.ArgumentParser( - prog="clangd-tidy", - description="Run clangd with clang-tidy and output diagnostics. This aims to serve as a faster alternative to clang-tidy.", - epilog="Find more information on https://github.com/lljbash/clangd-tidy.", - ) - parser.add_argument( - "-V", "--version", action="version", version=f"%(prog)s {__version__}" - ) - parser.add_argument( - "-p", - "--compile-commands-dir", - default="build", - 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]", - ) - parser.add_argument( - "-j", - "--jobs", - type=int, - default=1, - help="Number of async workers used by clangd. Background index also uses this many workers. [default: 1]", - ) - parser.add_argument( - "-o", - "--output", - type=argparse.FileType("w"), - default=sys.stdout, - help="Output file for diagnostics. [default: stdout]", - ) - parser.add_argument( - "--clangd-executable", - default="clangd", - help="Path to clangd executable. [default: clangd]", - ) - parser.add_argument( - "--allow-extensions", - default=DEFAULT_ALLOW_EXTENSIONS, - help=f"A comma-separated list of file extensions to allow. [default: {','.join(DEFAULT_ALLOW_EXTENSIONS)}]", - ) - parser.add_argument( - "--fail-on-severity", - metavar="SEVERITY", - choices=DiagnosticCollector.SEVERITY_INT.keys(), - default="hint", - help=f"On which severity of diagnostics this program should exit with a non-zero status. Candidates: {', '.join(DiagnosticCollector.SEVERITY_INT)}. [default: hint]", - ) - parser.add_argument( - "--tqdm", action="store_true", help="Show a progress bar (tqdm required)." - ) - parser.add_argument( - "--github", - action="store_true", - help="Append workflow commands for GitHub Actions to output.", - ) - parser.add_argument( - "--git-root", - default=os.getcwd(), - help="Root directory of the git repository. Only works with --github. [default: current directory]", - ) - parser.add_argument( - "-c", - "--compact", - action="store_true", - help="Print compact diagnostics (legacy).", - ) - parser.add_argument( - "--context", - type=int, - default=2, - help="Number of additional lines to display on both sides of each diagnostic. This option is ineffective with --compact. [default: 2]", - ) - parser.add_argument( - "--color", - choices=["auto", "always", "never"], - default="auto", - help="Colorize the output. This option is ineffective with --compact. [default: auto]", - ) - parser.add_argument( - "-v", "--verbose", action="store_true", help="Show verbose output from clangd." - ) - parser.add_argument( - "filename", - nargs="+", - help="Files to check. Files whose extensions are not in ALLOW_EXTENSIONS will be ignored.", - ) - args = parser.parse_args() - - ext_filter = FileExtensionFilter(set(map(str.strip, args.allow_extensions))) - files = list(filter(ext_filter, args.filename)) - for file in files: - if not os.path.isfile(file): - print(f"File not found: {file}", file=sys.stderr) - sys.exit(1) - - clangd_command = [ - f"{args.clangd_executable}", - f"--compile-commands-dir={args.compile_commands_dir}", - "--clang-tidy", - f"-j={args.jobs}", - "--pch-storage=memory", - "--enable-config", - "--offset-encoding=utf-16", - ] - - p = subprocess.Popen( - clangd_command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert p.stderr is not None - read_pipe = ReadPipe(p.stderr, args.verbose and sys.stderr or open(os.devnull, "w")) - read_pipe.start() - - # Kill clangd subprocess on SIGINT - pbar = None # use to close progress bar if it exists - signal.signal(signal.SIGINT, lambda sig, _: kill_child_process(sig, _, [p], pbar)) - - collector = DiagnosticCollector() - - json_rpc_endpoint = JsonRpcEndpoint(p.stdin, p.stdout) - lsp_endpoint = LspEndpoint( - json_rpc_endpoint, - notify_callbacks={ - "textDocument/publishDiagnostics": lambda args: collector.handle_publish_diagnostics( - args - ), - }, - ) - lsp_client = LspClient(lsp_endpoint) - - root_path = os.path.abspath(".") - root_uri = _file_uri(root_path) - workspace_folders = [{"name": "foo", "uri": root_uri}] - - lsp_client.initialize(p.pid, None, root_uri, None, None, "off", workspace_folders) - lsp_client.initialized() - - for file in files: - collector.request_diagnostics(lsp_client, file) - - if args.tqdm: +def _try_import_tqdm(enabled: bool): + if enabled: try: - from tqdm import tqdm + from tqdm import tqdm # type: ignore + + return tqdm except ImportError: print( - "tqdm not found. Please install tqdm to enable progress bar.", + "tqdm is not installed. The progress bar feature is disabled.", file=sys.stderr, ) - args.tqdm = False + return MagicMock() + + +class ClangdRunner: + def __init__( + self, + clangd: ClangdAsync, + files: Collection[pathlib.Path], + run_format: bool, + tqdm: bool, + max_pending_requests: int, + ): + self._clangd = clangd + self._files = files + self._run_format = run_format + self._tqdm = tqdm + self._max_pending_requests = max_pending_requests + + def acquire_diagnostics(self) -> DiagnosticCollection: + return asyncio.run(self._acquire_diagnostics()) + + async def _request_diagnostics(self) -> None: + self._sem = asyncio.Semaphore(self._max_pending_requests) + for file in self._files: + await self._sem.acquire() + await self._clangd.did_open(file) + if self._run_format: + await self._sem.acquire() + await self._clangd.formatting(file) + + async def _collect_diagnostics(self) -> DiagnosticCollection: + diagnostics: DiagnosticCollection = {} + formatting_diagnostics: DiagnosticCollection = ( + {} if self._run_format else {file: [] for file in self._files} + ) + nfiles = len(self._files) + tqdm = _try_import_tqdm(self._tqdm) + with tqdm( + total=nfiles, + desc="Collecting diagnostics", + ) as pbar: + while len(diagnostics) < nfiles or len(formatting_diagnostics) < nfiles: + resp = await self._clangd.recv_response_or_notification() + if isinstance(resp, LspNotificationMessage): + if resp.method == NotificationMethod.PUBLISH_DIAGNOSTICS: + params = cattrs.structure(resp.params, PublishDiagnosticsParams) + file = _uri_to_path(params.uri) + if file in self._files: + diagnostics[file] = params.diagnostics + tqdm.update(pbar) # type: ignore + self._sem.release() + else: + assert resp.request.method == RequestMethod.FORMATTING + assert resp.response.error is None, "Formatting failed" + params = cattrs.structure( + resp.request.params, DocumentFormattingParams + ) + file = _uri_to_path(params.textDocument.uri) + formatting_diagnostics[file] = ( + [ + Diagnostic( + range=Range(start=Position(0, 0), end=Position(0, 0)), + message="File does not conform to the formatting rules (run `clang-format` to fix)", + source="clang-format", + ) + ] + if resp.response.result + else [] + ) + self._sem.release() + return { + file: formatting_diagnostics[file] + diagnostics[file] + for file in self._files + } + + async def _acquire_diagnostics(self) -> DiagnosticCollection: + await self._clangd.start() + await self._clangd.initialize(pathlib.Path.cwd()) + init_resp = await self._clangd.recv_response_or_notification() + assert isinstance(init_resp, RequestResponsePair) + assert init_resp.request.method == RequestMethod.INITIALIZE + assert init_resp.response.error is None, "Initialization failed" + await self._clangd.initialized() + _, file_diagnostics = await asyncio.gather( + self._request_diagnostics(), self._collect_diagnostics() + ) + await self._clangd.shutdown() + await self._clangd.exit() + return file_diagnostics - if args.tqdm: - from tqdm import tqdm - with tqdm(total=len(files)) as pbar: - collector.cond.acquire() - while len(collector.diagnostics) < len(files): - pbar.update(len(collector.diagnostics) - pbar.n) - collector.cond.wait() - pbar.update(len(collector.diagnostics) - pbar.n) - collector.cond.release() - else: - collector.cond.acquire() - while len(collector.diagnostics) < len(files): - collector.cond.wait() - collector.cond.release() +def main_cli(): + args = parse_args() - lsp_client.shutdown() - lsp_client.exit() - lsp_endpoint.join() - os.wait() - if read_pipe.is_alive(): - read_pipe.join() + files: List[pathlib.Path] = args.filename + files = [ + file.resolve() for file in files if file.suffix[1:] in args.allow_extensions + ] + missing_files = [str(file) for file in files if not file.is_file()] + if missing_files: + print(f"File(s) not found: {', '.join(missing_files)}", file=sys.stderr) + sys.exit(1) + + file_diagnostics = ClangdRunner( + clangd=ClangdAsync( + args.clangd_executable, + compile_commands_dir=args.compile_commands_dir, + jobs=args.jobs, + verbose=args.verbose, + query_driver=args.query_driver, + ), + files=files, + run_format=args.format, + tqdm=args.tqdm, + max_pending_requests=args.jobs * 2, + ).acquire_diagnostics() + + line_filter: Optional[LineFilter] = args.line_filter + if line_filter is not None: + file_diagnostics = { + file: [ + diagnostic + for diagnostic in diagnostics + if line_filter.passes_line_filter(file, diagnostic) + ] + for file, diagnostics in file_diagnostics.items() + } formatter = ( FancyDiagnosticFormatter( @@ -314,13 +191,23 @@ def main_cli(): if not args.compact else CompactDiagnosticFormatter() ) - print(collector.format_diagnostics(formatter), file=args.output) + print(formatter.format(file_diagnostics), file=args.output) if args.github: print( - collector.format_diagnostics( - GithubActionWorkflowCommandDiagnosticFormatter(args.git_root) + GithubActionWorkflowCommandDiagnosticFormatter(args.git_root).format( + file_diagnostics ), file=args.output, ) - if collector.check_failed(args.fail_on_severity): + if any( + any( + ( + diagnostic.severity + and diagnostic.severity <= SEVERITY_INT[args.fail_on_severity] + ) + or diagnostic.source == "clang-format" + for diagnostic in diagnostics + ) + for diagnostics in file_diagnostics.values() + ): exit(1) diff --git a/clangd_tidy/pylspclient/LICENSE b/clangd_tidy/pylspclient/LICENSE deleted file mode 100644 index 179d7b8..0000000 --- a/clangd_tidy/pylspclient/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -MIT License - -Copyright (c) 2023 lljbash -Copyright (c) 2018 Avi Yeger - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/clangd_tidy/pylspclient/__init__.py b/clangd_tidy/pylspclient/__init__.py deleted file mode 100644 index a6a26af..0000000 --- a/clangd_tidy/pylspclient/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .json_rpc_endpoint import JsonRpcEndpoint -from .lsp_client import LspClient -from .lsp_endpoint import LspEndpoint -from . import lsp_structs - -__all__ = ["JsonRpcEndpoint", "LspClient", "LspEndpoint", "lsp_structs"] diff --git a/clangd_tidy/pylspclient/json_rpc_endpoint.py b/clangd_tidy/pylspclient/json_rpc_endpoint.py deleted file mode 100644 index 181607d..0000000 --- a/clangd_tidy/pylspclient/json_rpc_endpoint.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import print_function -import json -import re -import threading - -from . import lsp_structs - -JSON_RPC_REQ_FORMAT = "Content-Length: {json_string_len}\r\n\r\n{json_string}" -LEN_HEADER = "Content-Length: " -TYPE_HEADER = "Content-Type: " - - -# TODO: add content-type - - -class MyEncoder(json.JSONEncoder): - """ - Encodes an object in JSON - """ - def default(self, o): # pylint: disable=E0202 - return o.__dict__ - - -class JsonRpcEndpoint(object): - ''' - Thread safe JSON RPC endpoint implementation. Responsible to recieve and send JSON RPC messages, as described in the - protocol. More information can be found: https://www.jsonrpc.org/ - ''' - def __init__(self, stdin, stdout): - self.stdin = stdin - self.stdout = stdout - self.read_lock = threading.Lock() - self.write_lock = threading.Lock() - - @staticmethod - def __add_header(json_string): - ''' - Adds a header for the given json string - - :param str json_string: The string - :return: the string with the header - ''' - return JSON_RPC_REQ_FORMAT.format(json_string_len=len(json_string), json_string=json_string) - - - def send_request(self, message): - ''' - Sends the given message. - - :param dict message: The message to send. - ''' - json_string = json.dumps(message, cls=MyEncoder) - jsonrpc_req = self.__add_header(json_string) - with self.write_lock: - self.stdin.write(jsonrpc_req.encode()) - self.stdin.flush() - - - def recv_response(self): - ''' - Recives a message. - - :return: a message - ''' - with self.read_lock: - message_size = None - while True: - #read header - line = self.stdout.readline() - if not line: - # server quit - return None - line = line.decode("utf-8") - if not line.endswith("\r\n"): - raise lsp_structs.ResponseError(lsp_structs.ErrorCodes.ParseError, "Bad header: missing newline") - #remove the "\r\n" - line = line[:-2] - if line == "": - # done with the headers - break - elif line.startswith(LEN_HEADER): - line = line[len(LEN_HEADER):] - if not line.isdigit(): - raise lsp_structs.ResponseError(lsp_structs.ErrorCodes.ParseError, "Bad header: size is not int") - message_size = int(line) - elif line.startswith(TYPE_HEADER): - # nothing todo with type for now. - pass - else: - raise lsp_structs.ResponseError(lsp_structs.ErrorCodes.ParseError, "Bad header: unkown header") - if not message_size: - raise lsp_structs.ResponseError(lsp_structs.ErrorCodes.ParseError, "Bad header: missing size") - - jsonrpc_res = self.stdout.read(message_size).decode("utf-8") - return json.loads(jsonrpc_res) diff --git a/clangd_tidy/pylspclient/lsp_client.py b/clangd_tidy/pylspclient/lsp_client.py deleted file mode 100644 index 61221fb..0000000 --- a/clangd_tidy/pylspclient/lsp_client.py +++ /dev/null @@ -1,195 +0,0 @@ -from . import lsp_structs - -class LspClient(object): - def __init__(self, lsp_endpoint): - """ - Constructs a new LspClient instance. - - :param lsp_endpoint: TODO - """ - self.lsp_endpoint = lsp_endpoint - - - def initialize(self, processId, rootPath, rootUri, initializationOptions, capabilities, trace, workspaceFolders): - """ - The initialize request is sent as the first request from the client to the server. If the server receives a request or notification - before the initialize request it should act as follows: - - 1. For a request the response should be an error with code: -32002. The message can be picked by the server. - 2. Notifications should be dropped, except for the exit notification. This will allow the exit of a server without an initialize request. - - Until the server has responded to the initialize request with an InitializeResult, the client must not send any additional requests or - notifications to the server. In addition the server is not allowed to send any requests or notifications to the client until it has responded - with an InitializeResult, with the exception that during the initialize request the server is allowed to send the notifications window/showMessage, - window/logMessage and telemetry/event as well as the window/showMessageRequest request to the client. - - The initialize request may only be sent once. - - :param int processId: The process Id of the parent process that started the server. Is null if the process has not been started by another process. - If the parent process is not alive then the server should exit (see exit notification) its process. - :param str rootPath: The rootPath of the workspace. Is null if no folder is open. Deprecated in favour of rootUri. - :param DocumentUri rootUri: The rootUri of the workspace. Is null if no folder is open. If both `rootPath` and `rootUri` are set - `rootUri` wins. - :param any initializationOptions: User provided initialization options. - :param ClientCapabilities capabilities: The capabilities provided by the client (editor or tool). - :param Trace trace: The initial trace setting. If omitted trace is disabled ('off'). - :param list workspaceFolders: The workspace folders configured in the client when the server starts. This property is only available if the client supports workspace folders. - It can be `null` if the client supports workspace folders but none are configured. - """ - self.lsp_endpoint.start() - return self.lsp_endpoint.call_method("initialize", processId=processId, rootPath=rootPath, rootUri=rootUri, initializationOptions=initializationOptions, capabilities=capabilities, trace=trace, workspaceFolders=workspaceFolders) - - - def initialized(self): - """ - The initialized notification is sent from the client to the server after the client received the result of the initialize request - but before the client is sending any other request or notification to the server. The server can use the initialized notification - for example to dynamically register capabilities. The initialized notification may only be sent once. - """ - self.lsp_endpoint.send_notification("initialized") - - - def shutdown(self): - """ - The initialized notification is sent from the client to the server after the client received the result of the initialize request - but before the client is sending any other request or notification to the server. The server can use the initialized notification - for example to dynamically register capabilities. The initialized notification may only be sent once. - """ - self.lsp_endpoint.stop() - return self.lsp_endpoint.call_method("shutdown") - - - def exit(self): - """ - The initialized notification is sent from the client to the server after the client received the result of the initialize request - but before the client is sending any other request or notification to the server. The server can use the initialized notification - for example to dynamically register capabilities. The initialized notification may only be sent once. - """ - self.lsp_endpoint.send_notification("exit") - - - def didOpen(self, textDocument): - """ - The document open notification is sent from the client to the server to signal newly opened text documents. The document's truth is - now managed by the client and the server must not try to read the document's truth using the document's uri. Open in this sense - means it is managed by the client. It doesn't necessarily mean that its content is presented in an editor. An open notification must - not be sent more than once without a corresponding close notification send before. This means open and close notification must be - balanced and the max open count for a particular textDocument is one. Note that a server's ability to fulfill requests is independent - of whether a text document is open or closed. - - The DidOpenTextDocumentParams contain the language id the document is associated with. If the language Id of a document changes, the - client needs to send a textDocument/didClose to the server followed by a textDocument/didOpen with the new language id if the server - handles the new language id as well. - - :param TextDocumentItem textDocument: The document that was opened. - """ - return self.lsp_endpoint.send_notification("textDocument/didOpen", textDocument=textDocument) - - - def didChange(self, textDocument, contentChanges): - """ - The document change notification is sent from the client to the server to signal changes to a text document. - In 2.0 the shape of the params has changed to include proper version numbers and language ids. - - :param VersionedTextDocumentIdentifier textDocument: The initial trace setting. If omitted trace is disabled ('off'). - :param TextDocumentContentChangeEvent[] contentChanges: The actual content changes. The content changes describe single state changes - to the document. So if there are two content changes c1 and c2 for a document in state S then c1 move the document - to S' and c2 to S''. - """ - return self.lsp_endpoint.send_notification("textDocument/didChange", textDocument=textDocument, contentChanges=contentChanges) - - - def documentSymbol(self, textDocument): - """ - The document symbol request is sent from the client to the server to return a flat list of all symbols found in a given text document. - Neither the symbol's location range nor the symbol's container name should be used to infer a hierarchy. - - :param TextDocumentItem textDocument: The text document. - """ - result_dict = self.lsp_endpoint.call_method("textDocument/documentSymbol", textDocument=textDocument) - return [lsp_structs.SymbolInformation(**sym) for sym in result_dict] - - - def definition(self, textDocument, position): - """ - The goto definition request is sent from the client to the server to resolve the definition location of a symbol at a given text document position. - - :param TextDocumentItem textDocument: The text document. - :param Position position: The position inside the text document. - """ - result_dict = self.lsp_endpoint.call_method("textDocument/definition", textDocument=textDocument, position=position) - return [lsp_structs.Location(**l) for l in result_dict] - - - def typeDefinition(self, textDocument, position): - """ - The goto type definition request is sent from the client to the server to resolve the type definition location of a symbol at a given text document position. - - :param TextDocumentItem textDocument: The text document. - :param Position position: The position inside the text document. - """ - result_dict = self.lsp_endpoint.call_method("textDocument/definition", textDocument=textDocument, position=position) - return [lsp_structs.Location(**l) for l in result_dict] - - - def signatureHelp(self, textDocument, position): - """ - The signature help request is sent from the client to the server to request signature information at a given cursor position. - - :param TextDocumentItem textDocument: The text document. - :param Position position: The position inside the text document. - """ - result_dict = self.lsp_endpoint.call_method("textDocument/signatureHelp", textDocument=textDocument, position=position) - return lsp_structs.SignatureHelp(**result_dict) - - - def completion(self, textDocument, position, context): - """ - The signature help request is sent from the client to the server to request signature information at a given cursor position. - - :param TextDocumentItem textDocument: The text document. - :param Position position: The position inside the text document. - :param CompletionContext context: The completion context. This is only available if the client specifies - to send this using `ClientCapabilities.textDocument.completion.contextSupport === true` - """ - result_dict = self.lsp_endpoint.call_method("textDocument/completion", textDocument=textDocument, position=position, context=context) - if "isIncomplete" in result_dict: - return lsp_structs.CompletionList(**result_dict) - - return [lsp_structs.CompletionItem(**l) for l in result_dict] - - - def declaration(self, textDocument, position): - """ - The go to declaration request is sent from the client to the server to resolve the declaration location of a - symbol at a given text document position. - - The result type LocationLink[] got introduce with version 3.14.0 and depends in the corresponding client - capability `clientCapabilities.textDocument.declaration.linkSupport`. - - :param TextDocumentItem textDocument: The text document. - :param Position position: The position inside the text document. - """ - result_dict = self.lsp_endpoint.call_method("textDocument/declaration", textDocument=textDocument, position=position) - if "uri" in result_dict: - return lsp_structs.Location(**result_dict) - - return [lsp_structs.Location(**l) if "uri" in l else lsp_structs.LinkLocation(**l) for l in result_dict] - - - def definition(self, textDocument, position): - """ - The go to definition request is sent from the client to the server to resolve the declaration location of a - symbol at a given text document position. - - The result type LocationLink[] got introduce with version 3.14.0 and depends in the corresponding client - capability `clientCapabilities.textDocument.declaration.linkSupport`. - - :param TextDocumentItem textDocument: The text document. - :param Position position: The position inside the text document. - """ - result_dict = self.lsp_endpoint.call_method("textDocument/definition", textDocument=textDocument, position=position) - if "uri" in result_dict: - return lsp_structs.Location(**result_dict) - - return [lsp_structs.Location(**l) if "uri" in l else lsp_structs.LinkLocation(**l) for l in result_dict] diff --git a/clangd_tidy/pylspclient/lsp_endpoint.py b/clangd_tidy/pylspclient/lsp_endpoint.py deleted file mode 100644 index b4ad6ec..0000000 --- a/clangd_tidy/pylspclient/lsp_endpoint.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import print_function -import threading -import collections - -from . import lsp_structs - - -class LspEndpoint(threading.Thread): - def __init__(self, json_rpc_endpoint, method_callbacks={}, notify_callbacks={}, timeout=2): - threading.Thread.__init__(self) - self.json_rpc_endpoint = json_rpc_endpoint - self.notify_callbacks = notify_callbacks - self.method_callbacks = method_callbacks - self.event_dict = {} - self.response_dict = {} - self.next_id = 0 - self._timeout = timeout - self.shutdown_flag = False - - - def handle_result(self, rpc_id, result, error): - self.response_dict[rpc_id] = (result, error) - cond = self.event_dict[rpc_id] - cond.acquire() - cond.notify() - cond.release() - - - def stop(self): - self.shutdown_flag = True - - - def run(self): - while not self.shutdown_flag: - try: - jsonrpc_message = self.json_rpc_endpoint.recv_response() - if jsonrpc_message is None: - print("server quit") - break - method = jsonrpc_message.get("method") - result = jsonrpc_message.get("result") - error = jsonrpc_message.get("error") - rpc_id = jsonrpc_message.get("id") - params = jsonrpc_message.get("params") - - if method: - if rpc_id: - # a call for method - if method not in self.method_callbacks: - raise lsp_structs.ResponseError(lsp_structs.ErrorCodes.MethodNotFound, "Method not found: {method}".format(method=method)) - result = self.method_callbacks[method](params) - self.send_response(rpc_id, result, None) - else: - # a call for notify - if method not in self.notify_callbacks: - # Have nothing to do with this. - print("Notify method not found: {method}.".format(method=method)) - else: - self.notify_callbacks[method](params) - else: - self.handle_result(rpc_id, result, error) - except lsp_structs.ResponseError as e: - self.send_response(rpc_id, None, e) - - - def send_response(self, id, result, error): - message_dict = {} - message_dict["jsonrpc"] = "2.0" - message_dict["id"] = id - if result: - message_dict["result"] = result - if error: - message_dict["error"] = error - self.json_rpc_endpoint.send_request(message_dict) - - - def send_message(self, method_name, params, id = None): - message_dict = {} - message_dict["jsonrpc"] = "2.0" - if id is not None: - message_dict["id"] = id - message_dict["method"] = method_name - message_dict["params"] = params - self.json_rpc_endpoint.send_request(message_dict) - - - def call_method(self, method_name, **kwargs): - current_id = self.next_id - self.next_id += 1 - cond = threading.Condition() - self.event_dict[current_id] = cond - - cond.acquire() - self.send_message(method_name, kwargs, current_id) - if self.shutdown_flag: - cond.release() - return None - - if not cond.wait(timeout=self._timeout): - raise TimeoutError() - cond.release() - - self.event_dict.pop(current_id) - result, error = self.response_dict.pop(current_id) - if error: - raise lsp_structs.ResponseError(error.get("code"), error.get("message"), error.get("data")) - return result - - - def send_notification(self, method_name, **kwargs): - self.send_message(method_name, kwargs) diff --git a/clangd_tidy/pylspclient/lsp_structs.py b/clangd_tidy/pylspclient/lsp_structs.py deleted file mode 100644 index 2681e96..0000000 --- a/clangd_tidy/pylspclient/lsp_structs.py +++ /dev/null @@ -1,547 +0,0 @@ -import enum - - -def to_type(o, new_type): - ''' - Helper funciton that receives an object or a dict and convert it to a new given type. - - :param object|dict o: The object to convert - :param Type new_type: The type to convert to. - ''' - if new_type == type(o): - return o - else: - return new_type(**o) - - -class Position(object): - def __init__(self, line, character): - """ - Constructs a new Position instance. - - :param int line: Line position in a document (zero-based). - :param int character: Character offset on a line in a document (zero-based). - """ - self.line = line - self.character = character - - -class Range(object): - def __init__(self, start, end): - """ - Constructs a new Range instance. - - :param Position start: The range's start position. - :param Position end: The range's end position. - """ - self.start = to_type(start, Position) - self.end = to_type(end, Position) - - -class Location(object): - """ - Represents a location inside a resource, such as a line inside a text file. - """ - def __init__(self, uri, range): - """ - Constructs a new Location instance. - - :param str uri: Resource file. - :param Range range: The range inside the file - """ - self.uri = uri - self.range = to_type(range, Range) - - -class LocationLink(object): - """ - Represents a link between a source and a target location. - """ - def __init__(self, originSelectionRange, targetUri, targetRange, targetSelectionRange): - """ - Constructs a new LocationLink instance. - - :param Range originSelectionRange: Span of the origin of this link. - Used as the underlined span for mouse interaction. Defaults to the word range at the mouse position. - :param str targetUri: The target resource identifier of this link. - :param Range targetRange: The full target range of this link. If the target for example is a symbol then target - range is the range enclosing this symbol not including leading/trailing whitespace but everything else - like comments. This information is typically used to highlight the range in the editor. - :param Range targetSelectionRange: The range that should be selected and revealed when this link is being followed, - e.g the name of a function. Must be contained by the the `targetRange`. See also `DocumentSymbol#range` - """ - self.originSelectionRange = to_type(originSelectionRange, Range) - self.targetUri = targetUri - self.targetRange = to_type(targetRange, Range) - self.targetSelectionRange = to_type(targetSelectionRange, Range) - - -class Diagnostic(object): - def __init__(self, range, severity, code, source, message, relatedInformation): - """ - Constructs a new Diagnostic instance. - :param Range range: The range at which the message applies.Resource file. - :param int severity: The diagnostic's severity. Can be omitted. If omitted it is up to the - client to interpret diagnostics as error, warning, info or hint. - :param str code: The diagnostic's code, which might appear in the user interface. - :param str source: A human-readable string describing the source of this - diagnostic, e.g. 'typescript' or 'super lint'. - :param str message: The diagnostic's message. - :param list relatedInformation: An array of related diagnostic information, e.g. when symbol-names within - a scope collide all definitions can be marked via this property. - """ - self.range = range - self.severity = severity - self.code = code - self.source = source - self.message = message - self.relatedInformation = relatedInformation - - -class DiagnosticSeverity(object): - Error = 1 - Warning = 2 # TODO: warning is known in python - Information = 3 - Hint = 4 - - -class DiagnosticRelatedInformation(object): - def __init__(self, location, message): - """ - Constructs a new Diagnostic instance. - :param Location location: The location of this related diagnostic information. - :param str message: The message of this related diagnostic information. - """ - self.location = location - self.message = message - - -class Command(object): - def __init__(self, title, command, arguments): - """ - Constructs a new Diagnostic instance. - :param str title: Title of the command, like `save`. - :param str command: The identifier of the actual command handler. - :param list argusments: Arguments that the command handler should be invoked with. - """ - self.title = title - self.command = command - self.arguments = arguments - - -class TextDocumentItem(object): - """ - An item to transfer a text document from the client to the server. - """ - def __init__(self, uri, languageId, version, text): - """ - Constructs a new Diagnostic instance. - - :param DocumentUri uri: Title of the command, like `save`. - :param str languageId: The identifier of the actual command handler. - :param int version: Arguments that the command handler should be invoked with. - :param str text: Arguments that the command handler should be invoked with. - """ - self.uri = uri - self.languageId = languageId - self.version = version - self.text = text - - -class TextDocumentIdentifier(object): - """ - Text documents are identified using a URI. On the protocol level, URIs are passed as strings. - """ - def __init__(self, uri): - """ - Constructs a new TextDocumentIdentifier instance. - - :param DocumentUri uri: The text document's URI. - """ - self.uri = uri - - -class VersionedTextDocumentIdentifier(TextDocumentIdentifier): - """ - An identifier to denote a specific version of a text document. - """ - def __init__(self, uri, version): - """ - Constructs a new TextDocumentIdentifier instance. - - :param DocumentUri uri: The text document's URI. - :param int version: The version number of this document. If a versioned - text document identifier is sent from the server to the client and - the file is not open in the editor (the server has not received an - open notification before) the server can send `null` to indicate - that the version is known and the content on disk is the truth (as - speced with document content ownership). - The version number of a document will increase after each change, including - undo/redo. The number doesn't need to be consecutive. - """ - super(VersionedTextDocumentIdentifier, self).__init__(uri) - self.version = version - - -class TextDocumentContentChangeEvent(object): - """ - An event describing a change to a text document. If range and rangeLength are omitted - the new text is considered to be the full content of the document. - """ - def __init__(self, range, rangeLength, text): - """ - Constructs a new TextDocumentContentChangeEvent instance. - - :param Range range: The range of the document that changed. - :param int rangeLength: The length of the range that got replaced. - :param str text: The new text of the range/document. - """ - self.range = range - self.rangeLength = rangeLength - self.text = text - - -class TextDocumentPositionParams(object): - """ - A parameter literal used in requests to pass a text document and a position inside that document. - """ - def __init__(self, textDocument, position): - """ - Constructs a new TextDocumentPositionParams instance. - - :param TextDocumentIdentifier textDocument: The text document. - :param Position position: The position inside the text document. - """ - self.textDocument = textDocument - self.position = position - - -class LANGUAGE_IDENTIFIER(object): - BAT="bat" - BIBTEX="bibtex" - CLOJURE="clojure" - COFFESCRIPT="coffeescript" - C="c" - CPP="cpp" - CSHARP="csharp" - CSS="css" - DIFF="diff" - DOCKERFILE="dockerfile" - FSHARP="fsharp" - GIT_COMMIT="git-commit" - GIT_REBASE="git-rebase" - GO="go" - GROOVY="groovy" - HANDLEBARS="handlebars" - HTML="html" - INI="ini" - JAVA="java" - JAVASCRIPT="javascript" - JSON="json" - LATEX="latex" - LESS="less" - LUA="lua" - MAKEFILE="makefile" - MARKDOWN="markdown" - OBJECTIVE_C="objective-c" - OBJECTIVE_CPP="objective-cpp" - Perl="perl" - PHP="php" - POWERSHELL="powershell" - PUG="jade" - PYTHON="python" - R="r" - RAZOR="razor" - RUBY="ruby" - RUST="rust" - SASS="sass" - SCSS="scss" - ShaderLab="shaderlab" - SHELL_SCRIPT="shellscript" - SQL="sql" - SWIFT="swift" - TYPE_SCRIPT="typescript" - TEX="tex" - VB="vb" - XML="xml" - XSL="xsl" - YAML="yaml" - - -class SymbolKind(enum.Enum): - File = 1 - Module = 2 - Namespace = 3 - Package = 4 - Class = 5 - Method = 6 - Property = 7 - Field = 8 - Constructor = 9 - Enum = 10 - Interface = 11 - Function = 12 - Variable = 13 - Constant = 14 - String = 15 - Number = 16 - Boolean = 17 - Array = 18 - Object = 19 - Key = 20 - Null = 21 - EnumMember = 22 - Struct = 23 - Event = 24 - Operator = 25 - TypeParameter = 26 - - -class SymbolInformation(object): - """ - Represents information about programming constructs like variables, classes, interfaces etc. - """ - def __init__(self, name, kind, location, containerName=None, deprecated=False): - """ - Constructs a new SymbolInformation instance. - - :param str name: The name of this symbol. - :param int kind: The kind of this symbol. - :param bool Location: The location of this symbol. The location's range is used by a tool - to reveal the location in the editor. If the symbol is selected in the - tool the range's start information is used to position the cursor. So - the range usually spans more then the actual symbol's name and does - normally include things like visibility modifiers. - - The range doesn't have to denote a node range in the sense of a abstract - syntax tree. It can therefore not be used to re-construct a hierarchy of - the symbols. - :param str containerName: The name of the symbol containing this symbol. This information is for - user interface purposes (e.g. to render a qualifier in the user interface - if necessary). It can't be used to re-infer a hierarchy for the document - symbols. - :param bool deprecated: Indicates if this symbol is deprecated. - """ - self.name = name - self.kind = SymbolKind(kind) - self.deprecated = deprecated - self.location = to_type(location, Location) - self.containerName = containerName - - -class ParameterInformation(object): - """ - Represents a parameter of a callable-signature. A parameter can - have a label and a doc-comment. - """ - def __init__(self, label, documentation=""): - """ - Constructs a new ParameterInformation instance. - - :param str label: The label of this parameter. Will be shown in the UI. - :param str documentation: The human-readable doc-comment of this parameter. Will be shown in the UI but can be omitted. - """ - self.label = label - self.documentation = documentation - - -class SignatureInformation(object): - """ - Represents the signature of something callable. A signature - can have a label, like a function-name, a doc-comment, and - a set of parameters. - """ - def __init__(self, label, documentation="", parameters=[]): - """ - Constructs a new SignatureInformation instance. - - :param str label: The label of this signature. Will be shown in the UI. - :param str documentation: The human-readable doc-comment of this signature. Will be shown in the UI but can be omitted. - :param ParameterInformation[] parameters: The parameters of this signature. - """ - self.label = label - self.documentation = documentation - self.parameters = [to_type(parameter, ParameterInformation) for parameter in parameters] - - -class SignatureHelp(object): - """ - Signature help represents the signature of something - callable. There can be multiple signature but only one - active and only one active parameter. - """ - def __init__(self, signatures, activeSignature=0, activeParameter=0): - """ - Constructs a new SignatureHelp instance. - - :param SignatureInformation[] signatures: One or more signatures. - :param int activeSignature: - :param int activeParameter: - """ - self.signatures = [to_type(signature, SignatureInformation) for signature in signatures] - self.activeSignature = activeSignature - self.activeParameter = activeParameter - - -class CompletionTriggerKind(object): - Invoked = 1 - TriggerCharacter = 2 - TriggerForIncompleteCompletions = 3 - - -class CompletionContext(object): - """ - Contains additional information about the context in which a completion request is triggered. - """ - def __init__(self, triggerKind, triggerCharacter=None): - """ - Constructs a new CompletionContext instance. - - :param CompletionTriggerKind triggerKind: How the completion was triggered. - :param str triggerCharacter: The trigger character (a single character) that has trigger code complete. - Is undefined if `triggerKind !== CompletionTriggerKind.TriggerCharacter` - """ - self.triggerKind = triggerKind - if triggerCharacter: - self.triggerCharacter = triggerCharacter - - -class TextEdit(object): - """ - A textual edit applicable to a text document. - """ - def __init__(self, range, newText): - """ - :param Range range: The range of the text document to be manipulated. To insert - text into a document create a range where start === end. - :param str newText: The string to be inserted. For delete operations use an empty string. - """ - self.range = range - self.newText = newText - - -class InsertTextFormat(object): - PlainText = 1 - Snippet = 2 - - -class CompletionItem(object): - """ - """ - def __init__(self, label, kind=None, detail=None, documentation=None, deprecated=None, presented=None, sortText=None, filterText=None, insertText=None, insertTextFormat=None, textEdit=None, additionalTextEdits=None, commitCharacters=None, command=None, data=None, score=0.0): - """ - :param str label: The label of this completion item. By default also the text that is inserted when selecting - this completion. - :param int kind: The kind of this completion item. Based of the kind an icon is chosen by the editor. - :param str detail: A human-readable string with additional information about this item, like type or symbol information. - :param tr ocumentation: A human-readable string that represents a doc-comment. - :param bool deprecated: Indicates if this item is deprecated. - :param bool presented: Select this item when showing. Note: that only one completion item can be selected and that the - tool / client decides which item that is. The rule is that the first item of those that match best is selected. - :param str sortText: A string that should be used when comparing this item with other items. When `falsy` the label is used. - :param str filterText: A string that should be used when filtering a set of completion items. When `falsy` the label is used. - :param str insertText: A string that should be inserted into a document when selecting this completion. When `falsy` the label is used. - The `insertText` is subject to interpretation by the client side. Some tools might not take the string literally. For example - VS Code when code complete is requested in this example `con` and a completion item with an `insertText` of `console` is provided it - will only insert `sole`. Therefore it is recommended to use `textEdit` instead since it avoids additional client side interpretation. - @deprecated Use textEdit instead. - :param InsertTextFormat insertTextFormat: The format of the insert text. The format applies to both the `insertText` property - and the `newText` property of a provided `textEdit`. - :param TextEdit textEdit: An edit which is applied to a document when selecting this completion. When an edit is provided the value of `insertText` is ignored. - Note:* The range of the edit must be a single line range and it must contain the position at which completion - has been requested. - :param TextEdit additionalTextEdits: An optional array of additional text edits that are applied when selecting this completion. - Edits must not overlap (including the same insert position) with the main edit nor with themselves. - Additional text edits should be used to change text unrelated to the current cursor position - (for example adding an import statement at the top of the file if the completion item will - insert an unqualified type). - :param str commitCharacters: An optional set of characters that when pressed while this completion is active will accept it first and - then type that character. *Note* that all commit characters should have `length=1` and that superfluous - characters will be ignored. - :param Command command: An optional command that is executed *after* inserting this completion. Note: that - additional modifications to the current document should be described with the additionalTextEdits-property. - :param data: An data entry field that is preserved on a completion item between a completion and a completion resolve request. - :param float score: Score of the code completion item. - """ - self.label = label - self.kind = kind - self.detail = detail - self.documentation = documentation - self.deprecated = deprecated - self.presented = presented - self.sortText = sortText - self.filterText = filterText - self.insertText = insertText - self.insertTextFormat = insertTextFormat - self.textEdit = textEdit - self.additionalTextEdits = additionalTextEdits - self.commitCharacters = commitCharacters - self.command = command - self.data = data - self.score = score - - -class CompletionItemKind(enum.Enum): - Text = 1 - Method = 2 - Function = 3 - Constructor = 4 - Field = 5 - Variable = 6 - Class = 7 - Interface = 8 - Module = 9 - Property = 10 - Unit = 11 - Value = 12 - Enum = 13 - Keyword = 14 - Snippet = 15 - Color = 16 - File = 17 - Reference = 18 - Folder = 19 - EnumMember = 20 - Constant = 21 - Struct = 22 - Event = 23 - Operator = 24 - TypeParameter = 25 - - -class CompletionList(object): - """ - Represents a collection of [completion items](#CompletionItem) to be presented in the editor. - """ - def __init__(self, isIncomplete, items): - """ - Constructs a new CompletionContext instance. - - :param bool isIncomplete: This list it not complete. Further typing should result in recomputing this list. - :param CompletionItem items: The completion items. - """ - self.isIncomplete = isIncomplete - self.items = [to_type(i, CompletionItem) for i in items] - -class ErrorCodes(enum.Enum): - # Defined by JSON RPC - ParseError = -32700 - InvalidRequest = -32600 - MethodNotFound = -32601 - InvalidParams = -32602 - InternalError = -32603 - serverErrorStart = -32099 - serverErrorEnd = -32000 - ServerNotInitialized = -32002 - UnknownErrorCode = -32001 - - # Defined by the protocol. - RequestCancelled = -32800 - ContentModified = -32801 - -class ResponseError(Exception): - def __init__(self, code, message, data = None): - self.code = code - self.message = message - if data: - self.data = data diff --git a/clangd_tidy/version.py b/clangd_tidy/version.py index 44d3c54..e881802 100644 --- a/clangd_tidy/version.py +++ b/clangd_tidy/version.py @@ -4,7 +4,7 @@ try: from setuptools_scm import get_version # type: ignore - __version__ = get_version(root="..", relative_to=__file__) + __version__ = get_version(root="..", relative_to=__file__) # type: ignore except (ImportError, LookupError): __version__ = "UNKNOWN" diff --git a/pyproject.toml b/pyproject.toml index 200fedd..08fa39e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "clangd-tidy" dynamic = ["version"] -dependencies = ["tqdm"] +dependencies = ["attrs", "cattrs", "typing-extensions"] requires-python = ">=3.8" authors = [{ name = "lljbash", email = "lljbash@gmail.com" }] description = "A faster alternative to clang-tidy" @@ -23,6 +23,7 @@ classifiers = [ [project.scripts] clangd-tidy = "clangd_tidy:main_cli" +clangd-tidy-diff = "clangd_tidy:clang_tidy_diff" [project.urls] "Homepage" = "https://github.com/lljbash/clangd-tidy" @@ -33,5 +34,15 @@ write_to = "clangd_tidy/_dist_ver.py" [tool.black] include = '\.pyi?$' -extend-exclude = 'pylspclient' -required-version = "24" +required-version = "25" + +[tool.basedpyright] +include = ["clangd_tidy"] +pythonVersion = "3.8" +pythonPlatform = "Linux" +typeCheckingMode = "strict" + +[dependency-groups] +dev = [ + "basedpyright" +] diff --git a/test/.clang-format b/test/.clang-format new file mode 100644 index 0000000..9bfc990 --- /dev/null +++ b/test/.clang-format @@ -0,0 +1,7 @@ +--- +Language: Cpp +BasedOnStyle: Google +BreakAfterAttributes: Leave +CommentPragmas: '^ (IWYU pragma:|NOLINT(BEGIN|END|NEXTLINE)?(\(.+\))?:? )' +DerivePointerAlignment: false +InsertNewlineAtEOF: true diff --git a/test/.clang-tidy b/test/.clang-tidy new file mode 100644 index 0000000..7e66685 --- /dev/null +++ b/test/.clang-tidy @@ -0,0 +1,87 @@ +--- +Checks: ' + bugprone-*, + -bugprone-easily-swappable-parameters, + clang-analyzer-*, + clang-diagnostic-*, + cppcoreguidelines-*, + google-*, + -google-*googletest*, + hicpp-avoid-goto, + hicpp-exception-baseclass, + misc-header-include-cycle, + misc-static-assert, + misc-unused-alias-decls, + misc-unused-using-decls, + modernize-*, + performance-*, + readability-*, + -readability-qualified-auto, + -readability-static-accessed-through-instance' +# AnalyzeTemporaryDtors: false +FormatStyle: file +HeaderFilterRegex: '.*' +CheckOptions: + - key: cppcoreguidelines-avoid-do-while.IgnoreMacros + value: true + - key: cppcoreguidelines-narrowing-conversions.IgnoreConversionFromTypes + value: 'size_t;ptrdiff_t;size_type;difference_type' + - key: readability-function-cognitive-complexity.IgnoreMacros + value: true + - key: readability-identifier-length.MinimumVariableNameLength + value: 2 + - key: readability-identifier-length.MinimumParameterNameLength + value: 2 +# --- Google's naming convention BEGIN --- +# modified part is marked as comment + - key: readability-identifier-naming.ClassCase + value: CamelCase + - key: readability-identifier-naming.ClassMemberCase + value: lower_case + - key: readability-identifier-naming.ConstexprVariableCase + value: CamelCase + - key: readability-identifier-naming.ConstexprVariablePrefix + value: k + - key: readability-identifier-naming.EnumCase + value: CamelCase + - key: readability-identifier-naming.EnumConstantCase + value: CamelCase + - key: readability-identifier-naming.EnumConstantPrefix + value: k + - key: readability-identifier-naming.FunctionCase + # value: CamelCase + value: lower_case + - key: readability-identifier-naming.GlobalConstantCase + value: CamelCase + - key: readability-identifier-naming.GlobalConstantPrefix + value: k + - key: readability-identifier-naming.StaticConstantCase + value: CamelCase + - key: readability-identifier-naming.StaticConstantPrefix + value: k + - key: readability-identifier-naming.StaticVariableCase + value: lower_case + - key: readability-identifier-naming.MacroDefinitionCase + value: UPPER_CASE + - key: readability-identifier-naming.MacroDefinitionIgnoredRegexp + value: '^[A-Z]+(_[A-Z]+)*_$' + - key: readability-identifier-naming.MemberCase + value: lower_case + - key: readability-identifier-naming.PrivateMemberSuffix + value: _ + - key: readability-identifier-naming.PublicMemberSuffix + value: '' + - key: readability-identifier-naming.NamespaceCase + value: lower_case + - key: readability-identifier-naming.ParameterCase + value: lower_case + - key: readability-identifier-naming.TypeAliasCase + value: CamelCase + - key: readability-identifier-naming.TypedefCase + value: CamelCase + - key: readability-identifier-naming.VariableCase + value: lower_case + - key: readability-identifier-naming.IgnoreMainLikeFunctions + value: 1 +# --- Google's naming convention END --- +... diff --git a/test/a.cpp b/test/a.cpp new file mode 100644 index 0000000..3100d4a --- /dev/null +++ b/test/a.cpp @@ -0,0 +1,6 @@ +#include + +class AA { + public: + public: +}; diff --git a/test/b.cpp b/test/b.cpp new file mode 100644 index 0000000..cae2ba1 --- /dev/null +++ b/test/b.cpp @@ -0,0 +1,2 @@ +int +main() { int a; int b; c=1 } diff --git a/test/c.cpp b/test/c.cpp new file mode 100644 index 0000000..e69de29 diff --git a/test/d.cpp b/test/d.cpp new file mode 100644 index 0000000..bc8d8f4 --- /dev/null +++ b/test/d.cpp @@ -0,0 +1,2 @@ +auto +f() -> int;