ZedScripts 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,482 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import string
5
+ from typing import Any
6
+ from collections import UserList
7
+ # from warnings import deprecated
8
+
9
+ from lsprotocol import types
10
+
11
+
12
+ class TextPosition:
13
+ """
14
+ Position in a text file.
15
+ Positions are zero indexed - in most cases you will want to convert to one-index when displaying to the user.
16
+ """
17
+
18
+ def __init__(self, line: int, offset: int) -> None:
19
+ self.line: int = line
20
+ self.offset: int = offset
21
+
22
+ def __hash__(self) -> int:
23
+ return hash((self.line, self.offset))
24
+
25
+ @staticmethod
26
+ def from_index(text: str, i: int, offset: TextPosition | None = None) -> TextPosition:
27
+ """
28
+ Returns the TextPosition of a character in a string by its index.
29
+ :param text: The string containing the character.
30
+ :param i: Index of the character.
31
+ :param offset: If specified, the result will be offset as though the start was at this position.
32
+ :return: Position of the character relative to the start of the string.
33
+ """
34
+ if offset is None:
35
+ offset = TextPosition(0, 0)
36
+ lines: int = text.count("\n", 0, i)
37
+ if lines == 0:
38
+ return TextPosition(offset.line, offset.offset + i)
39
+ else:
40
+ return TextPosition(offset.line + lines, i - text.rfind("\n", 0, i) - 1)
41
+
42
+ def to_index(self, string: str) -> int:
43
+ newline: int = -1
44
+ for _ in range(self.line):
45
+ newline = string.index("\n", newline + 1)
46
+ return newline + self.offset + 1
47
+
48
+ def relative_to(self, other: TextPosition) -> TextPosition:
49
+ """
50
+ Returns a new TextPosition that represents the same position as this one, relative to ``other``.
51
+ :param other:
52
+ :return:
53
+ """
54
+ assert self.line > other.line or (self.line == other.line and self.offset >= other.offset)
55
+ line = self.line - other.line
56
+ offset = self.offset
57
+ if line == 0:
58
+ offset -= other.offset
59
+ return TextPosition(line, offset)
60
+
61
+ def __gt__(self, other: Any) -> bool:
62
+ if not isinstance(other, TextPosition):
63
+ return NotImplemented
64
+ return self.line > other.line or self.line == other.line and self.offset > other.offset
65
+
66
+ def __ge__(self, other: Any) -> bool:
67
+ if not isinstance(other, TextPosition):
68
+ return NotImplemented
69
+ return self.line > other.line or self.line == other.line and self.offset >= other.offset
70
+
71
+ def __lt__(self, other: Any) -> bool:
72
+ if not isinstance(other, TextPosition):
73
+ return NotImplemented
74
+ return other > self
75
+
76
+ def __le__(self, other: Any) -> bool:
77
+ if not isinstance(other, TextPosition):
78
+ return NotImplemented
79
+ return other >= self
80
+
81
+ def __eq__(self, other: Any) -> bool:
82
+ if not isinstance(other, TextPosition):
83
+ return False
84
+ return self.line == other.line and self.offset == other.offset
85
+
86
+ def __repr__(self) -> str:
87
+ return f"<{str(self.line)}:{str(self.offset)}>"
88
+
89
+ def to_lsp(self) -> types.Position:
90
+ return types.Position(line=self.line, character=self.offset)
91
+
92
+
93
+ class TextRange:
94
+ """
95
+ Range of characters in a text file.
96
+
97
+ Range positions should be thought of as between characters:
98
+ A range starting at ``0:0`` and ending at ``0:5`` contains the characters ``0:0`` - ``0:4``.
99
+ """
100
+
101
+ def __init__(self, start: TextPosition, end: TextPosition) -> None:
102
+ self.start: TextPosition = start
103
+ self.end: TextPosition = end
104
+
105
+ def __repr__(self) -> str:
106
+ return f"<{repr(self.start)}-{repr(self.end)}>"
107
+
108
+ def __hash__(self) -> int:
109
+ return hash((self.start, self.end))
110
+
111
+ def __eq__(self, other: Any) -> bool:
112
+ """When comparing with another TextRange, check for equality. If comparing with a
113
+ TextPosition, check if the position is within the start and end range."""
114
+ if isinstance(other, TextRange):
115
+ return self.start == other.start and self.end == other.end
116
+ elif isinstance(other, TextPosition):
117
+ return self.start <= other and self.end >= other
118
+ return False
119
+
120
+ def __lt__(self, other: Any) -> bool:
121
+ if isinstance(other, TextRange):
122
+ return (self.start, self.end) < (other.start, other.end)
123
+ elif isinstance(other, TextPosition):
124
+ return (self.start, self.end) < (other, other)
125
+ return NotImplemented
126
+
127
+ def __le__(self, other: Any) -> bool:
128
+ if isinstance(other, TextRange):
129
+ return (self.start, self.end) <= (other.start, other.end)
130
+ elif isinstance(other, TextPosition):
131
+ return (self.start, self.end) <= (other, other)
132
+ return NotImplemented
133
+
134
+ def __gt__(self, other: Any) -> bool:
135
+ if isinstance(other, TextRange):
136
+ return (self.start, self.end) > (other.start, other.end)
137
+ elif isinstance(other, TextPosition):
138
+ return (self.start, self.end) > (other, other)
139
+ return NotImplemented
140
+
141
+ def __ge__(self, other: Any) -> bool:
142
+ if isinstance(other, TextRange):
143
+ return (self.start, self.end) >= (other.start, other.end)
144
+ elif isinstance(other, TextPosition):
145
+ return (self.start, self.end) >= (other, other)
146
+ return NotImplemented
147
+
148
+ def to_lsp(self) -> types.Range:
149
+ return types.Range(start=self.start.to_lsp(), end=self.end.to_lsp())
150
+
151
+
152
+ def chars_in_range(text: str, range: TextRange) -> str:
153
+ """
154
+ Returns a substring of ``text`` containing all characters in the range described by ``range``.
155
+
156
+ If ``range`` is not contained within ``text``, an empty string is returned.
157
+ In general, this function is only intended to be used with ranges originally generated from ``text``.
158
+ :param text: The text document.
159
+ :param range: The range of characters to extract.
160
+ :return: Substring of ``text`` described by ``range``.
161
+ """
162
+ lines = text.split('\n')
163
+ if range.end.line >= len(lines):
164
+ return ""
165
+
166
+ line_delta = range.end.line - range.start.line
167
+
168
+ if line_delta == 0:
169
+ return lines[range.start.line][range.start.offset:range.end.offset]
170
+
171
+ result: str = lines[range.start.line][range.start.offset:] + "\n"
172
+ if line_delta > 1:
173
+ result += str.join("\n", lines[range.start.line + 1:range.end.line]) + "\n"
174
+ result += lines[range.end.line][:range.end.offset]
175
+ return result
176
+
177
+
178
+ class TokenType(enum.Enum):
179
+ TEXT = enum.auto()
180
+ WHITESPACE = enum.auto()
181
+ NEWLINE = enum.auto()
182
+ ELEMENT_DELIMITER = enum.auto()
183
+ PUNCTUATOR = enum.auto()
184
+ COMMENT = enum.auto()
185
+
186
+
187
+ ELEMENTS_DELIMITERS: set[str] = {"{", "}", ","}
188
+ PUNCTUATORS: set[str] = {"=", ":", ";", "."}
189
+
190
+
191
+
192
+ WHITESPACE_NO_NEWLINE = string.whitespace.replace("\n", "")
193
+ """Whitespace characters without a newline"""
194
+
195
+
196
+
197
+ class ZedscriptSource:
198
+ def __init__(self, text: str) -> None:
199
+ self.string: str = text
200
+ self.tokens: TokenCollection = TokenCollection()
201
+
202
+
203
+ class Token:
204
+ def __init__(
205
+ self, type: TokenType, pos: TextPosition, end: TextPosition,
206
+ source: ZedscriptSource, text: str
207
+ ) -> None:
208
+ self.type: TokenType = type
209
+ self.pos: TextPosition = pos
210
+ """Start position of the token."""
211
+ self.end: TextPosition = end
212
+ """End position of the token."""
213
+ self.source: ZedscriptSource = source
214
+ self.text = text
215
+
216
+ def pos_of(self, i: int) -> TextPosition:
217
+ """
218
+ Returns the TextPosition of the character at index i in the string.
219
+ :param i: Index of the character.
220
+ :return: Position of the character.
221
+ """
222
+ return TextPosition.from_index(self.text, i, self.pos)
223
+
224
+ # @deprecated("Use end property instead.")
225
+ def end_pos(self) -> TextPosition:
226
+ """
227
+ Calculates the end position of the token.
228
+ The end position of a token is the same as the start position of the next token.
229
+ :return:
230
+ """
231
+ return self.end
232
+
233
+ def __str__(self) -> str:
234
+ return self.text
235
+
236
+
237
+ class TokenCollection(UserList[Token]):
238
+ def at(self, pos: TextPosition) -> Token | None:
239
+ for token in self.data:
240
+ if token.end > pos:
241
+ if token.pos < pos:
242
+ raise RuntimeError("pos is not contained within the collection")
243
+ return token
244
+ raise RuntimeError("pos is not contained within the collection")
245
+
246
+ def pos_of(self, i: int) -> TextPosition:
247
+ """
248
+ Returns the TextPosition of the character at index ``i`` in the total string of the collection.
249
+ :param i: Character index.
250
+ :return: Position of the character.
251
+ """
252
+ # FIXME: this is a naive implementation, as it assumes there are no gaps between tokens
253
+ # it is valid for a gap to exist when comments are involved
254
+ total_length: int = 0
255
+
256
+ if len(str(self)) == i:
257
+ return self.data[-1].end
258
+
259
+ token: Token | None = None
260
+ for token in self.data:
261
+ token_length = len(str(token))
262
+ if total_length + token_length > i:
263
+ break
264
+ total_length += token_length
265
+ assert token is not None
266
+
267
+ return token.pos_of(i - total_length)
268
+
269
+ def to_range(self) -> TextRange:
270
+ if not self.data:
271
+ return TextRange(TextPosition(0, 0), TextPosition(0, 0))
272
+ return TextRange(self.data[0].pos, self.data[-1].end)
273
+
274
+ def strip(self) -> TokenCollection:
275
+ # early return
276
+ end = len(self.data)
277
+ if end == 0:
278
+ return TokenCollection(self.data)
279
+
280
+ start: int = 0
281
+ while start < len(self.data):
282
+ if self.data[start].type is not TokenType.WHITESPACE:
283
+ break
284
+ start += 1
285
+
286
+ while end >= start:
287
+ if self.data[end - 1].type is not TokenType.WHITESPACE:
288
+ break
289
+ end -= 1
290
+
291
+ return TokenCollection(self.data[start:end])
292
+
293
+ def __str__(self) -> str:
294
+ return str.join("", [token.text for token in self.data])
295
+
296
+ def split(self, separator: str = ".") -> list['TokenCollection']:
297
+ assert separator in PUNCTUATORS, f"Separator '{separator}' is not a recognized punctuator. Only {PUNCTUATORS} are allowed."
298
+
299
+ result: list['TokenCollection'] = []
300
+
301
+ current_tokens: list[Token] = []
302
+ for token in self.data:
303
+ if token.text == separator:
304
+ if current_tokens:
305
+ result.append(TokenCollection(current_tokens))
306
+ current_tokens = []
307
+ else:
308
+ current_tokens.append(token)
309
+ if current_tokens:
310
+ result.append(TokenCollection(current_tokens))
311
+
312
+ return result
313
+
314
+
315
+ # TODO: this class is kinda redundant after all
316
+ class TokenBuilder:
317
+ def __init__(self, pos: TextPosition, type: TokenType, source: ZedscriptSource) -> None:
318
+ self.type: TokenType = type
319
+ self.pos: TextPosition = pos
320
+ self.text: str = ""
321
+ self.source: ZedscriptSource = source
322
+
323
+ def add(self, char: str) -> None:
324
+ self.text += char
325
+
326
+ def build(self, end: TextPosition) -> Token:
327
+ return Token(self.type, self.pos, end, self.source, self.text)
328
+
329
+
330
+ class Lexer:
331
+ def __init__(self, text: str) -> None:
332
+ self.text: str = text
333
+ self.source = ZedscriptSource(text)
334
+ self.pos: int = -1
335
+ self.line: int = 0
336
+ self.offset: int = 0
337
+
338
+ def has_next(self, num_characters: int = 1) -> bool:
339
+ return self.pos + num_characters < len(self.text)
340
+
341
+ def next(self) -> str:
342
+ assert self.has_next()
343
+ self.pos += 1
344
+ char: str = self.text[self.pos]
345
+ if char == "\n":
346
+ self.offset = 0
347
+ self.line += 1
348
+ else:
349
+ self.offset += 1
350
+ return char
351
+
352
+ def peek(self, num_characters: int = 1) -> str:
353
+ """
354
+ Peek ahead in the text by the specified number of characters without advancing the current position.
355
+ `num_characters = 0` would be the current character.
356
+
357
+ Args:
358
+ num_characters (int, optional): The number of characters to peek ahead. Defaults to 1.
359
+
360
+ Returns:
361
+ str: The character at the specified lookahead position.
362
+ """
363
+ assert self.pos + num_characters < len(self.text), f"Peek position out of range (pos: {self.pos}, num_characters: {num_characters}, len: {len(self.text)})"
364
+ return self.text[self.pos + num_characters]
365
+
366
+ def start_token(self, type: TokenType) -> TokenBuilder:
367
+ return TokenBuilder(TextPosition(self.line, self.offset), type, self.source)
368
+
369
+ def tokenize_comment(self) -> None:
370
+ assert self.check_comment()
371
+
372
+ token = self.start_token(TokenType.COMMENT)
373
+ # we already know the first two characters are comment indicators
374
+ token.add(self.next())
375
+ token.add(self.next())
376
+
377
+ while self.has_next():
378
+ char = self.next()
379
+ token.add(char)
380
+ if char == "*" and self.peek(1) == "/":
381
+ token.add(self.next())
382
+ break
383
+
384
+ self.source.tokens.append(
385
+ token.build(
386
+ TextPosition(self.line, self.offset)
387
+ )
388
+ )
389
+
390
+ def check_comment(self) -> bool:
391
+ """True if the current position in the text indicates the start of a comment."""
392
+ return (self.peek(1) == "/"
393
+ and self.has_next(2)
394
+ and self.peek(2) == "*")
395
+
396
+ def tokenize_punctuator(self) -> None:
397
+ token = self.start_token(TokenType.PUNCTUATOR)
398
+ token.add(self.next())
399
+ self.source.tokens.append(
400
+ token.build(
401
+ TextPosition(self.line, self.offset)
402
+ )
403
+ )
404
+
405
+ def tokenize_text(self) -> None:
406
+ token = self.start_token(TokenType.TEXT)
407
+
408
+ while self.has_next():
409
+ char = self.peek()
410
+ if (self.check_comment()
411
+ or char in ELEMENTS_DELIMITERS
412
+ or char in string.whitespace
413
+ or char in PUNCTUATORS):
414
+ break
415
+ token.add(self.next())
416
+
417
+ self.source.tokens.append(
418
+ token.build(
419
+ TextPosition(self.line, self.offset)
420
+ )
421
+ )
422
+
423
+ def tokenize_whitespace(self) -> None:
424
+ token = self.start_token(TokenType.WHITESPACE)
425
+
426
+ while self.has_next():
427
+ char = self.peek()
428
+ if char not in string.whitespace:
429
+ break
430
+ token.add(self.next())
431
+
432
+ self.source.tokens.append(
433
+ token.build(
434
+ TextPosition(self.line, self.offset)
435
+ )
436
+ )
437
+
438
+ @staticmethod
439
+ def tokenize(raw: str) -> TokenCollection:
440
+ lexer: Lexer = Lexer(raw)
441
+ while lexer.has_next():
442
+ char = lexer.peek()
443
+ if lexer.check_comment():
444
+ lexer.tokenize_comment()
445
+ continue
446
+ elif char in PUNCTUATORS:
447
+ lexer.tokenize_punctuator()
448
+ continue
449
+ elif char in ELEMENTS_DELIMITERS:
450
+ token = lexer.start_token(TokenType.ELEMENT_DELIMITER)
451
+ token.add(lexer.next())
452
+ lexer.source.tokens.append(
453
+ token.build(
454
+ TextPosition(lexer.line, lexer.offset)
455
+ )
456
+ )
457
+ continue
458
+ elif char in string.whitespace:
459
+ lexer.tokenize_whitespace()
460
+ continue
461
+ lexer.tokenize_text()
462
+
463
+ return TokenCollection(lexer.source.tokens)
464
+
465
+
466
+ if __name__ == "__main__":
467
+ from pathlib import Path
468
+ workspace = Path(__file__).parent.parent.parent.parent
469
+ file = workspace / "tests" / "scripts" / "food.txt"
470
+ raw = file.read_text()
471
+ tokens = Lexer.tokenize(raw)
472
+ for token in tokens:
473
+ print(str(token.type).ljust(25) + f'"{repr(str(token))}"' + f' ({token.pos}-{token.end})')
474
+
475
+ print()
476
+
477
+ tokens = Lexer.tokenize("Parameter = Base.Item")
478
+ splitted_tokens = tokens.split()
479
+ for i, split in enumerate(splitted_tokens):
480
+ print(f"Split {i}:")
481
+ for token in split:
482
+ print(str(token.type).ljust(25) + f'"{repr(str(token))}"' + f' ({token.pos}-{token.end})')