Coverage for src/debputy/lsp/vendoring/_deb822_repro/locatable.py: 90%
122 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-07 12:14 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-07 12:14 +0200
1import dataclasses
2import itertools
3import sys
5from typing import Optional, TYPE_CHECKING, Iterable
7if TYPE_CHECKING:
8 from typing import Self
9 from .parsing import Deb822Element
12_DATA_CLASS_OPTIONAL_ARGS = {}
13if sys.version_info >= (3, 10): 13 ↛ 20line 13 didn't jump to line 20, because the condition on line 13 was never false
14 # The `slots` feature greatly reduces the memory usage by avoiding the `__dict__`
15 # instance. But at the end of the day, performance is "nice to have" for this
16 # feature and all current consumers are at Python 3.12 (except the CI tests...)
17 _DATA_CLASS_OPTIONAL_ARGS["slots"] = True
20@dataclasses.dataclass(frozen=True, **_DATA_CLASS_OPTIONAL_ARGS)
21class Position:
22 """Describes a "cursor" position inside a file
24 It consists of a line position (0-based line number) and a cursor position. This is modelled
25 after the "Position" in Language Server Protocol (LSP).
26 """
28 line_position: int
29 """Describes the line position as a 0-based line number
31 See line_number if you want a human-readable line number
32 """
33 cursor_position: int
34 """Describes a cursor position ("between two characters") or a character offset.
36 When this value is 0, the position is at the start of a line. When it is 1, then
37 the position is between the first and the second character (etc.).
38 """
40 @property
41 def line_number(self) -> int:
42 """The line number as human would count it"""
43 return self.line_position + 1
45 def relative_to(self, new_base: "Position") -> "Position":
46 """Offsets the position relative to another position
48 This is useful to avoid the `position_in_file()` method by caching where
49 the parents position and then for its children you use `range_in_parent()`
50 plus `relative_to()` to rebase the range.
52 >>> parent: Locatable = ... # doctest: +SKIP
53 >>> children: Iterable[Locatable] = ... # doctest: +SKIP
54 >>> # This will expensive
55 >>> parent_pos = parent.position_in_file( # doctest: +SKIP
56 ... skip_leading_comments=False
57 ... )
58 >>> for child in children: # doctest: +SKIP
59 ... child_pos = child.position_in_parent()
60 ... # Avoid a position_in_file() for each child
61 ... child_pos_in_file = child_pos.relative_to(parent_pos)
62 ... ... # Use the child_pos_in_file for something
64 :param new_base: The position that should have been the origin rather than
65 (0, 0).
66 :returns: The range offset relative to the base position.
67 """
68 if self.line_position == 0 and self.cursor_position == 0:
69 return new_base
70 if new_base.line_position == 0 and new_base.cursor_position == 0:
71 return self
72 if self.line_position == 0:
73 line_number = new_base.line_position
74 line_char_offset = new_base.cursor_position + self.cursor_position
75 else:
76 line_number = self.line_position + new_base.line_position
77 line_char_offset = self.cursor_position
78 return Position(
79 line_number,
80 line_char_offset,
81 )
84@dataclasses.dataclass(frozen=True, **_DATA_CLASS_OPTIONAL_ARGS)
85class Range:
86 """Describes a range inside a file
88 This can be useful to describe things like "from line 4, cursor position 2
89 to line 7 to cursor position 10". When describing a full line including the
90 newline, use line N, cursor position 0 to line N+1. cursor position 0.
92 It is also used to denote the size of objects (in that case, the start position
93 is set to START_POSITION as a convention if the precise location is not
94 specified).
96 This is modelled after the "Range" in Language Server Protocol (LSP).
97 """
99 start_pos: Position
100 end_pos: Position
102 @property
103 def start_line_position(self) -> int:
104 """Describes the start line position as a 0-based line number
106 See start_line_number if you want a human-readable line number
107 """
108 return self.start_pos.line_position
110 @property
111 def start_cursor_position(self) -> int:
112 """Describes the starting cursor position
114 When this value is 0, the position is at the start of a line. When it is 1, then
115 the position is between the first and the second character (etc.).
116 """
117 return self.start_pos.cursor_position
119 @property
120 def start_line_number(self) -> int:
121 """The start line number as human would count it"""
122 return self.start_pos.line_number
124 @property
125 def end_line_position(self) -> int:
126 """Describes the end line position as a 0-based line number
128 See end_line_number if you want a human-readable line number
129 """
130 return self.end_pos.line_position
132 @property
133 def end_line_number(self) -> int:
134 """The end line number as human would count it"""
135 return self.end_pos.line_number
137 @property
138 def end_cursor_position(self) -> int:
139 """Describes the end cursor position
141 When this value is 0, the position is at the start of a line. When it is 1, then
142 the position is between the first and the second character (etc.).
143 """
144 return self.end_pos.cursor_position
146 @property
147 def line_count(self) -> int:
148 """The number of lines (newlines) spanned by this range.
150 Will be zero when the range fits inside one line.
151 """
152 return self.end_line_position - self.start_line_position
154 @classmethod
155 def between(cls, a: Position, b: Position) -> "Self":
156 """Computes the range between two positions
158 Unlike the constructor, this will always create a "positive" range.
159 That is, the "earliest" position will always be the start position
160 regardless of the order they were passed to `between`. When using
161 the Range constructor, you have freedom to do "inverse" ranges
162 in case that is ever useful
163 """
164 if a.line_position > b.line_position or ( 164 ↛ 168line 164 didn't jump to line 168, because the condition on line 164 was never true
165 a.line_position == b.line_position and a.cursor_position > b.cursor_position
166 ):
167 # Order swap, so `a` is always the earliest position
168 a, b = b, a
169 return cls(
170 a,
171 b,
172 )
174 def relative_to(self, new_base: Position) -> "Range":
175 """Offsets the range relative to another position
177 This is useful to avoid the `position_in_file()` method by caching where
178 the parents position and then for its children you use `range_in_parent()`
179 plus `relative_to()` to rebase the range.
181 >>> parent: Locatable = ... # doctest: +SKIP
182 >>> children: Iterable[Locatable] = ... # doctest: +SKIP
183 >>> # This will expensive
184 >>> parent_pos = parent.position_in_file( # doctest: +SKIP
185 ... skip_leading_comments=False
186 ... )
187 >>> for child in children: # doctest: +SKIP
188 ... child_range = child.range_in_parent()
189 ... # Avoid a position_in_file() for each child
190 ... child_range_in_file = child_range.relative_to(parent_pos)
191 ... ... # Use the child_range_in_file for something
193 :param new_base: The position that should have been the origin rather than
194 (0, 0).
195 :returns: The range offset relative to the base position.
196 """
197 if new_base == START_POSITION:
198 return self
199 return Range(
200 self.start_pos.relative_to(new_base),
201 self.end_pos.relative_to(new_base),
202 )
204 def as_size(self) -> "Range":
205 """Reduces the range to a "size"
207 The returned range will always have its start position to (0, 0) and
208 its end position shifted accordingly if it was not already based at
209 (0, 0).
211 The original range is not mutated and, if it is already at (0, 0), the
212 method will just return it as-is.
213 """
214 if self.start_pos == START_POSITION: 214 ↛ 216line 214 didn't jump to line 216, because the condition on line 214 was never false
215 return self
216 line_count = self.line_count
217 if line_count:
218 new_end_cursor_position = self.end_cursor_position
219 else:
220 delta = self.end_cursor_position - self.start_cursor_position
221 new_end_cursor_position = delta
222 return Range(
223 START_POSITION,
224 Position(
225 line_count,
226 new_end_cursor_position,
227 ),
228 )
230 @classmethod
231 def from_position_and_size(cls, base: Position, size: "Range") -> "Self":
232 """Compute a range from a position and the size of another range
234 This provides you with a range starting at the base position that has
235 the same effective span as the size parameter.
237 :param base: The desired starting position
238 :param size: A range, which will be used as a size (that is, it will
239 be reduced to a size via the `as_size()` method) for the resulting
240 range
241 :returns: A range at the provided base position that has the size of
242 the provided range.
243 """
244 line_position = base.line_position
245 cursor_position = base.cursor_position
246 size_rebased = size.as_size()
247 lines = size_rebased.line_count
248 if lines:
249 line_position += lines
250 cursor_position = size_rebased.end_cursor_position
251 else:
252 delta = (
253 size_rebased.end_cursor_position - size_rebased.start_cursor_position
254 )
255 cursor_position += delta
256 return cls(
257 base,
258 Position(
259 line_position,
260 cursor_position,
261 ),
262 )
264 @classmethod
265 def from_position_and_sizes(
266 cls, base: Position, sizes: Iterable["Range"]
267 ) -> "Self":
268 """Compute a range from a position and the size of number of ranges
270 :param base: The desired starting position
271 :param sizes: All the ranges that combined makes up the size of the
272 desired position. Note that order can affect the end result. Particularly
273 the end character offset gets reset every time a size spans a line.
274 :returns: A range at the provided base position that has the size of
275 the provided range.
276 """
277 line_position = base.line_position
278 cursor_position = base.cursor_position
279 for size in sizes:
280 size_rebased = size.as_size()
281 lines = size_rebased.line_count
282 if lines:
283 line_position += lines
284 cursor_position = size_rebased.end_cursor_position
285 else:
286 delta = (
287 size_rebased.end_cursor_position
288 - size_rebased.start_cursor_position
289 )
290 cursor_position += delta
291 return cls(
292 base,
293 Position(
294 line_position,
295 cursor_position,
296 ),
297 )
300START_POSITION = Position(0, 0)
301SECOND_CHAR_POS = Position(0, 1)
302SECOND_LINE_POS = Position(1, 0)
303ONE_CHAR_RANGE = Range.between(START_POSITION, SECOND_CHAR_POS)
304ONE_LINE_RANGE = Range.between(START_POSITION, SECOND_LINE_POS)
307class Locatable:
308 __slots__ = ()
310 @property
311 def parent_element(self):
312 # type: () -> Optional[Deb822Element]
313 raise NotImplementedError
315 def position_in_parent(self, *, skip_leading_comments: bool = True) -> Position:
316 """The start position of this token/element inside its parent
318 This is operation is generally linear to the number of "parts" (elements/tokens)
319 inside the parent.
321 :param skip_leading_comments: If True, then if any leading comment that
322 that can be skipped will be excluded in the position of this locatable.
323 This is useful if you want the position "semantic" content of a field
324 without also highlighting a leading comment. Remember to align this
325 parameter with the `size` call, so the range does not "overshoot"
326 into the next element (or falls short and only covers part of an
327 element). Note that this option can only be used to filter out leading
328 comments when the comments are a subset of the element. It has no
329 effect on elements that are entirely made of comments.
330 """
331 # pylint: disable=unused-argument
332 # Note: The base class makes no assumptions about what tokens can be skipped,
333 # therefore, skip_leading_comments is unused here. However, I do not want the
334 # API to differ between elements and tokens.
336 parent = self.parent_element
337 if parent is None: 337 ↛ 338line 337 didn't jump to line 338, because the condition on line 337 was never true
338 raise TypeError(
339 "Cannot determine the position since the object is detached"
340 )
341 relevant_parts = itertools.takewhile(
342 lambda x: x is not self, parent.iter_parts()
343 )
344 span = Range.from_position_and_sizes(
345 START_POSITION,
346 (x.size(skip_leading_comments=False) for x in relevant_parts),
347 )
348 return span.end_pos
350 def range_in_parent(self, *, skip_leading_comments: bool = True) -> Range:
351 """The range of this token/element inside its parent
353 This is operation is generally linear to the number of "parts" (elements/tokens)
354 inside the parent.
356 :param skip_leading_comments: If True, then if any leading comment that
357 that can be skipped will be excluded in the position of this locatable.
358 This is useful if you want the position "semantic" content of a field
359 without also highlighting a leading comment. Remember to align this
360 parameter with the `size` call, so the range does not "overshoot"
361 into the next element (or falls short and only covers part of an
362 element). Note that this option can only be used to filter out leading
363 comments when the comments are a subset of the element. It has no
364 effect on elements that are entirely made of comments.
365 """
366 pos = self.position_in_parent(skip_leading_comments=skip_leading_comments)
367 return Range.from_position_and_size(
368 pos, self.size(skip_leading_comments=skip_leading_comments)
369 )
371 def position_in_file(self, *, skip_leading_comments: bool = True) -> Position:
372 """The start position of this token/element in this file
374 This is an *expensive* operation and in many cases have to traverse
375 the entire file structure to answer the query. Consider whether
376 you can maintain the parent's position and then use
377 `position_in_parent()` combined with
378 `child_position.relative_to(parent_position)`
380 :param skip_leading_comments: If True, then if any leading comment that
381 that can be skipped will be excluded in the position of this locatable.
382 This is useful if you want the position "semantic" content of a field
383 without also highlighting a leading comment. Remember to align this
384 parameter with the `size` call, so the range does not "overshoot"
385 into the next element (or falls short and only covers part of an
386 element). Note that this option can only be used to filter out leading
387 comments when the comments are a subset of the element. It has no
388 effect on elements that are entirely made of comments.
389 """
390 position = self.position_in_parent(
391 skip_leading_comments=skip_leading_comments,
392 )
393 parent = self.parent_element
394 if parent is not None: 394 ↛ 397line 394 didn't jump to line 397, because the condition on line 394 was never false
395 parent_position = parent.position_in_file(skip_leading_comments=False)
396 position = position.relative_to(parent_position)
397 return position
399 def size(self, *, skip_leading_comments: bool = True) -> Range:
400 """Describe the objects size as a continuous range
402 :param skip_leading_comments: If True, then if any leading comment that
403 that can be skipped will be excluded in the position of this locatable.
404 This is useful if you want the position "semantic" content of a field
405 without also highlighting a leading comment. Remember to align this
406 parameter with the `position_in_file` or `position_in_parent` call,
407 so the range does not "overshoot" into the next element (or falls
408 short and only covers part of an element). Note that this option can
409 only be used to filter out leading comments when the comments are a
410 subset of the element. It has no effect on elements that are entirely
411 made of comments.
412 """
413 raise NotImplementedError