From 4cbc11f85a2b07317181709f9c1cae6da60c683b Mon Sep 17 00:00:00 2001 From: Lingjie Date: Mon, 29 Apr 2024 11:44:11 +0800 Subject: [PATCH] feat: prettiers the output to be more inline with clang-tidy (#2) Based on #1, originally draft by @ArchieAtkinson close #1 --------- Co-authored-by: Archie Atkinson --- README.md | 50 +++++--- clangd-tidy | 119 +++++++++---------- diagnostic_formatter.py | 250 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 335 insertions(+), 84 deletions(-) create mode 100644 diagnostic_formatter.py diff --git a/README.md b/README.md index 9d15685..7d5e81d 100644 --- a/README.md +++ b/README.md @@ -11,29 +11,31 @@ Unfortunately, there seems to be no plan within LLVM to accelerate the standalon ## Comparison with clang-tidy **Pros:** + - 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 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 - CompileFlags: - Remove: -fabi* - ``` - - Example: Adding IWYU include checks. - ```yaml - Diagnostics: - # Available in clangd-14 - UnusedIncludes: Strict - # Require clangd-17 - MissingIncludes: Strict - ``` + - Example: Removing unknown compiler flags from the compilation database. + ```yaml + CompileFlags: + Remove: -fabi* + ``` + - Example: Adding IWYU include checks. + ```yaml + Diagnostics: + # Available in clangd-14 + UnusedIncludes: Strict + # Require clangd-17 + MissingIncludes: Strict + ``` - 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. -- Diagnostics generated by clangd-tidy are less aesthetically pleasing than clang-tidy. +- Diagnostics generated by clangd-tidy might be marginally less aesthetically pleasing compared to clang-tidy. ## Prerequisites @@ -47,7 +49,9 @@ Unfortunately, there seems to be no plan within LLVM to accelerate the standalon 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] [-v] + [--fail-on-severity SEVERITY] [--tqdm] [--github] + [--git-root GIT_ROOT] [-c] [--context CONTEXT] + [--color {auto,always,never}] [-v] filename [filename ...] Run clangd with clang-tidy and output diagnostics. This aims to serve as a @@ -59,6 +63,7 @@ positional arguments: 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 @@ -77,11 +82,18 @@ options: On which severity of diagnostics this program should exit with a non-zero status. Candidates: error, warn, info, hint. [default: hint] - --tqdm Show a progress bar (require tqdm). + --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] - -v, --verbose Print verbose output from clangd. + -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 + --compact. [default: 2] + --color {auto,always,never} + Colorize the output. This option is ineffective with + --compact. [default: auto] + -v, --verbose Show verbose output from clangd. Find more information on https://github.com/lljbash/clangd-tidy. ``` @@ -91,3 +103,7 @@ Find more information on https://github.com/lljbash/clangd-tidy. Special thanks to [@yeger00](https://github.com/yeger00) for his [pylspclient](https://github.com/yeger00/pylspclient). 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. + +Contributions are welcome! Feel free to open an issue or a pull request. diff --git a/clangd-tidy b/clangd-tidy index f67a939..74caa99 100755 --- a/clangd-tidy +++ b/clangd-tidy @@ -8,11 +8,14 @@ import sys import threading from typing import IO, Set, TextIO +import diagnostic_formatter 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 +__version__ = "0.2.0" + class ReadPipe(threading.Thread): def __init__(self, pipe: IO[bytes], out: TextIO): @@ -58,25 +61,17 @@ def _uri_file(uri: str): return uri[7:] +def _is_output_supports_color(output: TextIO): + return hasattr(output, "isatty") and output.isatty() + + class DiagnosticCollector: - SEVERITY = { - 1: "Error", - 2: "Warning", - 3: "Information", - 4: "Hint", - } SEVERITY_INT = { "error": 1, "warn": 2, "info": 3, "hint": 4, } - SEVERITY_GITHUB = { - 1: "error", - 2: "warning", - 3: "notice", - 4: "notice", - } def __init__(self): self.diagnostics = {} @@ -110,56 +105,10 @@ class DiagnosticCollector: return True return False - def fancy_diagnostics(self) -> str: - fancy_output = "" - for file, diagnostics in sorted(self.diagnostics.items()): - if len(diagnostics) == 0: - continue - fancy_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 - fancy_output += f"- line {line}, col {col}:{extra_info}\n{message}\n\n" - fancy_output += "\n" - return fancy_output - - def workflow_commands_for_github_actions(self, git_root: str) -> str: - commands = "::group::{workflow commands}\n" - for file, diagnostics in sorted(self.diagnostics.items()): - 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, 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_diagnostics( + self, formatter: diagnostic_formatter.DiagnosticFormatter + ) -> str: + return formatter.format(sorted(self.diagnostics.items())).rstrip() if __name__ == "__main__": @@ -181,6 +130,9 @@ if __name__ == "__main__": 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", @@ -219,7 +171,7 @@ if __name__ == "__main__": 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 (require tqdm)." + "--tqdm", action="store_true", help="Show a progress bar (tqdm required)." ) parser.add_argument( "--github", @@ -232,7 +184,25 @@ if __name__ == "__main__": help="Root directory of the git repository. Only works with --github. [default: current directory]", ) parser.add_argument( - "-v", "--verbose", action="store_true", help="Print verbose output from clangd." + "-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", @@ -328,11 +298,26 @@ if __name__ == "__main__": if read_pipe.is_alive(): read_pipe.join() - diagnostics = collector.fancy_diagnostics().strip() - print(diagnostics, file=args.output) + formatter = ( + diagnostic_formatter.FancyDiagnosticFormatter( + extra_context=args.context, + enable_color=( + _is_output_supports_color(args.output) + if args.color == "auto" + else args.color == "always" + ), + ) + if not args.compact + else diagnostic_formatter.CompactDiagnosticFormatter() + ) + print(collector.format_diagnostics(formatter), file=args.output) if args.github: print( - collector.workflow_commands_for_github_actions(args.git_root).strip(), + collector.format_diagnostics( + diagnostic_formatter.GithubActionWorkflowCommandDiagnosticFormatter( + args.git_root + ) + ), file=args.output, ) if collector.check_failed(args.fail_on_severity): diff --git a/diagnostic_formatter.py b/diagnostic_formatter.py new file mode 100644 index 0000000..6205158 --- /dev/null +++ b/diagnostic_formatter.py @@ -0,0 +1,250 @@ +from abc import ABC, abstractmethod +import os +import re +from typing import Any, Iterable, List, Optional, Tuple, TypeAlias + + +DiagnosticCollection: TypeAlias = Iterable[Tuple[str, Any]] + + +class DiagnosticFormatter(ABC): + SEVERITY = { + 1: "Error", + 2: "Warning", + 3: "Information", + 4: "Hint", + } + + @abstractmethod + def format(self, diagnostic_collection: DiagnosticCollection) -> 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 + + +class GithubActionWorkflowCommandDiagnosticFormatter(DiagnosticFormatter): + SEVERITY_GITHUB = { + 1: "error", + 2: "warning", + 3: "notice", + 4: "notice", + } + + 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 + + +class FancyDiagnosticFormatter(DiagnosticFormatter): + class Colorizer: + class ColorSeqTty: + ERROR = "\033[91m" + WARNING = "\033[93m" + INFO = "\033[96m" + HINT = "\033[94m" + NOTE = "\033[90m" + GREEN = "\033[92m" + BOLD = "\033[1m" + ENDC = "\033[0m" + + class ColorSeqNoTty: + ERROR = "" + WARNING = "" + INFO = "" + HINT = "" + NOTE = "" + GREEN = "" + BOLD = "" + ENDC = "" + + def __init__(self, enable_color: bool): + 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}" + if severity == 2: + 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}" + if severity == 4: + 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}" + + def note(self, message: str): + return f"{self.color_seq.NOTE}{message}{self.color_seq.ENDC}" + + def __init__(self, extra_context: int, enable_color: bool): + self._extra_context = extra_context + self._colorizer = self.Colorizer(enable_color) + + def _colorized_severity(self, severity: int): + return self._colorizer.per_severity(severity, self.SEVERITY[severity]) + + @staticmethod + 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" + + def _code_context( + self, + file: str, + line_start: int, + line_end: int, + col_start: int, + col_end: int, + extra_context: Optional[int] = None, + ) -> str: + UNDERLINE = "~" + UNDERLINE_START = "^" + if extra_context is None: + extra_context = self._extra_context + + # get context code + with open(file, "r") as f: + content = f.readlines() + context_start_line = max(0, line_start - extra_context) + context_end_line = min(len(content), line_end + extra_context + 1) + code = content[context_start_line:context_end_line] + + context = "" + for lino, line in enumerate(code, start=context_start_line): + # prepend line numbers + context += self._prepend_line_number(line, lino) + + # add diagnostic indicator line + if lino < line_start or lino > line_end: + continue + line_col_start = ( + col_start if lino == line_start else len(line) - len(line.lstrip()) + ) + line_col_end = col_end if lino == line_end else len(line.rstrip()) + indicator = UNDERLINE_START if lino == line_start else UNDERLINE + indicator = indicator.rjust(line_col_start + 1) + indicator = indicator.ljust(line_col_end, UNDERLINE) + indicator = self._colorizer.highlight(indicator) + context += self._prepend_line_number(indicator, lino=None) + + return context + + @staticmethod + def _diagnostic_message( + file: str, + line_start: int, + col_start: int, + severity: str, + message: str, + code: str, + context: str, + ) -> str: + return f"{file}:{line_start + 1}:{col_start + 1}: {severity}: {message} {code}\n{context}" + + def format(self, diagnostic_collection: DiagnosticCollection) -> str: + fancy_output = "" + + for file, diagnostics in diagnostic_collection: + if len(diagnostics) == 0: + 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" + + return fancy_output