Source code for cpp_linter.clang_tools.patcher

"""A module to contain the abstractions about creating suggestions from a diff generated
by the clang tool's output."""

from abc import ABC
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from pygit2 import Patch  # type: ignore
from ..common_fs import FileObj

try:
    from pygit2.enums import DiffOption  # type: ignore

    INDENT_HEURISTIC = DiffOption.INDENT_HEURISTIC
except ImportError:  # if pygit2.__version__ < 1.14
    from pygit2 import GIT_DIFF_INDENT_HEURISTIC  # type: ignore

    INDENT_HEURISTIC = GIT_DIFF_INDENT_HEURISTIC


[docs] class Suggestion: """A data structure to contain information about a single suggestion. :param file_name: The path to the file that this suggestion pertains. This should use posix path separators. """ def __init__(self, file_name: str) -> None: #: The file's line number starting the suggested change. self.line_start: int = -1 #: The file's line number ending the suggested change. self.line_end: int = -1 #: The file's path about the suggested change. self.file_name: str = file_name #: The markdown comment about the suggestion. self.comment: str = ""
[docs] def serialize_to_github_payload(self) -> Dict[str, Any]: """Serialize this object into a JSON compatible with Github's REST API.""" assert self.line_end > 0, "ending line number unknown" result = {"path": self.file_name, "body": self.comment, "line": self.line_end} if self.line_start != self.line_end and self.line_start > 0: result["start_line"] = self.line_start return result
[docs] class ReviewComments: """A data structure to contain PR review comments from a specific clang tool.""" def __init__(self) -> None: #: The list of actual comments self.suggestions: List[Suggestion] = [] self.tool_total: Dict[str, Optional[int]] = { "clang-tidy": None, "clang-format": None, } """The total number of concerns about a specific clang tool. This may not equate to the length of `suggestions` because 1. There is no guarantee that all suggestions will fit within the PR's diff. 2. Suggestions are a combined result of advice from both tools. A `None` value means a review was not requested from the corresponding tool. """ self.full_patch: Dict[str, str] = {"clang-tidy": "", "clang-format": ""} """The full patch of all the suggestions (including those that will not fit within the diff)"""
[docs] def merge_similar_suggestion(self, suggestion: Suggestion) -> bool: """Merge a given ``suggestion`` into a similar `Suggestion` :returns: `True` if the suggestion was merged, otherwise `False`. """ for known in self.suggestions: if ( known.file_name == suggestion.file_name and known.line_end == suggestion.line_end and known.line_start == suggestion.line_start ): known.comment += f"\n{suggestion.comment}" return True return False
[docs] def serialize_to_github_payload( # avoid circular imports by accepting primitive types (instead of ClangVersions) self, tidy_version: Optional[str], format_version: Optional[str], ) -> Tuple[str, List[Dict[str, Any]]]: """Serialize this object into a summary and list of comments compatible with Github's REST API. :param tidy_version: The version numbers of the clang-tidy used. :param format_version: The version numbers of the clang-format used. :returns: The returned tuple contains a brief summary (at index ``0``) that contains markdown text describing the summary of the review comments. The list of `suggestions` (at index ``1``) is the serialized JSON object. """ summary = "" comments = [] posted_tool_advice = {"clang-tidy": 0, "clang-format": 0} for comment in self.suggestions: comments.append(comment.serialize_to_github_payload()) if "### clang-format" in comment.comment: posted_tool_advice["clang-format"] += 1 if "### clang-tidy" in comment.comment: posted_tool_advice["clang-tidy"] += 1 for tool_name in ("clang-tidy", "clang-format"): tool_version = tidy_version if tool_name == "clang-format": tool_version = format_version if tool_version is None or self.tool_total[tool_name] is None: continue # if tool wasn't used summary += f"### Used {tool_name} v{tool_version}\n\n" if ( len(comments) and posted_tool_advice[tool_name] != self.tool_total[tool_name] ): summary += ( f"Only {posted_tool_advice[tool_name]} out of " + f"{self.tool_total[tool_name]} {tool_name}" + " concerns fit within this pull request's diff.\n" ) if self.full_patch[tool_name]: summary += ( f"\n<details><summary>Click here for the full {tool_name} patch" + f"</summary>\n\n\n```diff\n{self.full_patch[tool_name]}\n" + "```\n\n\n</details>\n\n" ) elif not self.tool_total[tool_name]: summary += f"No concerns from {tool_name}.\n" return (summary, comments)
[docs] class PatchMixin(ABC): """An abstract mixin that unified parsing of the suggestions into PR review comments.""" def __init__(self) -> None: #: A unified diff of the applied fixes from the clang tool's output self.patched: Optional[bytes] = None
[docs] def get_suggestion_help(self, start, end) -> str: """Create helpful text about what the suggestion aims to fix. The parameters ``start`` and ``end`` are the line numbers (relative to file's original content) encapsulating the suggestion. """ return f"### {self.get_tool_name()} "
[docs] def get_tool_name(self) -> str: """A function that must be implemented by derivatives to get the clang tool's name that generated the `patched` data.""" raise NotImplementedError("must be implemented by derivative")
[docs] def get_suggestions_from_patch( self, file_obj: FileObj, summary_only: bool, review_comments: ReviewComments ): """Create a list of suggestions from the tool's `patched` output. Results are stored in the ``review_comments`` parameter (passed by reference). """ assert ( self.patched ), f"{self.__class__.__name__} has no suggestions for {file_obj.name}" patch = Patch.create_from( Path(file_obj.name).read_bytes(), self.patched, file_obj.name, file_obj.name, context_lines=0, # exclude any surrounding unchanged lines flag=INDENT_HEURISTIC, ) tool_name = self.get_tool_name() assert tool_name in review_comments.full_patch review_comments.full_patch[tool_name] += f"{patch.text}" assert tool_name in review_comments.tool_total tool_total = review_comments.tool_total[tool_name] or 0 for hunk in patch.hunks: tool_total += 1 if summary_only: continue new_hunk_range = file_obj.is_hunk_contained(hunk) if new_hunk_range is None: continue start_line, end_line = new_hunk_range comment = Suggestion(file_obj.name) body = self.get_suggestion_help(start=start_line, end=end_line) if start_line < end_line: comment.line_start = start_line comment.line_end = end_line removed = [] suggestion = "" for line in hunk.lines: if line.origin in ("+", " "): suggestion += f"{line.content}" else: line_numb = line.old_lineno removed.append(line_numb) if not suggestion and removed: body += "\nPlease remove the line(s)\n- " body += "\n- ".join([str(x) for x in removed]) else: body += f"\n```suggestion\n{suggestion}```" comment.comment = body if not review_comments.merge_similar_suggestion(comment): review_comments.suggestions.append(comment) review_comments.tool_total[tool_name] = tool_total