Source code for dartwork_mpl.lint.fix

"""Proven local autofixes for lint issues.

Surface (re-exported by :mod:`dartwork_mpl.lint`): ``apply_lint_fixes``.
The edit planning here is shared with :mod:`.migrate`.
"""

from __future__ import annotations

import ast
import tokenize
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import TypeAlias

from .catalog import Issue
from .detect import _call_owner, _method_offset, lint
from .syntax import SourceRejected, _OwnerKind, _parse_source, _Syntax


@dataclass(frozen=True)
class _Edit:
    """A bounded source replacement expressed in original character offsets."""

    start: int
    end: int
    text: str


@dataclass(frozen=True)
class _Plan:
    """The edits that remove one occurrence, keyed by the issue's column."""

    offset: int
    edits: tuple[_Edit, ...]


@dataclass(frozen=True)
class _Fix:
    """One original occurrence and the nonoverlapping edits that remove it.

    ``start`` is the fixed call's source offset; fixes apply in that order.
    """

    issue: Issue
    start: int
    edits: tuple[_Edit, ...]


@dataclass(frozen=True)
class EditsOverlap:
    """Two planned edits cover the same original characters."""

    first: _Edit
    second: _Edit


@dataclass(frozen=True)
class EditedSourceRejected:
    """The edited source no longer parses, so the edits are refused."""

    rejection: SourceRejected


EditRefused: TypeAlias = EditsOverlap | EditedSourceRejected
"""Why ``_apply_edits`` returned no source; callers keep the original."""


@dataclass(frozen=True)
class InsideEdit:
    """A result offset lies inside inserted text and has no original position."""


@dataclass(frozen=True)
class _Call:
    """A call expression with its recognized owner and the dm name in scope."""

    node: ast.Call
    owner: ast.expr
    path: list[str]
    kind: _OwnerKind | None
    package: str


def _dm_owner(syntax: _Syntax, node: ast.expr) -> str | None:
    """Choose an existing direct dm import from this exact expression's scope."""
    return next(
        (
            name
            for name, kind in syntax.facts.get(id(node), {}).items()
            if kind == "dm" and "." not in name
        ),
        None,
    )


def _supported_preset(value: ast.expr) -> bool:
    """Whether ``value`` is a string literal naming a shipped preset.

    Reads the style slice's preset registry; submitted code is never
    imported or executed.
    """
    if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
        return False
    from ..style import style as shipped_style

    return value.value in shipped_style.presets_dict()


def _plan_style_use(syntax: _Syntax, call: _Call) -> _Plan | None:
    """``plt.style.use("<shipped preset>")`` → ``dm.style.use(...)``."""
    node = call.node
    if not (
        call.kind == "pyplot"
        and call.path == ["style", "use"]
        and len(node.args) == 1
        and not node.keywords
        and _supported_preset(node.args[0])
    ):
        return None
    start, end = syntax.span(call.owner)
    return _Plan(start, (_Edit(start, end, call.package),))


def _plan_tight_layout(syntax: _Syntax, call: _Call) -> _Plan | None:
    """``fig.tight_layout()`` on a proven Figure → ``dm.simple_layout(fig)``."""
    node, owner = call.node, call.owner
    if not (
        isinstance(owner, ast.Name)
        and call.kind == "figure"
        and syntax.facts.get(id(owner), {}).get(owner.id) == "figure"
        and call.path == ["tight_layout"]
        and isinstance(node.func, ast.Attribute)
        and not node.args
        and not node.keywords
    ):
        return None
    package = call.package
    start, end = syntax.span(node)
    offset = _method_offset(syntax, node.func)
    comments = [
        token
        for token in syntax.tokens
        if token.type == tokenize.COMMENT
        and start <= syntax.token_offset(token.start) < end
    ]
    if comments:
        function_start, function_end = syntax.span(node.func)
        opening = syntax.code.index("(", function_end, end)
        return _Plan(
            offset,
            (
                _Edit(function_start, function_end, f"{package}.simple_layout"),
                _Edit(opening + 1, opening + 1, owner.id),
            ),
        )
    return _Plan(
        offset, (_Edit(start, end, f"{package}.simple_layout({owner.id})"),)
    )


# The catalog rules that have a proven local autofix, each with its planner.
# Public Issue fields remain independent of this private dispatch metadata.
_AUTO_FIX_TABLE: Mapping[str, Callable[[_Syntax, _Call], _Plan | None]] = (
    MappingProxyType(
        {"plt-style-use": _plan_style_use, "tight-layout": _plan_tight_layout}
    )
)

_LAYOUT_RULES = frozenset({"tight-layout"})
"""Autofix rules that rewrite layout calls; migration leaves them alone."""


def _planned_fixes(
    syntax: _Syntax, before: list[Issue], *, layout: bool = True
) -> list[_Fix]:
    """Plan only complete locally proven calls; never infer missing imports or figures."""
    by_key = {(issue.rule_id, issue.column): issue for issue in before}
    planners = [
        (rule_id, planner)
        for rule_id, planner in _AUTO_FIX_TABLE.items()
        if layout or rule_id not in _LAYOUT_RULES
    ]
    fixes: list[_Fix] = []
    for node in ast.walk(syntax.tree):
        if not isinstance(node, ast.Call):
            continue
        owner, path, kind = _call_owner(syntax, node)
        package = _dm_owner(syntax, node.func)
        if package is None:
            continue
        call = _Call(node, owner, path, kind, package)
        for rule_id, planner in planners:
            plan = planner(syntax, call)
            if plan is None:
                continue
            issue = by_key.get((rule_id, plan.offset))
            if issue is not None:
                fixes.append(_Fix(issue, syntax.span(node)[0], plan.edits))
            break
    return sorted(fixes, key=lambda fix: fix.start)


def _apply_edits(code: str, edits: list[_Edit]) -> str | EditRefused:
    """Apply original-span edits once, refusing overlaps or invalid complete output."""
    ordered = sorted(edits, key=lambda edit: (edit.start, edit.end))
    previous: _Edit | None = None
    for edit in ordered:
        if previous is not None and edit.start < previous.end:
            return EditsOverlap(previous, edit)
        previous = edit
    result = code
    for edit in reversed(ordered):
        result = result[: edit.start] + edit.text + result[edit.end :]
    parsed = _parse_source(result)
    if isinstance(parsed, SourceRejected):
        return EditedSourceRejected(parsed)
    return result


def _original_offset(offset: int, edits: list[_Edit]) -> int | InsideEdit:
    """Map a surviving result occurrence back to its original character position."""
    delta = 0
    for edit in sorted(edits, key=lambda item: (item.start, item.end)):
        start = edit.start + delta
        end = start + len(edit.text)
        if offset < start:
            break
        if start <= offset < end:
            return InsideEdit()
        delta += len(edit.text) - (edit.end - edit.start)
    return offset - delta


[docs] def apply_lint_fixes(code: str) -> tuple[str, list[Issue], list[Issue]]: """Fix proven local calls while preserving refused source and occurrence identity. Applied Issue locations refer to the original source. Unfixed locations refer to the returned source. Malformed input is returned exactly with no applied issues; neither parsing nor rewriting executes supplied Python. """ before = lint(code) syntax = _parse_source(code) if isinstance(syntax, SourceRejected): return code, [], before fixes = _planned_fixes(syntax, before) edits = [edit for fix in fixes for edit in fix.edits] result = _apply_edits(code, edits) if not isinstance(result, str): return code, [], before after = lint(result) survivors = { (issue.rule_id, original) for issue in after if issue.column is not None and isinstance(original := _original_offset(issue.column, edits), int) } applied = [ fix.issue for fix in fixes if (fix.issue.rule_id, fix.issue.column) not in survivors ] # Preserve the catalog's public declaration order within original locations. applied_keys = {(issue.rule_id, issue.column) for issue in applied} return ( result, [ issue for issue in before if (issue.rule_id, issue.column) in applied_keys ], after, )