from os import environ
from pathlib import Path
from typing import List, Dict, Any, Union, Tuple, Optional, TYPE_CHECKING
from pygit2 import DiffHunk # type: ignore
from ..loggers import logger
if TYPE_CHECKING: # pragma: no covers
# circular import
from ..clang_tools.clang_tidy import TidyAdvice
from ..clang_tools.clang_format import FormatAdvice
#: A path to generated cache artifacts. (only used when verbosity is in debug mode)
CACHE_PATH = Path(environ.get("CPP_LINTER_CACHE", ".cpp-linter_cache"))
[docs]
class FileObj:
"""A class to represent a single file being analyzed.
:param name: The file name. This should use Unix style path delimiters (``/``),
even on Windows.
:param additions: A `list` of line numbers that have added changes in the diff.
This value is used to populate the `lines_added` property.
:param diff_chunks: The ranges that define the beginning and ending line numbers
for all hunks in the diff.
"""
def __init__(
self,
name: str,
additions: Optional[List[int]] = None,
diff_chunks: Optional[List[List[int]]] = None,
):
self.name: str = name #: The file name
self.additions: List[int] = additions or []
"""A list of line numbers that contain added changes. This will be empty if
not focusing on lines changed only."""
self.diff_chunks: List[List[int]] = diff_chunks or []
"""A list of line numbers that define the beginning and ending of hunks in the
diff. This will be empty if not focusing on lines changed only."""
self.lines_added: List[List[int]] = FileObj._consolidate_list_to_ranges(
additions or []
)
"""A list of line numbers that define the beginning and ending of ranges that
have added changes. This will be empty if not focusing on lines changed only.
"""
#: The results from clang-tidy
self.tidy_advice: Optional["TidyAdvice"] = None
#: The results from clang-format
self.format_advice: Optional["FormatAdvice"] = None
def __repr__(self) -> str:
return f"<FileObj {self.name} added:{self.additions} chunks:{self.diff_chunks}>"
@staticmethod
def _consolidate_list_to_ranges(numbers: List[int]) -> List[List[int]]:
"""A helper function that is only used after parsing the lines from a diff that
contain additions.
:param numbers: A `list` of integers representing the lines' numbers that
contain additions.
:returns: A consolidated sequence of lists. Each list will have 2 items
describing the starting and ending lines of all line ``numbers``.
"""
result: List[List[int]] = []
for i, n in enumerate(numbers):
if not i:
result.append([n])
elif n - 1 != numbers[i - 1]:
result[-1].append(numbers[i - 1] + 1)
result.append([n])
if i == len(numbers) - 1:
result[-1].append(n + 1)
return result
[docs]
def range_of_changed_lines(
self, lines_changed_only: int, get_ranges: bool = False
) -> Union[List[int], List[List[int]]]:
"""Assemble a list of lines changed.
:param lines_changed_only: A flag to indicate the focus of certain lines.
- ``0``: focuses on all lines in a file(s).
- ``1``: focuses on any lines shown in the event's diff (may include
unchanged lines).
- ``2``: focuses strictly on lines in the diff that contain additions.
:param get_ranges: A flag to return a list of sequences representing
:py:class:`range` parameters. Defaults to `False` since this is only
required when constructing clang-tidy or clang-format CLI arguments.
:returns:
A list of line numbers for which to give attention. If ``get_ranges`` is
asserted, then the returned list will be a list of ranges. If
``lines_changed_only`` is ``0``, then an empty list is returned.
"""
if lines_changed_only:
ranges = self.diff_chunks if lines_changed_only == 1 else self.lines_added
if get_ranges:
return ranges
return self.additions
# we return an empty list (instead of None) here so we can still iterate it
return [] # type: ignore[return-value]
[docs]
def serialize(self) -> Dict[str, Any]:
"""For easy debugging, use this method to serialize the `FileObj` into a json
compatible `dict`."""
return {
"filename": self.name,
"line_filter": {
"diff_chunks": self.diff_chunks,
"lines_added": self.lines_added,
},
}
[docs]
def is_hunk_contained(self, hunk: DiffHunk) -> Optional[Tuple[int, int]]:
"""Does a given ``hunk`` start and end within a single diff hunk?
This also includes some compensations for hunk headers that are oddly formed.
.. tip:: This is mostly useful to create comments that can be posted within a
git changes' diff. Ideally, designed for PR reviews based on patches
generated by clang tools' output.
:returns: The appropriate starting and ending line numbers of the given hunk.
If hunk cannot fit in a single hunk, this returns `None`.
"""
if hunk.old_lines > 0:
start = hunk.old_start
# span of old_lines is an inclusive range
end = hunk.old_start + hunk.old_lines - 1
else: # if number of old lines is 0
# start hunk at new line number
start = hunk.new_start
# make it span 1 line
end = start
return self.is_range_contained(start, end)
[docs]
def is_range_contained(self, start: int, end: int) -> Optional[Tuple[int, int]]:
"""Does the given ``start`` and ``end`` line numbers fit within a single diff
hunk?
This is a helper function to `is_hunk_contained()`.
.. tip:: This is mostly useful to create comments that can be posted within a
git changes' diff. Ideally, designed for PR reviews based on patches
generated by clang tools' output.
:returns: The appropriate starting and ending line numbers of the given hunk.
If hunk cannot fit in a single hunk, this returns `None`.
"""
for hunk in self.diff_chunks:
chunk_range = range(hunk[0], hunk[1])
if start in chunk_range and end in chunk_range:
return (start, end)
logger.warning(
"lines %d - %d are not within a single diff hunk for file %s.",
start,
end,
self.name,
)
return None
[docs]
def has_line_changes(
lines_changed_only: int, diff_chunks: List[List[int]], additions: List[int]
) -> bool:
"""Does this file actually apply to condition specified by ``lines_changed_only``?
:param lines_changed_only: A value that means:
- 0 = We don't care. Analyze the whole file.
- 1 = Only analyze lines in the diff chunks, which may include unchanged
lines but not lines with subtractions.
- 2 = Only analyze lines with additions.
:param diff_chunks: The ranges of lines in the diff for a single file.
:param additions: The lines with additions in the diff for a single file.
"""
return (
(lines_changed_only == 1 and len(diff_chunks) > 0)
or (lines_changed_only == 2 and len(additions) > 0)
or not lines_changed_only
)
[docs]
def get_line_cnt_from_cols(file_path: str, offset: int) -> Tuple[int, int]:
"""Gets a line count and columns offset from a file's absolute offset.
:param file_path: Path to file.
:param offset: The byte offset to translate
:returns:
A `tuple` of 2 `int` numbers:
- Index 0 is the line number for the given offset.
- Index 1 is the column number for the given offset on the line.
"""
# logger.debug("Getting line count from %s at offset %d", file_path, offset)
contents = Path(file_path).read_bytes()[:offset]
return (contents.count(b"\n") + 1, offset - contents.rfind(b"\n"))