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

1import dataclasses 

2import itertools 

3import sys 

4 

5from typing import Optional, TYPE_CHECKING, Iterable 

6 

7if TYPE_CHECKING: 

8 from typing import Self 

9 from .parsing import Deb822Element 

10 

11 

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 

18 

19 

20@dataclasses.dataclass(frozen=True, **_DATA_CLASS_OPTIONAL_ARGS) 

21class Position: 

22 """Describes a "cursor" position inside a file 

23 

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 """ 

27 

28 line_position: int 

29 """Describes the line position as a 0-based line number 

30 

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. 

35 

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 """ 

39 

40 @property 

41 def line_number(self) -> int: 

42 """The line number as human would count it""" 

43 return self.line_position + 1 

44 

45 def relative_to(self, new_base: "Position") -> "Position": 

46 """Offsets the position relative to another position 

47 

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. 

51 

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 

63 

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 ) 

82 

83 

84@dataclasses.dataclass(frozen=True, **_DATA_CLASS_OPTIONAL_ARGS) 

85class Range: 

86 """Describes a range inside a file 

87 

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. 

91 

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). 

95 

96 This is modelled after the "Range" in Language Server Protocol (LSP). 

97 """ 

98 

99 start_pos: Position 

100 end_pos: Position 

101 

102 @property 

103 def start_line_position(self) -> int: 

104 """Describes the start line position as a 0-based line number 

105 

106 See start_line_number if you want a human-readable line number 

107 """ 

108 return self.start_pos.line_position 

109 

110 @property 

111 def start_cursor_position(self) -> int: 

112 """Describes the starting cursor position 

113 

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 

118 

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 

123 

124 @property 

125 def end_line_position(self) -> int: 

126 """Describes the end line position as a 0-based line number 

127 

128 See end_line_number if you want a human-readable line number 

129 """ 

130 return self.end_pos.line_position 

131 

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 

136 

137 @property 

138 def end_cursor_position(self) -> int: 

139 """Describes the end cursor position 

140 

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 

145 

146 @property 

147 def line_count(self) -> int: 

148 """The number of lines (newlines) spanned by this range. 

149 

150 Will be zero when the range fits inside one line. 

151 """ 

152 return self.end_line_position - self.start_line_position 

153 

154 @classmethod 

155 def between(cls, a: Position, b: Position) -> "Self": 

156 """Computes the range between two positions 

157 

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 ) 

173 

174 def relative_to(self, new_base: Position) -> "Range": 

175 """Offsets the range relative to another position 

176 

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. 

180 

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 

192 

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 ) 

203 

204 def as_size(self) -> "Range": 

205 """Reduces the range to a "size" 

206 

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). 

210 

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 ) 

229 

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 

233 

234 This provides you with a range starting at the base position that has 

235 the same effective span as the size parameter. 

236 

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 ) 

263 

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 

269 

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 ) 

298 

299 

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) 

305 

306 

307class Locatable: 

308 __slots__ = () 

309 

310 @property 

311 def parent_element(self): 

312 # type: () -> Optional[Deb822Element] 

313 raise NotImplementedError 

314 

315 def position_in_parent(self, *, skip_leading_comments: bool = True) -> Position: 

316 """The start position of this token/element inside its parent 

317 

318 This is operation is generally linear to the number of "parts" (elements/tokens) 

319 inside the parent. 

320 

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. 

335 

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 

349 

350 def range_in_parent(self, *, skip_leading_comments: bool = True) -> Range: 

351 """The range of this token/element inside its parent 

352 

353 This is operation is generally linear to the number of "parts" (elements/tokens) 

354 inside the parent. 

355 

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 ) 

370 

371 def position_in_file(self, *, skip_leading_comments: bool = True) -> Position: 

372 """The start position of this token/element in this file 

373 

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)` 

379 

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 

398 

399 def size(self, *, skip_leading_comments: bool = True) -> Range: 

400 """Describe the objects size as a continuous range 

401 

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