debputy.lsp.diagnostics

src/debputy/lsp/diagnostics.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import dataclasses
from bisect import bisect_left, bisect_right
from collections.abc import Mapping
from typing import (
    TypedDict,
    NotRequired,
    List,
    Any,
    Literal,
    Optional,
    TYPE_CHECKING,
    get_args,
    FrozenSet,
    cast,
    Tuple,
    TypeVar,
)
from collections.abc import Sequence

if TYPE_CHECKING:
    import lsprotocol.types as types
else:
    import debputy.lsprotocol.types as types

# These are in order of severity (most important to least important).
#
# Special cases:
#  - "spelling" is a specialized version of "pedantic" for textual spelling mistakes
#    (LSP uses the same severity for both; only `debputy lint` shows a difference
#     between them)
#
LintSeverity = Literal["error", "warning", "informational", "pedantic", "spelling"]

LINT_SEVERITY2LSP_SEVERITY: Mapping[LintSeverity, types.DiagnosticSeverity] = {
    "error": types.DiagnosticSeverity.Error,
    "warning": types.DiagnosticSeverity.Warning,
    "informational": types.DiagnosticSeverity.Information,
    "pedantic": types.DiagnosticSeverity.Hint,
    "spelling": types.DiagnosticSeverity.Hint,
}
NATIVELY_LSP_SUPPORTED_SEVERITIES: frozenset[LintSeverity] = cast(
    "FrozenSet[LintSeverity]",
    frozenset(
        {
            "error",
            "warning",
            "informational",
            "pedantic",
        }
    ),
)


_delta = set(get_args(LintSeverity)).symmetric_difference(
    LINT_SEVERITY2LSP_SEVERITY.keys()
)
assert (
    not _delta
), f"LintSeverity and LINT_SEVERITY2LSP_SEVERITY are not aligned. Delta: {_delta}"
del _delta


class DiagnosticData(TypedDict):
    quickfixes: NotRequired[list[Any] | None]
    lint_severity: NotRequired[LintSeverity | None]
    report_for_related_file: NotRequired[str]
    enable_non_interactive_auto_fix: bool


@dataclasses.dataclass(slots=True)
class DiagnosticReport:
    doc_uri: str
    doc_version: int
    diagnostic_report_id: str
    is_in_progress: bool
    diagnostics: list[types.Diagnostic]

    _diagnostic_range_helper: Optional["DiagnosticRangeHelper"] = None

    def diagnostics_in_range(self, text_range: types.Range) -> list[types.Diagnostic]:
        if not self.diagnostics:
            return []
        helper = self._diagnostic_range_helper
        if helper is None:
            helper = DiagnosticRangeHelper(self.diagnostics)
            self._diagnostic_range_helper = helper
        return helper.diagnostics_in_range(text_range)


def _pos_as_tuple(pos: types.Position) -> tuple[int, int]:
    return pos.line, pos.character


class DiagnosticRangeHelper:

    __slots__ = ("diagnostics", "by_start_index", "by_end_index")

    def __init__(self, diagnostics: list[types.Diagnostic]) -> None:
        self.diagnostics = diagnostics
        self.by_start_index = sorted(
            (
                (_pos_as_tuple(diagnostics[i].range.start), i)
                for i in range(len(diagnostics))
            ),
        )
        self.by_end_index = sorted(
            (
                (_pos_as_tuple(diagnostics[i].range.end), i)
                for i in range(len(diagnostics))
            ),
        )

    def diagnostics_in_range(self, text_range: types.Range) -> list[types.Diagnostic]:
        start_pos = _pos_as_tuple(text_range.start)
        end_pos = _pos_as_tuple(text_range.end)

        try:
            lower_index_limit = _find_gt(
                self.by_end_index,
                start_pos,
                key=lambda t: t[0],
            )[1]
        except NoSuchElementError:
            lower_index_limit = len(self.diagnostics)

        try:
            upper_index_limit = _find_lt(
                self.by_start_index,
                end_pos,
                key=lambda t: t[0],
            )[1]

            upper_index_limit += 1
        except NoSuchElementError:
            upper_index_limit = 0

        return self.diagnostics[lower_index_limit:upper_index_limit]


T = TypeVar("T")


class NoSuchElementError(ValueError):
    pass


def _find_lt(a: Sequence[Any], x: Any, *, key: Any = None):
    "Find rightmost value less than x"
    i = bisect_left(a, x, key=key)
    if i:
        return a[i - 1]
    raise NoSuchElementError


def _find_gt(a: Sequence[Any], x: Any, *, key: Any = None):
    "Find leftmost value greater than x"
    i = bisect_right(a, x, key=key)
    if i != len(a):
        return a[i]
    raise NoSuchElementError