# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import platform
import re
import traceback
from collections.abc import Callable, Collection, Container, Iterable, Mapping, Sequence
from contextlib import AbstractContextManager
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, TypedDict, TypeVar
from libcst import CSTNode, CSTNodeT, FlattenSentinel, RemovalSentinel
from libcst._add_slots import add_slots
from libcst.metadata import CodePosition, CodeRange
from packaging.version import Version
__all__ = ["CodePosition", "CodeRange", "Version"]
T = TypeVar("T")
STDIN = Path("-")
FileContent = bytes
RuleOptionScalar = str | int | float | bool
RuleOptionValue = RuleOptionScalar | list[Any] | dict[str, Any]
RuleOptionTypes = (str, int, float, bool)
RuleOptions = dict[str, RuleOptionValue]
RuleOptionsTable = dict[str, RuleOptions]
NodeReplacement = CSTNodeT | FlattenSentinel[CSTNodeT] | RemovalSentinel
Metrics = dict[str, Any]
MetricsHook = Callable[[Metrics], None]
VisitorMethod = Callable[[CSTNode], None]
VisitHook = Callable[[str], AbstractContextManager[None]]
class OutputFormat(str, Enum):
custom = "custom"
rattle = "rattle"
# json = "json" # TODO
vscode = "vscode"
[docs]
@dataclass(frozen=True)
class Invalid:
code: str
range: CodeRange | None = None
expected_message: str | None = None
expected_replacement: str | None = None
options: RuleOptions | None = None
[docs]
@dataclass(frozen=True)
class Valid:
code: str
options: RuleOptions | None = None
LintIgnoreRegex = re.compile(
r"""
\#\s* # leading hash and whitespace
rattle:\s*ignore # directive
(?:
\s*\[
(?P<rattle_names>
[a-z][a-z0-9]*(?:-[a-z0-9]+)* # first rule name
(?:,\s*[a-z][a-z0-9]*(?:-[a-z0-9]+)*)* # subsequent rule names
)
\]
|
(?!\w)(?!\s+\w)(?!\s*\[) # do not accept bare names or invalid brackets
)
""",
re.VERBOSE,
)
@dataclass(frozen=True)
class LintIgnoreDirective:
names: str | None
def parse_lint_ignore_comment(comment: str) -> LintIgnoreDirective | None:
match = LintIgnoreRegex.search(comment)
if match is None:
return None
return LintIgnoreDirective(names=match.group("rattle_names"))
QualifiedRuleRegex = re.compile(
r"""
^
(?P<module>
(?P<local>\.)?
[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*
)
(?::(?P<name>[a-z][a-z0-9]*(?:-[a-z0-9]+)*))?
$
""",
re.VERBOSE,
)
RuleNameSelectorRegex = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
class QualifiedRuleRegexResult(TypedDict):
module: str
name: str | None
local: str | None
def is_sequence(value: object) -> bool:
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
def is_rule_option_value(value: object) -> bool:
if isinstance(value, RuleOptionTypes):
return True
if is_sequence(value):
assert isinstance(value, Sequence)
return all(is_rule_option_value(item) for item in value)
if isinstance(value, Mapping):
return all(
isinstance(key, str) and is_rule_option_value(item) for key, item in value.items()
)
return False
def is_collection(value: object) -> bool:
return isinstance(value, Iterable) and not isinstance(value, (str, bytes))
[docs]
@dataclass(frozen=True)
class QualifiedRule:
module: str
name: str | None = None
local: str | None = None
root: Path | None = field(default=None, hash=False, compare=False)
def __str__(self) -> str:
return self.module + (f":{self.name}" if self.name else "")
def __lt__(self, other: object) -> bool:
if isinstance(other, QualifiedRule):
return str(self) < str(other)
return NotImplemented
[docs]
@dataclass(frozen=True)
class RuleNameSelector:
value: str
def __str__(self) -> str:
return self.value
def __lt__(self, other: object) -> bool:
if isinstance(other, RuleNameSelector):
return self.value < other.value
return NotImplemented
RuleSelector = QualifiedRule | RuleNameSelector
[docs]
@dataclass
class Options:
"""Command-line options to affect runtime behavior."""
debug: bool | None = None
config_file: Path | None = None
exclude: Sequence[str] = ()
extend_exclude: Sequence[str] = ()
jobs: int | None = None
tags: Tags | None = None
rules: Sequence[RuleSelector] = ()
output_format: OutputFormat | None = None
output_template: str | None = None
print_metrics: bool = False
no_format: bool = False
@dataclass
class LSPOptions:
"""Command-line options to affect LSP runtime behavior."""
tcp: int | None
ws: int | None
stdio: bool = True
debounce_interval: float = 0.5
[docs]
@dataclass
class Config:
"""Materialized configuration valid for processing a single file."""
path: Path = field(default_factory=Path)
root: Path = field(default_factory=Path.cwd)
excluded: bool = False
# feature flags
enable_root_import: bool | Path = False
# rule selection
enable: list[RuleSelector] = field(default_factory=list)
disable: list[RuleSelector] = field(default_factory=list)
rule_imports: list[RuleSelector] = field(default_factory=list)
options: RuleOptionsTable = field(default_factory=dict)
# filtering criteria
python_version: Version | None = field(
default_factory=lambda: Version(platform.python_version())
)
tags: Tags = field(default_factory=Tags)
# post-run processing
formatter: str | None = "auto"
# output formatting options
output_format: OutputFormat = OutputFormat.rattle
output_template: str = ""
def __post_init__(self) -> None:
self.path = self.path.resolve()
self.root = self.root.resolve()
@dataclass
class RawConfig:
path: Path
data: dict[str, Any]
def __post_init__(self) -> None:
self.path = self.path.resolve()
[docs]
@add_slots
@dataclass(frozen=True)
class LintViolation:
"""An individual lint error, with an optional replacement and expected diff."""
rule_name: str
range: CodeRange | None
message: str
node: CSTNode
replacement: NodeReplacement[CSTNode] | None
diff: str = ""
position_node: CSTNode | None = None
@property
def autofixable(self) -> bool:
"""Whether the violation includes a suggested replacement."""
return bool(self.replacement)
[docs]
@dataclass
class Result:
"""A single lint result for a given file and lint rule."""
path: Path
violation: LintViolation | None
error: tuple[Exception, str] | None = None
source: FileContent | None = None
config: Config | None = None
[docs]
@classmethod
def from_exception(
cls,
path: Path,
error: Exception,
*,
source: FileContent | None = None,
config: Config | None = None,
) -> "Result":
return cls(
path,
violation=None,
error=(error, traceback.format_exc()),
source=source,
config=config,
)