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.
- ZedScripts/__about__.py +1 -0
- ZedScripts/__init__.py +38 -0
- ZedScripts/enums/Diagnostic.py +165 -0
- ZedScripts/enums/SyntaxErrorType.py +7 -0
- ZedScripts/environment/__init__.py +33 -0
- ZedScripts/environment/config.py +82 -0
- ZedScripts/environment/document.py +374 -0
- ZedScripts/environment/mod.py +62 -0
- ZedScripts/environment/version.py +202 -0
- ZedScripts/environment/workspace.py +320 -0
- ZedScripts/locale/en.json +38 -0
- ZedScripts/main.py +7 -0
- ZedScripts/providers/actions.py +13 -0
- ZedScripts/providers/capabilities.py +107 -0
- ZedScripts/providers/configuration.py +26 -0
- ZedScripts/providers/debug.py +59 -0
- ZedScripts/providers/diagnostics.py +101 -0
- ZedScripts/providers/hover.py +37 -0
- ZedScripts/providers/http.py +49 -0
- ZedScripts/providers/locale.py +80 -0
- ZedScripts/providers/notifications.py +45 -0
- ZedScripts/providers/semantic_tokens.py +189 -0
- ZedScripts/scripts/__init__.py +160 -0
- ZedScripts/scripts/block.py +547 -0
- ZedScripts/scripts/dataset.py +355 -0
- ZedScripts/scripts/value.py +706 -0
- ZedScripts/scripts/version.py +75 -0
- ZedScripts/server.py +288 -0
- ZedScripts/structure/ast.py +143 -0
- ZedScripts/structure/lexer.py +482 -0
- ZedScripts/structure/parser.py +243 -0
- ZedScripts/utils.py +85 -0
- zedscripts-0.0.2.dist-info/METADATA +105 -0
- zedscripts-0.0.2.dist-info/RECORD +37 -0
- zedscripts-0.0.2.dist-info/WHEEL +5 -0
- zedscripts-0.0.2.dist-info/licenses/LICENSE +21 -0
- zedscripts-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from lsprotocol import types
|
|
6
|
+
|
|
7
|
+
from . import VersionType
|
|
8
|
+
from .version import Version
|
|
9
|
+
from ..utils import path_to_uri, position_to_texposition
|
|
10
|
+
from ..enums.Diagnostic import DiagnosticType
|
|
11
|
+
from ..structure.lexer import Lexer, TokenCollection
|
|
12
|
+
from ..structure.parser import parse_tokens, chunk_to_root
|
|
13
|
+
from ..providers.diagnostics import DiagnosticReport, WorkspaceDiagnosticReport, DiagnosticCollection
|
|
14
|
+
from ..providers.notifications import ZedNotification, SetZedScriptsNotificationParams
|
|
15
|
+
from ..providers.semantic_tokens import SemanticTokenCollection, build_syntactic_tokens
|
|
16
|
+
from ..providers.actions import make_linked_editing_ranges
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .workspace import Workspace
|
|
23
|
+
from .mod import Mod
|
|
24
|
+
from ..server import ZedServer
|
|
25
|
+
from ..scripts.block import Block, Root
|
|
26
|
+
from ..scripts.dataset import Dataset
|
|
27
|
+
|
|
28
|
+
class Document:
|
|
29
|
+
documents: list['Document'] = []
|
|
30
|
+
documents_by_version: dict['Version', list['Document']] = {}
|
|
31
|
+
def __init__(self, path: Path, rootType: str, workspace: 'Workspace', version: 'Version') -> None:
|
|
32
|
+
self.rootType: str = rootType
|
|
33
|
+
"""The type of the root block of the document. If the document is not identified as a ZedScripts file,
|
|
34
|
+
then this class should not be instantiated."""
|
|
35
|
+
self.path: Path = path
|
|
36
|
+
"""The file system path to the document."""
|
|
37
|
+
self.lexical_tokens: TokenCollection = TokenCollection()
|
|
38
|
+
"""Holds the lexical tokens of the document after lexing. That is, the raw tokens generated from the source text."""
|
|
39
|
+
self.body: Root | None = None
|
|
40
|
+
"""Holds the root block of the document after parsing. None until properly parsed."""
|
|
41
|
+
self.workspace: 'Workspace' = workspace
|
|
42
|
+
"""The workspace the document belongs to."""
|
|
43
|
+
self.version: 'Version' = version
|
|
44
|
+
"""Holds the version type the document belongs to (versioning or common folder, base game etc)."""
|
|
45
|
+
self.text: str | None = None
|
|
46
|
+
"""Holds the raw text content of the document. None until the text is read from the file
|
|
47
|
+
or passed through Document.update_text."""
|
|
48
|
+
|
|
49
|
+
self.mod: 'Mod | None' = None
|
|
50
|
+
"""The mod this document belongs to, if any."""
|
|
51
|
+
|
|
52
|
+
self.semantic_tokens: SemanticTokenCollection = SemanticTokenCollection(self)
|
|
53
|
+
"""Holds semantic tokens for general highlighting. Populated from the validation process.
|
|
54
|
+
|
|
55
|
+
Without the validation process, this collection will not be populated so users
|
|
56
|
+
will not receive any syntax highlights besides syntactic ones."""
|
|
57
|
+
self.syntactic_semantic_tokens: SemanticTokenCollection = SemanticTokenCollection(self)
|
|
58
|
+
"""Holds semantic tokens for syntax highlighting, that is things like `{`, `}` and `,`.
|
|
59
|
+
|
|
60
|
+
This is used to not repopulate this list everytime we revalidate the document."""
|
|
61
|
+
|
|
62
|
+
self.diagnostics: DiagnosticCollection = DiagnosticCollection()
|
|
63
|
+
"""Holds general diagnostics for the document after validation."""
|
|
64
|
+
self.syntactic_diagnostics: DiagnosticCollection = DiagnosticCollection()
|
|
65
|
+
"""Holds syntactic diagnostics for the document after parsing."""
|
|
66
|
+
|
|
67
|
+
self._validation_version: int = 0
|
|
68
|
+
"""Simple identifier to keep track of the version of the document for validation purposes.
|
|
69
|
+
Incremented anytime the document content changed."""
|
|
70
|
+
self._needs_validation: bool = True
|
|
71
|
+
"""Indicates whether the document needs to be revalidated. If set to False,
|
|
72
|
+
then the document doesn't have any reasons to be revalidated as its diagnostics and
|
|
73
|
+
semantic tokens are up-to-date."""
|
|
74
|
+
|
|
75
|
+
# cache document instance
|
|
76
|
+
Document.documents.append(self)
|
|
77
|
+
Document.documents_by_version.setdefault(self.version, []).append(self)
|
|
78
|
+
|
|
79
|
+
self.parse()
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
return f"Document(version={self.version}, workspace={self.workspace}, mod={self.mod}, path={self.path})"
|
|
83
|
+
|
|
84
|
+
def set_mod(self, mod: 'Mod') -> None:
|
|
85
|
+
self.mod = mod
|
|
86
|
+
|
|
87
|
+
def make_zedscripts(self) -> None:
|
|
88
|
+
# import here to avoid circular dependency
|
|
89
|
+
from ..server import ZedServer
|
|
90
|
+
server = ZedServer.instance
|
|
91
|
+
if server is not None:
|
|
92
|
+
server.send_notification(
|
|
93
|
+
ZedNotification.SET_ZEDSCRIPTS,
|
|
94
|
+
SetZedScriptsNotificationParams(
|
|
95
|
+
uri=path_to_uri(self.path)
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def get_text(self) -> str:
|
|
100
|
+
if self.text is None:
|
|
101
|
+
self.text = self.path.read_text()
|
|
102
|
+
return self.text
|
|
103
|
+
|
|
104
|
+
def update_text(self, text: str) -> None:
|
|
105
|
+
"""Bump the version if it has changed."""
|
|
106
|
+
if self.get_text() != text:
|
|
107
|
+
self.text = text
|
|
108
|
+
self.bump()
|
|
109
|
+
|
|
110
|
+
def get_uri(self) -> str:
|
|
111
|
+
return path_to_uri(self.path)
|
|
112
|
+
|
|
113
|
+
def get_lsp_diagnostics(self) -> list[types.Diagnostic]:
|
|
114
|
+
lsp_diagnostics = self.diagnostics.to_lsp() + self.syntactic_diagnostics.to_lsp()
|
|
115
|
+
logging.debug(f"LSP Diagnostics for {self.get_uri()}: {len(lsp_diagnostics)}")
|
|
116
|
+
return lsp_diagnostics
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# document management
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def list_by_workspace() -> dict['Document', 'Workspace']:
|
|
123
|
+
return {document: document.workspace for document in Document.documents}
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def get_documents() -> list['Document']:
|
|
127
|
+
return Document.documents
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def find(path: Path) -> 'Document | None':
|
|
131
|
+
for document in Document.documents:
|
|
132
|
+
if document.path == path:
|
|
133
|
+
return document
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def make(dataset: 'Dataset', path: Path, workspace: 'Workspace') -> 'Document | None':
|
|
138
|
+
# find the rootType of the document
|
|
139
|
+
rootType = dataset.test_for_root(path)
|
|
140
|
+
if rootType is None:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
# find the version and skip if pre 42
|
|
144
|
+
# since we don't validate that
|
|
145
|
+
version = Version.find_or_make_version(path)
|
|
146
|
+
if version.type == VersionType.PRE_42:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
logging.debug(f"Creating document for path: {path}, rootType: {rootType}, version: {version}")
|
|
150
|
+
|
|
151
|
+
# if it is a valid ZedScripts document, create a new Document instance
|
|
152
|
+
document = Document(path, rootType, workspace, version)
|
|
153
|
+
document.make_zedscripts()
|
|
154
|
+
Document.documents.append(document)
|
|
155
|
+
|
|
156
|
+
return document
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def find_or_make(dataset: 'Dataset', path: Path, workspace: 'Workspace') -> 'Document | None':
|
|
160
|
+
# if we find one, we don't have to verify it is a ZedScripts file
|
|
161
|
+
# bcs it means the document didn't move
|
|
162
|
+
document = Document.find(path)
|
|
163
|
+
if document is None:
|
|
164
|
+
document = Document.make(dataset, path, workspace)
|
|
165
|
+
return document
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def delete(path: Path) -> None:
|
|
169
|
+
document = Document.find(path)
|
|
170
|
+
if document is not None:
|
|
171
|
+
Document.documents.remove(document)
|
|
172
|
+
|
|
173
|
+
# TODO: there might be some diagnostics cleanup needed here
|
|
174
|
+
# aka remove any diagnostics by the LSP associated with this
|
|
175
|
+
# document
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def rename(old_path: Path, new_path: Path) -> None:
|
|
179
|
+
# no previous document exists for the old path
|
|
180
|
+
document = Document.find(old_path)
|
|
181
|
+
if document is None:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# first make sure that the new path is a valid root in the dataset
|
|
185
|
+
rootType = document.workspace.dataset.test_for_root(new_path)
|
|
186
|
+
if rootType is None:
|
|
187
|
+
Document.delete(old_path)
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# update the document information with new path and new root type
|
|
191
|
+
document.path = new_path
|
|
192
|
+
document.rootType = rootType
|
|
193
|
+
document.make_zedscripts()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# this should mostly all be handled by the parser, to automatically mark files
|
|
198
|
+
# as having been reparsed, only if tokens changed
|
|
199
|
+
|
|
200
|
+
def mark_changed(self) -> None:
|
|
201
|
+
self._needs_validation = True
|
|
202
|
+
def clear_changed(self) -> None:
|
|
203
|
+
self._needs_validation = False
|
|
204
|
+
def has_changed(self) -> bool:
|
|
205
|
+
if self._needs_validation:
|
|
206
|
+
return True
|
|
207
|
+
return self.body is None
|
|
208
|
+
|
|
209
|
+
def bump(self) -> None:
|
|
210
|
+
"""
|
|
211
|
+
Increment the document version and mark it as needing validation.
|
|
212
|
+
This version is used as an identifier for the diagnostic reports.
|
|
213
|
+
"""
|
|
214
|
+
self._validation_version += 1
|
|
215
|
+
self.mark_changed()
|
|
216
|
+
|
|
217
|
+
# reset all diagnostics and tokens
|
|
218
|
+
self.diagnostics.clear()
|
|
219
|
+
self.syntactic_diagnostics.clear()
|
|
220
|
+
self.semantic_tokens.clear()
|
|
221
|
+
self.syntactic_semantic_tokens.clear()
|
|
222
|
+
|
|
223
|
+
logging.debug("Bumped document version to %d", self._validation_version)
|
|
224
|
+
|
|
225
|
+
def get_diagnostics_id(self) -> str:
|
|
226
|
+
"""
|
|
227
|
+
Generate a result ID based on the hash of the diagnostics.
|
|
228
|
+
This ensures that if diagnostics don't change (even if text changes),
|
|
229
|
+
the client knows to skip processing via the "unchanged" report.
|
|
230
|
+
"""
|
|
231
|
+
# Create a deterministic hash of the current diagnostics
|
|
232
|
+
diagnostics_str = str(sorted([
|
|
233
|
+
(d.type.value, d.location, d.args)
|
|
234
|
+
for d in self.diagnostics + self.syntactic_diagnostics
|
|
235
|
+
]))
|
|
236
|
+
return hashlib.md5(diagnostics_str.encode()).hexdigest()
|
|
237
|
+
|
|
238
|
+
def get_semantic_tokens_id(self) -> str:
|
|
239
|
+
"""
|
|
240
|
+
Generate a result ID based on the hash of the semantic tokens.
|
|
241
|
+
This ensures that if tokens don't change (even if text changes),
|
|
242
|
+
the client knows to skip processing via the "unchanged" report.
|
|
243
|
+
"""
|
|
244
|
+
# Create a deterministic hash of the current semantic tokens
|
|
245
|
+
tokens_str = str(sorted([
|
|
246
|
+
(t.type.value, t.range)
|
|
247
|
+
for t in self.semantic_tokens
|
|
248
|
+
]))
|
|
249
|
+
return hashlib.md5(tokens_str.encode()).hexdigest()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# notification response
|
|
254
|
+
|
|
255
|
+
def parse(self) -> None:
|
|
256
|
+
"""
|
|
257
|
+
Parse the new document text and update its syntactic structure and diagnostics.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
text (str): The new text content of the document.
|
|
261
|
+
"""
|
|
262
|
+
logging.debug("Parsing document: %s", self.get_uri())
|
|
263
|
+
# tokenize and parse the document text
|
|
264
|
+
self.lexical_tokens = Lexer.tokenize(self.get_text())
|
|
265
|
+
result = parse_tokens(self.lexical_tokens)
|
|
266
|
+
|
|
267
|
+
# set new syntactic diagnostics
|
|
268
|
+
syntactic_diagnostics = self.syntactic_diagnostics
|
|
269
|
+
syntactic_diagnostics.clear() # reset previous syntactic diagnostics
|
|
270
|
+
for error in result.errors:
|
|
271
|
+
syntactic_diagnostics.add(
|
|
272
|
+
type=DiagnosticType(error.type),
|
|
273
|
+
location=error.location
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# reparse the document body into a block structure for easier diagnostics
|
|
277
|
+
self.body = chunk_to_root(self, result.chunk, self.rootType)
|
|
278
|
+
build_syntactic_tokens(self)
|
|
279
|
+
|
|
280
|
+
def validate(self) -> None:
|
|
281
|
+
logging.debug("Validating document: %s", self.get_uri())
|
|
282
|
+
# clear old diagnostics and semantic tokens
|
|
283
|
+
self.diagnostics.clear()
|
|
284
|
+
self.semantic_tokens.clear()
|
|
285
|
+
|
|
286
|
+
# retrieve the starting point for validation
|
|
287
|
+
body = self.body
|
|
288
|
+
assert body is not None, f"Document ({self.get_uri()}) body should not be None before validation"
|
|
289
|
+
|
|
290
|
+
# validate the root block, which will validate its children
|
|
291
|
+
body.validate(self.workspace.dataset)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def on_document_changed(self, text: str) -> None:
|
|
296
|
+
self.update_text(text)
|
|
297
|
+
|
|
298
|
+
if not self.has_changed():
|
|
299
|
+
logging.debug("Document was not changed, skipping revalidation.")
|
|
300
|
+
return
|
|
301
|
+
self.clear_changed()
|
|
302
|
+
|
|
303
|
+
# reparse
|
|
304
|
+
logging.debug("Reparsing document: %s", self.get_uri())
|
|
305
|
+
self.parse()
|
|
306
|
+
|
|
307
|
+
def on_document_diagnostics(self, previous_result_id: str | None) -> DiagnosticReport:
|
|
308
|
+
# validate the document
|
|
309
|
+
self.validate()
|
|
310
|
+
|
|
311
|
+
result_id = self.get_diagnostics_id()
|
|
312
|
+
if (previous_result_id is not None
|
|
313
|
+
and previous_result_id == result_id):
|
|
314
|
+
return types.UnchangedDocumentDiagnosticReport(result_id)
|
|
315
|
+
|
|
316
|
+
return types.FullDocumentDiagnosticReport(
|
|
317
|
+
items=self.get_lsp_diagnostics(),
|
|
318
|
+
result_id=result_id)
|
|
319
|
+
|
|
320
|
+
def on_workspace_diagnostics(self, previous_result_id: str | None) -> WorkspaceDiagnosticReport:
|
|
321
|
+
self.validate()
|
|
322
|
+
|
|
323
|
+
# verify that the diagnostics didn't move
|
|
324
|
+
# and if they didn't then send an unchanged report
|
|
325
|
+
result_id = self.get_diagnostics_id()
|
|
326
|
+
if (previous_result_id is not None
|
|
327
|
+
and previous_result_id == result_id):
|
|
328
|
+
return types.WorkspaceUnchangedDocumentDiagnosticReport(
|
|
329
|
+
uri=self.get_uri(),
|
|
330
|
+
result_id=result_id,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return types.WorkspaceFullDocumentDiagnosticReport(
|
|
334
|
+
uri=self.get_uri(),
|
|
335
|
+
items=self.get_lsp_diagnostics(),
|
|
336
|
+
result_id=result_id,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def on_semantic_tokens(self) -> types.SemanticTokens:
|
|
340
|
+
return types.SemanticTokens(
|
|
341
|
+
data=SemanticTokenCollection(self, *(self.semantic_tokens + self.syntactic_semantic_tokens)).to_lsp(),
|
|
342
|
+
result_id=self.get_semantic_tokens_id(), # useless since they don't send it back ?
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def on_hover(self, position: types.Position) -> types.Hover | None:
|
|
346
|
+
text_position = position_to_texposition(position)
|
|
347
|
+
if self.body is None:
|
|
348
|
+
return None
|
|
349
|
+
return self.body.get_hover_information(self.workspace.dataset, text_position)
|
|
350
|
+
|
|
351
|
+
def on_linked_editing_range(self, position: types.Position) -> types.LinkedEditingRanges | None:
|
|
352
|
+
if self.body is None:
|
|
353
|
+
return None
|
|
354
|
+
text_position = position_to_texposition(position)
|
|
355
|
+
|
|
356
|
+
root = self.body
|
|
357
|
+
element = root.get_element_at(text_position)
|
|
358
|
+
if element is not None:
|
|
359
|
+
ranges = element.get_linked_editing_ranges(text_position)
|
|
360
|
+
if ranges is None:
|
|
361
|
+
return None
|
|
362
|
+
return make_linked_editing_ranges(ranges)
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
def on_definition(self, position: types.Position) -> list[types.LocationLink] | None:
|
|
366
|
+
if self.body is None:
|
|
367
|
+
return None
|
|
368
|
+
text_position = position_to_texposition(position)
|
|
369
|
+
|
|
370
|
+
root = self.body
|
|
371
|
+
element = root.get_element_at(text_position)
|
|
372
|
+
if element is not None:
|
|
373
|
+
return element.get_definition(text_position)
|
|
374
|
+
return None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from . import VersionType
|
|
5
|
+
from .version import Version
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .workspace import Workspace
|
|
9
|
+
from .document import Document
|
|
10
|
+
|
|
11
|
+
class Mod:
|
|
12
|
+
"""Represents a mod inside a specific workspace.
|
|
13
|
+
|
|
14
|
+
It is generally used to easily manage and retrieve files associated to a specific version.
|
|
15
|
+
Since different mod sources can have different versioning structure, we need to handle them
|
|
16
|
+
independently from each others when searching for block references."""
|
|
17
|
+
mods: dict[Path, 'Mod'] = {}
|
|
18
|
+
def __init__(self, folder: Path, workspace: 'Workspace'):
|
|
19
|
+
self.folder: Path = folder
|
|
20
|
+
self.workspace: 'Workspace' = workspace
|
|
21
|
+
self.documents: dict[Path, 'Document'] = {}
|
|
22
|
+
self.mod_info_files: dict[Path, 'Version'] = {}
|
|
23
|
+
|
|
24
|
+
def add_mod_info_file(self, path: Path, version: 'Version') -> None:
|
|
25
|
+
self.mod_info_files[path] = version
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def find_or_make_mod(folder: Path, workspace: 'Workspace') -> 'Mod':
|
|
29
|
+
folder = folder.resolve()
|
|
30
|
+
for mod_folder, mod in Mod.mods.items():
|
|
31
|
+
if folder.is_relative_to(mod_folder):
|
|
32
|
+
return mod
|
|
33
|
+
mod = Mod(folder, workspace)
|
|
34
|
+
Mod.mods[folder] = mod
|
|
35
|
+
return mod
|
|
36
|
+
|
|
37
|
+
def get_by_version(self) -> dict[Version, list['Document']]:
|
|
38
|
+
"""Retrieve documents grouped by their version."""
|
|
39
|
+
result: dict[Version, list['Document']] = {}
|
|
40
|
+
for doc in self.documents.values():
|
|
41
|
+
result.setdefault(doc.version, []).append(doc)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
def get_closest_from_version(self, version: 'Version') -> list['Document']:
|
|
45
|
+
"""Retrieve the documents of the closest version equal or below of the provided version."""
|
|
46
|
+
# only keep versions that use versioning
|
|
47
|
+
by_version = self.get_by_version()
|
|
48
|
+
versions = by_version.keys()
|
|
49
|
+
versions_filtered = [v for v in versions if v.type == VersionType.VERSIONING]
|
|
50
|
+
|
|
51
|
+
closest_version = version.find_closest_below(versions_filtered)
|
|
52
|
+
if closest_version is None:
|
|
53
|
+
return []
|
|
54
|
+
return by_version[closest_version]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ModCollection(dict[Path, Mod]):
|
|
58
|
+
def add_document(self, path: Path, document: 'Document') -> None:
|
|
59
|
+
for mod_path, mod in self.items():
|
|
60
|
+
if path.is_relative_to(mod_path):
|
|
61
|
+
mod.documents[path] = document
|
|
62
|
+
return
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from . import VersionType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SCRIPT_FILE_VERSION_PATTERN = r"(?:(?P<base>\w+))\/(?:(?P<version>(?:4\d(?:\.\d+)*)|common)\/)?(?:(?P<media>media)|(?P<modinfo>mod\.info))"
|
|
10
|
+
"""
|
|
11
|
+
Pattern to match script file versions in the file path.
|
|
12
|
+
|
|
13
|
+
- The `base` is the folder before the versioning/common or media folder.
|
|
14
|
+
- The `version` is the folder containing the version number or the `common` folder. (can be absent)
|
|
15
|
+
- The `media` is the folder containing most modding files.
|
|
16
|
+
- The `modinfo` means the path is a `mod.info` file.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
_DEFAULT_MAJOR = 42
|
|
20
|
+
"""Default major version number"""
|
|
21
|
+
_DEFAULT_MINOR = 0
|
|
22
|
+
"""Default minor version number"""
|
|
23
|
+
|
|
24
|
+
class Version:
|
|
25
|
+
# static instances
|
|
26
|
+
COMMON: 'Version'
|
|
27
|
+
# POST_42: 'Version' # that one is dynamic based on the source
|
|
28
|
+
PRE_42: 'Version'
|
|
29
|
+
OTHER: 'Version'
|
|
30
|
+
"""`Any` in old ZedScripts."""
|
|
31
|
+
BASE_GAME: 'Version'
|
|
32
|
+
|
|
33
|
+
key_format: str = "{type}.{major}.{minor}"
|
|
34
|
+
version_map: dict[str, 'Version'] = {}
|
|
35
|
+
|
|
36
|
+
def __init__(self, source: str):
|
|
37
|
+
self.source: str = source
|
|
38
|
+
type, major, minor = Version.get_type(source)
|
|
39
|
+
self.type: VersionType = type
|
|
40
|
+
self.major: int = major
|
|
41
|
+
self.minor: int = minor
|
|
42
|
+
|
|
43
|
+
self.version: str = Version.format_version(type, major, minor)
|
|
44
|
+
Version.version_map[self.version] = self
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
return f"Version(source={self.source}, type={self.type}, major={self.major}, minor={self.minor})"
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def format_version(type: VersionType, major: int, minor: int) -> str:
|
|
51
|
+
"""Format a version string for easy comparison and storage."""
|
|
52
|
+
return Version.key_format.format(type=type, major=major, minor=minor)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def get_type(source: str) -> tuple[VersionType, int, int]:
|
|
56
|
+
# TODO: implement
|
|
57
|
+
|
|
58
|
+
match source:
|
|
59
|
+
case VersionType.COMMON:
|
|
60
|
+
return VersionType.COMMON, _DEFAULT_MAJOR, _DEFAULT_MINOR
|
|
61
|
+
case VersionType.PRE_42:
|
|
62
|
+
return VersionType.PRE_42, _DEFAULT_MAJOR, _DEFAULT_MINOR
|
|
63
|
+
case VersionType.OTHER:
|
|
64
|
+
return VersionType.OTHER, _DEFAULT_MAJOR, _DEFAULT_MINOR
|
|
65
|
+
case VersionType.BASE_GAME:
|
|
66
|
+
return VersionType.BASE_GAME, _DEFAULT_MAJOR, _DEFAULT_MINOR
|
|
67
|
+
|
|
68
|
+
# try to find the version based on the source string
|
|
69
|
+
parts = source.split('.')
|
|
70
|
+
if len(parts) == 0:
|
|
71
|
+
logging.warning("Source string is empty.")
|
|
72
|
+
return VersionType.OTHER, _DEFAULT_MAJOR, _DEFAULT_MINOR
|
|
73
|
+
|
|
74
|
+
# try to convert to int the major part
|
|
75
|
+
try:
|
|
76
|
+
major = int(parts[0])
|
|
77
|
+
except ValueError:
|
|
78
|
+
logging.warning("Source string does not start with a valid integer.")
|
|
79
|
+
return VersionType.OTHER, _DEFAULT_MAJOR, _DEFAULT_MINOR
|
|
80
|
+
|
|
81
|
+
# values under 42 are just invalid and never handled by any version of the game
|
|
82
|
+
if major < 42:
|
|
83
|
+
logging.warning("Source string has a major version under 42, which is invalid.")
|
|
84
|
+
return VersionType.OTHER, _DEFAULT_MAJOR, _DEFAULT_MINOR
|
|
85
|
+
|
|
86
|
+
# retrieve the minor if present
|
|
87
|
+
minor = 0
|
|
88
|
+
if len(parts) > 1:
|
|
89
|
+
try:
|
|
90
|
+
minor = int(parts[1])
|
|
91
|
+
except ValueError:
|
|
92
|
+
logging.warning("Source string has an invalid minor version.")
|
|
93
|
+
return VersionType.OTHER, _DEFAULT_MAJOR, _DEFAULT_MINOR
|
|
94
|
+
|
|
95
|
+
# other values after minor are ignored by the game
|
|
96
|
+
# so no need to retrieve those
|
|
97
|
+
|
|
98
|
+
return VersionType.VERSIONING, major, minor
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def from_string(version_str: str) -> 'Version':
|
|
102
|
+
"""
|
|
103
|
+
Convert a version string to a type, major and minor information and check if
|
|
104
|
+
the version string already exists in the version map, which will be returned.
|
|
105
|
+
|
|
106
|
+
Else, a new Version instance will be created and returned.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
version_str (str): The version string to be converted into a Version instance,
|
|
110
|
+
which comes from the file path usually.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Version: The Version instance corresponding to the given version string.
|
|
114
|
+
"""
|
|
115
|
+
type, major, minor = Version.get_type(version_str)
|
|
116
|
+
key = Version.format_version(type, major, minor)
|
|
117
|
+
if key in Version.version_map:
|
|
118
|
+
return Version.version_map[key]
|
|
119
|
+
return Version(version_str)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def find_or_make_version(path: Path) -> 'Version':
|
|
124
|
+
path_posix = path.as_posix()
|
|
125
|
+
|
|
126
|
+
# try to match the version pattern in the path
|
|
127
|
+
m = re.compile(SCRIPT_FILE_VERSION_PATTERN).search(path_posix)
|
|
128
|
+
if not m or len(m.groupdict()) == 0:
|
|
129
|
+
return Version.OTHER
|
|
130
|
+
|
|
131
|
+
groups = m.groupdict()
|
|
132
|
+
|
|
133
|
+
# try to match a version string in the path
|
|
134
|
+
version_str = groups.get("version")
|
|
135
|
+
if version_str:
|
|
136
|
+
return Version.from_string(version_str)
|
|
137
|
+
|
|
138
|
+
# verify if it is the PZ source folder which doesn't use versioning folders
|
|
139
|
+
# linux uses projectzomboid while windows uses ProjectZomboid
|
|
140
|
+
base = groups.get("base")
|
|
141
|
+
if base in ["projectzomboid", "ProjectZomboid"]:
|
|
142
|
+
# we can check that in the base folder there is a `projectzomboid.jar`
|
|
143
|
+
# file which would mean it is B42
|
|
144
|
+
# using the match, we can retrieve the path of the base
|
|
145
|
+
base_path = path_posix[:m.start() + len(base)]
|
|
146
|
+
jar_path = Path(f"{base_path}/projectzomboid.jar")
|
|
147
|
+
if jar_path.exists() and jar_path.is_file():
|
|
148
|
+
return Version.BASE_GAME
|
|
149
|
+
|
|
150
|
+
# else, we see if this is a B41 file
|
|
151
|
+
# that is it has a media folder or it's a mod.info file
|
|
152
|
+
if groups.get("media") or groups.get("modinfo"):
|
|
153
|
+
return Version.PRE_42
|
|
154
|
+
|
|
155
|
+
# we didn't find any indicator for a specific version
|
|
156
|
+
return Version.OTHER
|
|
157
|
+
|
|
158
|
+
def find_closest_below(self, others: list['Version']) -> 'Version | None':
|
|
159
|
+
others_str = [str(other) for other in others]
|
|
160
|
+
others_str.sort()
|
|
161
|
+
|
|
162
|
+
# if self is not using versioning, then we simply retrieve the latest
|
|
163
|
+
if self.type != VersionType.VERSIONING:
|
|
164
|
+
return Version(others_str[-1])
|
|
165
|
+
|
|
166
|
+
# check if it's itself in the list
|
|
167
|
+
if str(self) in others_str:
|
|
168
|
+
return self
|
|
169
|
+
|
|
170
|
+
# we now insert self into the sorted list to find the closest below
|
|
171
|
+
self_str = str(self)
|
|
172
|
+
others_str.append(self_str)
|
|
173
|
+
others_str.sort() # sort again of course
|
|
174
|
+
|
|
175
|
+
# find self in the sorted list
|
|
176
|
+
self_index = others_str.index(self_str)
|
|
177
|
+
|
|
178
|
+
# if self is not the first element, then we return the element just before it
|
|
179
|
+
if self_index > 0:
|
|
180
|
+
return Version(others_str[self_index - 1])
|
|
181
|
+
|
|
182
|
+
# if it's at the first position, we pick the version above
|
|
183
|
+
# TODO: this is generally not a common case, probably should verify how
|
|
184
|
+
# the game handles it
|
|
185
|
+
if self_index == 0 and len(others_str) > 1:
|
|
186
|
+
return Version(others_str[self_index + 1])
|
|
187
|
+
|
|
188
|
+
logging.warning("No closest below version found for %s", self)
|
|
189
|
+
|
|
190
|
+
# if we reach here, it means there is no closest below version
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
Version.COMMON = Version(VersionType.COMMON)
|
|
200
|
+
Version.PRE_42 = Version(VersionType.PRE_42)
|
|
201
|
+
Version.OTHER = Version(VersionType.OTHER)
|
|
202
|
+
Version.BASE_GAME = Version(VersionType.BASE_GAME)
|