From 2a4de983cad0a91951cf8c5b68c5ef12fdec2717 Mon Sep 17 00:00:00 2001 From: Lingjie Date: Thu, 2 Jan 2025 18:03:57 +0800 Subject: [PATCH] feat: add clang-format check --- clangd_tidy/diagnostic_formatter.py | 38 +++++++------ clangd_tidy/lsp/clangd.py | 11 ++++ clangd_tidy/lsp/client.py | 4 +- clangd_tidy/lsp/messages.py | 21 +++++++- clangd_tidy/main_cli.py | 84 +++++++++++++++++++++-------- test/d.cpp | 2 + 6 files changed, 118 insertions(+), 42 deletions(-) create mode 100644 test/d.cpp diff --git a/clangd_tidy/diagnostic_formatter.py b/clangd_tidy/diagnostic_formatter.py index 5a7e103..8ab90a6 100644 --- a/clangd_tidy/diagnostic_formatter.py +++ b/clangd_tidy/diagnostic_formatter.py @@ -1,14 +1,13 @@ from abc import ABC, abstractmethod -from dataclasses import dataclass import os import pathlib import re -from typing import Iterable, List, Optional +from typing import Dict, Iterable, List, Optional from .lsp.messages import Diagnostic, DiagnosticSeverity __all__ = [ - "FileDiagnostics", + "DiagnosticCollection", "DiagnosticFormatter", "CompactDiagnosticFormatter", "FancyDiagnosticFormatter", @@ -16,13 +15,7 @@ ] -@dataclass -class FileDiagnostics: - file: pathlib.Path - diagnostics: List[Diagnostic] - - -DiagnosticCollection = Iterable[FileDiagnostics] +DiagnosticCollection = Dict[pathlib.Path, List[Diagnostic]] class DiagnosticFormatter(ABC): @@ -35,10 +28,9 @@ class DiagnosticFormatter(ABC): def format(self, diagnostic_collection: DiagnosticCollection) -> str: file_outputs: List[str] = [] - for file_diagnostics in sorted( - diagnostic_collection, key=lambda d: d.file.as_posix() + for file, diagnostics in sorted( + diagnostic_collection.items(), key=lambda fd: fd[0].as_posix() ): - file, diagnostics = file_diagnostics.file, file_diagnostics.diagnostics diagnostic_outputs = [ o for o in [ @@ -143,7 +135,7 @@ def _make_file_output( def _make_whole_output(self, file_outputs: Iterable[str]) -> str: head = "::group::{workflow commands}" tail = "::endgroup::" - return "\n".join([head, *file_outputs, tail]) + return "\n".join(["", head, *file_outputs, tail]) class FancyDiagnosticFormatter(DiagnosticFormatter): @@ -155,6 +147,7 @@ class ColorSeqTty: HINT = "\033[94m" NOTE = "\033[90m" GREEN = "\033[92m" + MAGENTA = "\033[95m" BOLD = "\033[1m" ENDC = "\033[0m" @@ -165,6 +158,7 @@ class ColorSeqNoTty: HINT = "" NOTE = "" GREEN = "" + MAGENTA = "" BOLD = "" ENDC = "" @@ -188,6 +182,9 @@ def highlight(self, message: str): def note(self, message: str): 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 __init__(self, extra_context: int, enable_color: bool): self._extra_context = extra_context self._colorizer = self.Colorizer(enable_color) @@ -241,7 +238,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( @@ -255,10 +252,17 @@ def _diagnostic_message( ) -> str: return f"{file}:{line_start + 1}:{col_start + 1}: {severity}: {message} {code}\n{context}" + 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:] @@ -306,7 +310,7 @@ def _make_file_output( self, file: pathlib.Path, diagnostic_outputs: Iterable[str] ) -> str: del file - return "\n".join(diagnostic_outputs) + return "\n\n".join(diagnostic_outputs) def _make_whole_output(self, file_outputs: Iterable[str]) -> str: - return "\n".join(file_outputs) + return "\n\n".join(file_outputs) diff --git a/clangd_tidy/lsp/clangd.py b/clangd_tidy/lsp/clangd.py index 5ee4200..eca28c6 100644 --- a/clangd_tidy/lsp/clangd.py +++ b/clangd_tidy/lsp/clangd.py @@ -7,11 +7,13 @@ from .client import ClientAsync, RequestResponsePair from .messages import ( DidOpenTextDocumentParams, + DocumentFormattingParams, InitializeParams, LanguageId, LspNotificationMessage, NotificationMethod, RequestMethod, + TextDocumentIdentifier, TextDocumentItem, WorkspaceFolder, ) @@ -83,6 +85,15 @@ async def did_open(self, path: pathlib.Path) -> None: ), ) + 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()) + ), + ) + async def shutdown(self) -> None: await self._client.request(RequestMethod.SHUTDOWN) diff --git a/clangd_tidy/lsp/client.py b/clangd_tidy/lsp/client.py index f634a57..69875c7 100644 --- a/clangd_tidy/lsp/client.py +++ b/clangd_tidy/lsp/client.py @@ -32,7 +32,9 @@ def __init__(self, rpc: RpcEndpointAsync): async def request(self, method: RequestMethod, params: Params = Params()) -> None: id = next(self._id) - message = RequestMessage(id=id, method=method, params=params) + message = RequestMessage( + id=id, method=method, params=cattrs.unstructure(params) + ) self._requests[id] = message await self._rpc.send(cattrs.unstructure(message)) diff --git a/clangd_tidy/lsp/messages.py b/clangd_tidy/lsp/messages.py index 44de850..2da591a 100644 --- a/clangd_tidy/lsp/messages.py +++ b/clangd_tidy/lsp/messages.py @@ -14,6 +14,7 @@ class Message: class RequestMethod(Enum): INITIALIZE = "initialize" SHUTDOWN = "shutdown" + FORMATTING = "textDocument/formatting" @unique @@ -38,7 +39,7 @@ class Params: class RequestMessage(Message): id: int method: RequestMethod - params: Params + params: dict = Factory(dict) @define @@ -51,7 +52,7 @@ class ResponseError: @define(kw_only=True) class ResponseMessage(Message): id: int - result: Optional[dict] = None + result: Any = None error: Optional[ResponseError] = None @@ -134,3 +135,19 @@ 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 = Factory(dict) diff --git a/clangd_tidy/main_cli.py b/clangd_tidy/main_cli.py index f037fba..36036df 100644 --- a/clangd_tidy/main_cli.py +++ b/clangd_tidy/main_cli.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import asyncio -import importlib.util import pathlib import sys from typing import Collection, List, TextIO @@ -13,15 +12,20 @@ from .args import parse_args, SEVERITY_INT from .diagnostic_formatter import ( CompactDiagnosticFormatter, + DiagnosticCollection, FancyDiagnosticFormatter, - FileDiagnostics, GithubActionWorkflowCommandDiagnosticFormatter, ) from .lsp import ClangdAsync, RequestResponsePair from .lsp.messages import ( + Diagnostic, + DocumentFormattingParams, LspNotificationMessage, NotificationMethod, + Position, PublishDiagnosticsParams, + Range, + RequestMethod, ) __all__ = ["main_cli"] @@ -37,42 +41,73 @@ def _is_output_supports_color(output: TextIO) -> bool: class ClangdRunner: def __init__( - self, clangd: ClangdAsync, files: Collection[pathlib.Path], tqdm: bool + self, + clangd: ClangdAsync, + files: Collection[pathlib.Path], + tqdm: bool, + max_pending_requests: int, ): self._clangd = clangd self._files = files self._tqdm = tqdm + self._sem = asyncio.Semaphore(max_pending_requests) - def run(self) -> List[FileDiagnostics]: + def acquire_diagnostics(self) -> DiagnosticCollection: return asyncio.run(self._acquire_diagnostics()) async def _request_diagnostics(self) -> None: - await asyncio.gather(*(self._clangd.did_open(file) for file in self._files)) - - async def _collect_diagnostics(self) -> List[FileDiagnostics]: - file_diagnostics: List[FileDiagnostics] = [] + for file in self._files: + await self._sem.acquire() + await self._clangd.did_open(file) + await self._sem.acquire() + await self._clangd.formatting(file) + + async def _collect_diagnostics(self) -> DiagnosticCollection: + diagnostics: DiagnosticCollection = {} + formatting_diagnostics: DiagnosticCollection = {} with tqdm( total=len(self._files), desc="Collecting diagnostics", disable=not self._tqdm, ) as pbar: - while len(file_diagnostics) < len(self._files): + while len(diagnostics) < len(self._files): 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) - file_diagnostics.append( - FileDiagnostics(file, params.diagnostics) - ) + diagnostics[file] = params.diagnostics tqdm.update(pbar) - return file_diagnostics - - async def _acquire_diagnostics(self) -> List[FileDiagnostics]: + 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 [] + ) + 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( @@ -96,12 +131,13 @@ def main_cli(): sys.exit(1) file_diagnostics = ClangdRunner( - ClangdAsync( + clangd=ClangdAsync( args.clangd_executable, args.compile_commands_dir, args.jobs, args.verbose ), - files, - args.tqdm, - ).run() + files=files, + tqdm=args.tqdm, + max_pending_requests=args.jobs * 2, + ).acquire_diagnostics() formatter = ( FancyDiagnosticFormatter( @@ -125,9 +161,13 @@ def main_cli(): ) if any( any( - d.severity and d.severity >= SEVERITY_INT[args.fail_on_severity] - for d in fd.diagnostics + ( + diagostic.severity + and diagostic.severity >= SEVERITY_INT[args.fail_on_severity] + ) + or diagostic.source == "clang-format" + for diagostic in diagnostics ) - for fd in file_diagnostics + for diagnostics in file_diagnostics.values() ): exit(1) 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;