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,547 @@
1
+ import logging
2
+ from typing import TYPE_CHECKING, Any, Iterable, Iterator
3
+
4
+ from lsprotocol import types
5
+
6
+ from .. import SCRIPTSDOCS_LINK
7
+ from ..utils import path_to_uri
8
+ from . import Element
9
+ from ..enums.Diagnostic import DiagnosticType
10
+ from ..structure.ast import BlockNode, Chunk
11
+ from ..structure.lexer import TextRange, TextPosition
12
+ from ..providers.semantic_tokens import SemanticTokenType, SemanticTokenModifier
13
+ from ..providers.hover import make_hover_information, format_tree
14
+
15
+ if TYPE_CHECKING:
16
+ from .value import Value
17
+ from .dataset import Dataset
18
+ from ..environment.document import Document
19
+
20
+ class Block:
21
+ def __init__(self, document: 'Document', type: str):
22
+ self.document: 'Document' = document
23
+ self.type: str = type
24
+ """
25
+ Type of the block.
26
+ All blocks must have an explicit type, except the root block of a file, which is given an empty string.
27
+ If the block was created from AST, the type may be empty for non-root blocks, but this is ill-formed.
28
+ """
29
+ self.children: list['ScriptBlock'] = []
30
+ self.values: list['Value'] = []
31
+
32
+ def __repr__(self) -> str:
33
+ return f"Block(type={self.type}, children={len(self.children)}, values={len(self.values)})"
34
+
35
+ def __iter__(self) -> Iterator['ScriptBlock']:
36
+ return iter(self.children)
37
+
38
+ def get_tree(self) -> str:
39
+ type = self.type
40
+
41
+ parents: list[str] = [type]
42
+ current = self
43
+ while isinstance(current, ScriptBlock) and current.parent is not None:
44
+ parents.insert(0, current.parent.type)
45
+ current = current.parent
46
+
47
+ return " → ".join(parents)
48
+
49
+ def get_root(self) -> 'Root':
50
+ """
51
+ Find the root block of the whole block hierarchy.
52
+
53
+ Returns:
54
+ Root: The root block of the whole tree.
55
+ """
56
+ return NotImplemented
57
+
58
+ def get_description(self, dataset: 'Dataset') -> str:
59
+ return NotImplemented
60
+
61
+ def get_scriptsdocs_url(self, tree: list[str]) -> str:
62
+ return SCRIPTSDOCS_LINK + "/".join(tree).replace(" ", "-").lower()
63
+
64
+ def get_element_at(self, text_position: TextPosition) -> 'ScriptBlock | Value | None':
65
+ # first check the values
66
+ for value in self.values:
67
+ node = value.node
68
+ if node.contains_position(text_position):
69
+ return value
70
+
71
+ # then check the children blocks
72
+ for child in self.children:
73
+ element = child.get_element_at(text_position)
74
+ if element is not None:
75
+ return element
76
+
77
+ return None
78
+
79
+
80
+ def get_hover_information(self, dataset: 'Dataset', text_position: TextPosition) -> types.Hover | None:
81
+ element = self.get_element_at(text_position)
82
+ if element is not None and element is not self:
83
+ return element.get_hover_information(dataset, text_position)
84
+ return None
85
+
86
+ def validate(self, dataset: 'Dataset') -> bool: ...
87
+ def validate_block(self, dataset: 'Dataset') -> bool: ...
88
+ def validate_children(self, dataset: 'Dataset') -> None: ...
89
+
90
+ def get_keys(self) -> list[str]:
91
+ """
92
+ Get all the keys from the block's key-value pairs.
93
+
94
+ Returns:
95
+ list[str]: A list of all keys in the block's key-value pairs, converted to lowercase.
96
+ """
97
+ keys = []
98
+ for value in self.values:
99
+ if value.is_key_value():
100
+ keys.append(value.key().lower())
101
+ return keys
102
+
103
+ def check_for_duplicates(self, key: str) -> bool:
104
+ """
105
+ Check if the given key has duplicates within the block's key-value pairs.
106
+ A key is considered a duplicate if it appears more than once in the block's key-value pairs.
107
+
108
+ Args:
109
+ key (str): The key to check for duplicates.
110
+
111
+ Returns:
112
+ bool: True if the key has duplicates, False otherwise.
113
+ """
114
+ keys = self.get_keys()
115
+ duplicate_count = len([k for k in keys if k == key.lower()])
116
+ return duplicate_count > 1 # > 1 bcs there's itself in the list
117
+
118
+ def add_diagnostic(self, type: DiagnosticType, location: TextRange, args: dict[str, Any] = {}) -> None:
119
+ self.document.diagnostics.add(type=type, location=location, args=args)
120
+
121
+ def add_semantic_token(self, type: SemanticTokenType, location: TextRange, modifiers: list[SemanticTokenModifier] = []) -> None:
122
+ self.document.semantic_tokens.add(type=type, location=location, modifiers=modifiers)
123
+
124
+
125
+
126
+ class ScriptBlock(Block, Element["BlockNode"]):
127
+ def __init__(self, document: 'Document',
128
+ type: str, id: str | None,
129
+ node: BlockNode, parent: Block,
130
+ comment: str) -> None:
131
+ super().__init__(document=document, type=type)
132
+ self.id: str | None = id
133
+ """
134
+ ID of the block.
135
+ Blocks are not required to have an ID: this is represented by None.
136
+ """
137
+
138
+ self.node: BlockNode = node
139
+ self.comment: str = comment
140
+
141
+ self.parent: Block = parent
142
+
143
+ self.references: list[Value] = []
144
+ """List of values that reference this block."""
145
+
146
+ def __repr__(self) -> str:
147
+ return f"ScriptBlock(type={self.type}, id={self.id})"
148
+
149
+ def __str__(self) -> str:
150
+ if self.id is None:
151
+ return f"{self.type}"
152
+ return f"{self.type}:{self.id}"
153
+
154
+ def get_element_at(self, text_position: TextPosition) -> 'ScriptBlock | Value | None':
155
+ result = super().get_element_at(text_position)
156
+ if result is not None:
157
+ return result
158
+
159
+ # check if it's within itself
160
+ node = self.node
161
+ if node.contains_position(text_position):
162
+ return self
163
+
164
+ return None
165
+
166
+
167
+ ## information
168
+
169
+ def get_root(self) -> 'Root':
170
+ current: Block = self
171
+ i = 0
172
+ while isinstance(current, ScriptBlock) and i < 1000:
173
+ current = current.parent
174
+ i += 1
175
+ assert isinstance(current, Root), "reached iteration limit without finding root"
176
+ return current
177
+
178
+ def get_description(self, dataset: 'Dataset') -> str:
179
+ if not dataset.is_script_block(self.type):
180
+ return ''
181
+ block_data = dataset.get_script_block_data(self.type)
182
+ return block_data.get('description', '')
183
+
184
+ def get_hover_information(self, dataset: 'Dataset', text_position: TextPosition) -> types.Hover | None:
185
+ node = self.node
186
+ type_node = node.type
187
+ if type_node is not None and type_node.to_range() == text_position:
188
+ # show a tree hierarchy of the parameter
189
+ tree = format_tree(self.get_tree())
190
+ desc = self.get_description(dataset)
191
+
192
+ # show link to ScriptsDocs
193
+ if dataset.is_script_block(self.type):
194
+ variant_tree = dataset.get_block_tree(self.type)
195
+ url = self.get_scriptsdocs_url(variant_tree)
196
+ tree += f"\n[Documentation]({url})"
197
+
198
+ # format the whole thing
199
+ if desc == "":
200
+ txt = tree
201
+ else:
202
+ txt = f"{tree}\n\n---\n\n{desc}"
203
+
204
+ return make_hover_information(
205
+ txt,
206
+ range=self.node.type.to_range() if self.node.type is not None else None,
207
+ )
208
+ # TODO: check ID hover info
209
+ return super().get_hover_information(dataset, text_position)
210
+
211
+
212
+ ## actions
213
+
214
+ def get_ref_id_range(self) -> 'TextRange':
215
+ assert self.node.id is not None, "Block must have an ID"
216
+ return self.node.id.to_range()
217
+
218
+ def get_referenced_to_ranges(self, source_document: 'Document | None' = None) -> list['TextRange']:
219
+ ranges: list['TextRange'] = []
220
+ if source_document is None:
221
+ source_document = self.document
222
+
223
+ # add ranges for all references to this block
224
+ for ref in self.references:
225
+ # only apply editing ranges for values that are in the same document
226
+ if ref.parent.document != source_document:
227
+ continue
228
+ ranges.append(ref.get_ref_id_range())
229
+
230
+ return ranges
231
+
232
+ def gather_linked_editing_ranges(self) -> list[TextRange]:
233
+ assert self.node.id is not None, "Block must have an ID"
234
+ ranges: list[TextRange] = []
235
+
236
+ ranges.append(self.get_ref_id_range())
237
+ ranges.extend(self.get_referenced_to_ranges())
238
+
239
+ return ranges
240
+
241
+
242
+
243
+ def get_linked_editing_ranges(self, text_position: TextPosition) -> list[TextRange] | None:
244
+ # TODO: if it's the ID of a module block
245
+ # we need to change specifically the module of the references only
246
+
247
+ # if we're editing its ID, then edit all occurrences of the ID in references to this block
248
+ node = self.node
249
+ id_node = node.id
250
+ if id_node is not None and id_node.to_range() == text_position:
251
+ # find all occurrences of this ID in references to this block
252
+ return self.gather_linked_editing_ranges()
253
+ return None
254
+
255
+
256
+ ## definition
257
+
258
+ def gather_id_definitions(self) -> list[types.LocationLink]:
259
+ # assert self.node.id is not None, "Block must have an ID"
260
+ # id_node = self.node.id
261
+
262
+ result: list[types.LocationLink] = []
263
+
264
+ for ref in self.references:
265
+ ref_id_range = ref.get_ref_id_range().to_lsp()
266
+
267
+ location = types.LocationLink(
268
+ target_uri=path_to_uri(ref.parent.document.path),
269
+ target_range=ref_id_range,
270
+ target_selection_range=ref_id_range,
271
+ )
272
+ result.append(location)
273
+
274
+ return result
275
+
276
+
277
+
278
+
279
+ def get_definition(self, text_position: 'TextPosition') -> list[types.LocationLink] | None:
280
+ # check if we're on the ID of the block
281
+ node = self.node
282
+ id_node = node.id
283
+ if id_node is not None and id_node.to_range() == text_position:
284
+ return self.gather_id_definitions()
285
+
286
+ return None
287
+
288
+
289
+ ## validation
290
+
291
+ def validate(self, dataset: 'Dataset') -> bool:
292
+ # validate self
293
+ if not self.validate_block(dataset):
294
+ # don't validate the rest since they are dependent
295
+ # on the data of this block
296
+ return False
297
+
298
+ # validate key-values
299
+ for value in self.values:
300
+ value.validate(dataset)
301
+
302
+ # validate children
303
+ self.validate_children(dataset)
304
+
305
+ return True
306
+
307
+ def validate_block(self, dataset: 'Dataset') -> bool:
308
+ """
309
+ Validate the block itself, without considering its children or key-values.
310
+ This includes:
311
+ - validating the block type
312
+ - validating its ID
313
+
314
+ Args:
315
+ dataset (Dataset): The dataset used for validation.
316
+ diagnostics (DiagnosticCollection): The collection to which any validation diagnostics should be added.
317
+
318
+ Returns:
319
+ bool: True if the block is valid, False otherwise.
320
+ """
321
+ type = self.type
322
+ node = self.node
323
+ if node.type is None or type == "":
324
+ return False
325
+
326
+ self.add_semantic_token(
327
+ SemanticTokenType.KEYWORD,
328
+ node.type.to_range()
329
+ )
330
+
331
+ # validate the type of the block
332
+ if not dataset.is_script_block(type):
333
+ self.add_diagnostic(
334
+ DiagnosticType.BLOCK_UNKNOWN_BLOCK,
335
+ node.type.to_range(),
336
+ {"type": type}
337
+ )
338
+ return False
339
+
340
+ # validate the ID of the block
341
+ if not self.validate_id(dataset):
342
+ return False
343
+
344
+ return True
345
+
346
+ def validate_id(self, dataset: 'Dataset') -> bool:
347
+ type = self.type
348
+ node = self.node
349
+ if node.type is None or type == "":
350
+ return False
351
+
352
+ # get ID data
353
+ block_data = dataset.get_script_block_data(self.type)
354
+ ID_data = block_data.get('ID')
355
+
356
+ # retrieve ID info
357
+ id = self.id
358
+ has_ID = id is not None
359
+
360
+ # no ID data means there shouldn't be any ID
361
+ if has_ID:
362
+ node_id = node.id
363
+ assert node_id is not None, "ID node should not be None when ID is present"
364
+
365
+ # add semantic token for the ID
366
+ # that the ID is valid or not
367
+ self.add_semantic_token(
368
+ SemanticTokenType.CLASS,
369
+ node_id.to_range(),
370
+ [SemanticTokenModifier.DECLARATION]
371
+ )
372
+
373
+ # there shouldn't be an ID
374
+ if ID_data is None:
375
+ self.add_diagnostic(
376
+ DiagnosticType.BLOCK_UNEXPECTED_ID,
377
+ node_id.to_range(),
378
+ {"type": type, "id": id}
379
+ )
380
+ return False
381
+
382
+ # it doesn't have ID needs
383
+ if ID_data is None:
384
+ return True
385
+
386
+ # check if ID is optional for this block with a specific parent type
387
+ optional_blocks = ID_data.get('optional')
388
+ if optional_blocks is not None:
389
+ parent_type = self.parent.type
390
+ if parent_type.lower() in [block.lower() for block in optional_blocks]:
391
+ return True # it's optional, so we skip any other checks
392
+
393
+ # used to check if the parent block requires an ID for this sub-block
394
+ parents_without = ID_data.get('parentsWithout')
395
+ should_have_id_from_parent = True
396
+ if parents_without is not None:
397
+ parent_type = self.parent.type
398
+ if parent_type.lower() in [block.lower() for block in parents_without]:
399
+ should_have_id_from_parent = False
400
+
401
+ # there should be an ID but there isn't one
402
+ if not has_ID:
403
+ if should_have_id_from_parent:
404
+ self.add_diagnostic(
405
+ DiagnosticType.BLOCK_MISSING_ID,
406
+ node.type.to_range(),
407
+ {"id": ID_data}
408
+ )
409
+ return False
410
+
411
+ # there is an ID, so validate it
412
+ else:
413
+ if not should_have_id_from_parent:
414
+ assert parents_without is not None, "parents_without should not be None when checking for unexpected ID with specific parent"
415
+ self.add_diagnostic(
416
+ DiagnosticType.BLOCK_HAS_ID_IN_PARENT,
417
+ node.type.to_range(),
418
+ {"type": type, "parent_type": self.parent.type, "invalid_blocks": parents_without}
419
+ )
420
+ return False
421
+
422
+ # verify that the ID can have spaces
423
+ can_have_spaces = ID_data.get('canHaveSpace', False)
424
+ if not can_have_spaces and " " in id:
425
+ self.add_diagnostic(
426
+ DiagnosticType.BLOCK_ID_CANNOT_CONTAIN_SPACES,
427
+ node.type.to_range(),
428
+ {"type": type, "id": id}
429
+ )
430
+ return False
431
+
432
+ # check if the ID is an accepted value
433
+ valid_IDs = ID_data.get('values')
434
+ if valid_IDs is not None:
435
+ if id not in valid_IDs:
436
+ self.add_diagnostic(
437
+ DiagnosticType.BLOCK_INVALID_ID,
438
+ node.type.to_range(),
439
+ {"type": type, "id": id, "validIDs": valid_IDs}
440
+ )
441
+ return False
442
+
443
+ # TODO: this needs to be modified to support a wider range of condition for variants
444
+ # for example specific parameters or parents defining variants
445
+
446
+ # whenever we need to consider it as its own type based on the provided ID
447
+ if ID_data.get('asType', False):
448
+ self.type = self.type + " " + id
449
+ self.id = None # set the ID to None since it is now part of the type
450
+
451
+ # forbidden IDs
452
+ forbidden_IDs = ID_data.get('forbidden')
453
+ if forbidden_IDs is not None:
454
+ if id in forbidden_IDs:
455
+ self.add_diagnostic(
456
+ DiagnosticType.BLOCK_FORBIDDEN_ID,
457
+ node.type.to_range(),
458
+ {"type": type, "id": id, "forbiddenIDs": forbidden_IDs}
459
+ )
460
+ return False
461
+
462
+ # TODO: implement ID translation diagnostics
463
+ # requires workspace handling setup
464
+
465
+ return True
466
+
467
+
468
+ def validate_children(self, dataset: 'Dataset') -> None:
469
+ # validate all children blocks
470
+ for child in self.children:
471
+ child.validate(dataset)
472
+
473
+
474
+ class Root(Block, Element["Chunk"]):
475
+ def __init__(self, document: 'Document', type: str, node: Chunk, comment: str) -> None:
476
+ super().__init__(document, type)
477
+ self.type = type
478
+ self.node = node
479
+ self.comment = comment
480
+
481
+ def get_root(self) -> 'Root':
482
+ return self
483
+
484
+ def validate(self, dataset: 'Dataset') -> bool:
485
+ # validate key-values
486
+ for value in self.values:
487
+ value.validate(dataset)
488
+
489
+ # validate children
490
+ self.validate_children(dataset)
491
+ return True
492
+
493
+ def validate_children(self, dataset: 'Dataset') -> None:
494
+ # validate all children blocks
495
+ for child in self.children:
496
+ child.validate(dataset)
497
+
498
+ # TODO: this only supports one module block rn
499
+ # but can multiple ones be used by the game ?
500
+ def get_module(self) -> 'ScriptBlock | None':
501
+ """
502
+ Retrieves the module block within the current root block, if it exists.
503
+ """
504
+ for child in self:
505
+ if child.type == "module":
506
+ return child
507
+ return None
508
+
509
+ def get_imports(self) -> set[str]:
510
+ """
511
+ Retrieves all the imports of the current module block.
512
+ """
513
+ imports: set[str] = set()
514
+
515
+ # find the module block
516
+ module = self.get_module()
517
+ if module is None:
518
+ return set(imports)
519
+
520
+ # search for import statements within the module block
521
+ for child in module:
522
+ if child.type != "import":
523
+ continue
524
+ # retrieve all the values
525
+ for value in child.values:
526
+ if not value.is_key_value():
527
+ imports.add(value.value())
528
+ return imports
529
+
530
+ def find_references(self, modules: Iterable[str], id: str, block_type: str) -> list['ScriptBlock']:
531
+ result: set['ScriptBlock'] = set()
532
+
533
+ # find the corresponding module block within the current root block
534
+ module: ScriptBlock | None = self.get_module()
535
+ if module is None:
536
+ return list(result)
537
+ module_id = module.id
538
+ if module_id is None or module_id not in modules:
539
+ return list(result)
540
+
541
+ # iterate over all children of the module block
542
+ # and find our block
543
+ for child in module:
544
+ if child.type == block_type and child.id == id:
545
+ result.add(child)
546
+
547
+ return list(result)