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,320 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, TYPE_CHECKING, Iterable
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from .. import CONFIGURATION_FILE_NAME, GLOBAL_CONFIGURATION_FILE
|
|
10
|
+
from . import WorkspaceType, VersionType
|
|
11
|
+
from .document import Document
|
|
12
|
+
from .mod import Mod, ModCollection
|
|
13
|
+
from .version import Version
|
|
14
|
+
from .config import ConfigurationModel
|
|
15
|
+
from ..providers.notifications import ZedNotification, send_progress_notification
|
|
16
|
+
from ..utils import merge_pydantic_models
|
|
17
|
+
from ..scripts.dataset import Dataset
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ..scripts.block import ScriptBlock
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Workspace:
|
|
25
|
+
workspace_cache: dict[WorkspaceType, dict[Path, 'Workspace']] = {}
|
|
26
|
+
global_configuration: ConfigurationModel | None = None
|
|
27
|
+
global_dataset: 'Dataset' = Dataset()
|
|
28
|
+
|
|
29
|
+
def __init__(self, folder: Path, workspace_type: WorkspaceType):
|
|
30
|
+
self.folder = folder
|
|
31
|
+
self.workspace_type = workspace_type
|
|
32
|
+
self.documents: dict[Path, Document] = {}
|
|
33
|
+
|
|
34
|
+
self.mods: ModCollection = ModCollection()
|
|
35
|
+
self.ignored_patterns: list[re.Pattern] = []
|
|
36
|
+
self.configuration: ConfigurationModel = self.update_configuration()
|
|
37
|
+
self.dataset: Dataset = Dataset()
|
|
38
|
+
self.dataset.load(self.configuration.dataset)
|
|
39
|
+
|
|
40
|
+
# cache workspace
|
|
41
|
+
Workspace.workspace_cache.setdefault(workspace_type, {})[folder] = self
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
return f"Workspace(type={self.workspace_type}, folder={self.folder})"
|
|
45
|
+
|
|
46
|
+
def load(self) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Retrieve every script files and cache them as Document instances.
|
|
49
|
+
"""
|
|
50
|
+
logging.info(f"Loading workspace: {self.folder}")
|
|
51
|
+
self.load_mods()
|
|
52
|
+
# self.load_documents()
|
|
53
|
+
self.load_documents()
|
|
54
|
+
|
|
55
|
+
def load_documents(self) -> None:
|
|
56
|
+
send_progress_notification(ZedNotification.LOADING_DOCUMENTS,
|
|
57
|
+
self.folder, 0.0, self.workspace_type)
|
|
58
|
+
|
|
59
|
+
# inside the workspace folder, find every txt files, and try to load them
|
|
60
|
+
# as documents
|
|
61
|
+
files = list(self.folder.rglob("*.txt"))
|
|
62
|
+
total_files = len(files)
|
|
63
|
+
last_progress = 0
|
|
64
|
+
step = 10
|
|
65
|
+
step_count = step * total_files / 100
|
|
66
|
+
for progress, path in enumerate(files):
|
|
67
|
+
self.load_document(path)
|
|
68
|
+
|
|
69
|
+
# send update notification to client
|
|
70
|
+
if progress - last_progress >= step_count:
|
|
71
|
+
logging.info(f"Loading progress: {progress}/{total_files}")
|
|
72
|
+
send_progress_notification(ZedNotification.LOADING_DOCUMENTS,
|
|
73
|
+
self.folder, progress / total_files * 100, self.workspace_type)
|
|
74
|
+
last_progress = progress
|
|
75
|
+
|
|
76
|
+
# send final progress notification
|
|
77
|
+
send_progress_notification(ZedNotification.LOADING_DOCUMENTS,
|
|
78
|
+
self.folder, 100, self.workspace_type)
|
|
79
|
+
|
|
80
|
+
def load_mods(self) -> None:
|
|
81
|
+
logging.info(f"Loading mods for workspace: {self.folder}")
|
|
82
|
+
# look for mod.info files
|
|
83
|
+
# one or more of these files are associated to a specific mod
|
|
84
|
+
for file in self.folder.rglob("mod.info"):
|
|
85
|
+
# only consider versioning and common folders
|
|
86
|
+
version = Version.find_or_make_version(file)
|
|
87
|
+
if version.type in {VersionType.VERSIONING, VersionType.COMMON}:
|
|
88
|
+
mod_folder = file.parent.parent
|
|
89
|
+
mod = Mod.find_or_make_mod(mod_folder, self)
|
|
90
|
+
mod.add_mod_info_file(file, version)
|
|
91
|
+
self.mods[mod_folder] = mod
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
# if it's OTHER, then it's probably not a mod file
|
|
95
|
+
# TODO: should we handle those ?
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def load_libraries() -> None:
|
|
99
|
+
logging.info("Loading libraries for all workspaces.")
|
|
100
|
+
|
|
101
|
+
# gather libraries from configs
|
|
102
|
+
config = Workspace.get_global_configuration()
|
|
103
|
+
|
|
104
|
+
configs = []
|
|
105
|
+
for workspace in Workspace.workspace_cache.get(WorkspaceType.PROJECT, {}).values():
|
|
106
|
+
configs.append(workspace.get_configuration())
|
|
107
|
+
merged_config = merge_pydantic_models(config, *configs)
|
|
108
|
+
|
|
109
|
+
# add each libraries to global library cache
|
|
110
|
+
libraries = merged_config.libraries
|
|
111
|
+
libraries = Workspace.filter_out_projects(libraries)
|
|
112
|
+
|
|
113
|
+
Workspace.workspace_cache[WorkspaceType.LIBRARY] = {}
|
|
114
|
+
for library in libraries:
|
|
115
|
+
Workspace.workspace_cache[WorkspaceType.LIBRARY][library] = Workspace(library, WorkspaceType.LIBRARY)
|
|
116
|
+
for library in Workspace.workspace_cache[WorkspaceType.LIBRARY].values():
|
|
117
|
+
library.load()
|
|
118
|
+
|
|
119
|
+
def should_ignore(self, path: Path) -> bool:
|
|
120
|
+
for pattern in self.ignored_patterns:
|
|
121
|
+
if pattern.search(str(path)):
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def load_document(self, path: Path) -> Document | None:
|
|
126
|
+
# skip if the path respects the ignored patterns
|
|
127
|
+
if self.should_ignore(path):
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
document = Document.find_or_make(self.dataset, path, self)
|
|
131
|
+
if document is not None:
|
|
132
|
+
self.mods.add_document(path, document)
|
|
133
|
+
self.documents[path] = document
|
|
134
|
+
return document
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def find_workspace(path: Path) -> 'Workspace | None':
|
|
139
|
+
for workspaces in Workspace.workspace_cache.values():
|
|
140
|
+
for folder, workspace in workspaces.items():
|
|
141
|
+
if folder in path.parents:
|
|
142
|
+
return workspace
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def find_or_make(path: Path) -> Document | None:
|
|
147
|
+
# find the document with the associated path
|
|
148
|
+
docs = Document.list_by_workspace()
|
|
149
|
+
for doc in docs.keys():
|
|
150
|
+
if doc.path == path:
|
|
151
|
+
return doc
|
|
152
|
+
|
|
153
|
+
workspace = Workspace.find_workspace(path)
|
|
154
|
+
if workspace is not None:
|
|
155
|
+
return workspace.load_document(path)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def filter_out_projects(libraries: set[Path]) -> set[Path]:
|
|
160
|
+
"""Filter out the libraries that are already present inside
|
|
161
|
+
folders of project workspaces since those will have already parsed that content.
|
|
162
|
+
|
|
163
|
+
Doesn't filter out libraries that are a folder above a project workspace however."""
|
|
164
|
+
project_folders = set(Workspace.workspace_cache.get(WorkspaceType.PROJECT, {}).keys())
|
|
165
|
+
|
|
166
|
+
return {lib for lib in libraries
|
|
167
|
+
if not any(lib.resolve().is_relative_to(proj.resolve())
|
|
168
|
+
for proj in project_folders)}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# searches
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def search_for_block_references(version: Version, modules: Iterable[str],
|
|
175
|
+
id: str, block_type: str) -> list['ScriptBlock']:
|
|
176
|
+
"""Searches for block references within the specified modules and version context.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
version (Version): The version context for the search.
|
|
180
|
+
modules (Iterable[str]): List of module names to search within.
|
|
181
|
+
id (str): ID of the block to search for.
|
|
182
|
+
block_type (str): Expected type of the block to search for.
|
|
183
|
+
"""
|
|
184
|
+
result: set['ScriptBlock'] = set()
|
|
185
|
+
|
|
186
|
+
# skip search for pre B42 versions
|
|
187
|
+
if version == Version.PRE_42:
|
|
188
|
+
return list(result)
|
|
189
|
+
|
|
190
|
+
# search into documents with a version that is general
|
|
191
|
+
versions = [Version.COMMON, Version.BASE_GAME, Version.OTHER]
|
|
192
|
+
documents = [doc for v in versions for doc in Document.documents_by_version.get(v, [])]
|
|
193
|
+
for document in documents:
|
|
194
|
+
if document.body is None: continue
|
|
195
|
+
refs = document.body.find_references(modules, id, block_type)
|
|
196
|
+
result.update(refs)
|
|
197
|
+
|
|
198
|
+
# iterate over each mods, and look for the closest
|
|
199
|
+
# version to the version we are looking into for each
|
|
200
|
+
# to look for references
|
|
201
|
+
for workspaces in Workspace.workspace_cache.values():
|
|
202
|
+
for workspace in workspaces.values():
|
|
203
|
+
for mod in workspace.mods.values():
|
|
204
|
+
for document in mod.documents.values():
|
|
205
|
+
if document.body is None: continue
|
|
206
|
+
refs = document.body.find_references(modules, id, block_type)
|
|
207
|
+
result.update(refs)
|
|
208
|
+
|
|
209
|
+
return list(result)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# configuration
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def find_workspace_of_configuration_file(path: Path) -> 'Workspace | None':
|
|
218
|
+
# only search in project workspaces
|
|
219
|
+
for workspace in Workspace.workspace_cache.get(WorkspaceType.PROJECT, {}).values():
|
|
220
|
+
if (workspace.get_configuration_path() == path):
|
|
221
|
+
return workspace
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def is_configuration_file(path: Path) -> bool:
|
|
226
|
+
return path.is_file() and path.name == CONFIGURATION_FILE_NAME
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def is_global_configuration_file(path: Path) -> bool:
|
|
230
|
+
return path.is_file() and path.resolve() == GLOBAL_CONFIGURATION_FILE
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def update_global_configuration(text: str | None = None) -> None:
|
|
234
|
+
logging.info("Updating global configuration from %s", GLOBAL_CONFIGURATION_FILE)
|
|
235
|
+
Workspace.global_configuration = Workspace.load_configuration(GLOBAL_CONFIGURATION_FILE, text=text)
|
|
236
|
+
logging.debug(Workspace.global_configuration)
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def update_configuration_file(path: Path, text: str | None = None) -> bool:
|
|
240
|
+
# skip if not a configuration file
|
|
241
|
+
if not Workspace.is_configuration_file(path):
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
# check if it's a global config file
|
|
245
|
+
if Workspace.is_global_configuration_file(path):
|
|
246
|
+
Workspace.update_global_configuration()
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
# find out if it's a workspace-relative config file
|
|
250
|
+
workspace = Workspace.find_workspace_of_configuration_file(path)
|
|
251
|
+
if workspace is None:
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
# if it's from a workspace, then we simply update its configuration
|
|
255
|
+
workspace.update_configuration(text=text)
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def update_all_configurations() -> None:
|
|
260
|
+
Workspace.update_global_configuration()
|
|
261
|
+
for workspace in Workspace.workspace_cache.get(WorkspaceType.PROJECT, {}).values():
|
|
262
|
+
workspace.update_configuration()
|
|
263
|
+
|
|
264
|
+
def get_configuration_path(self) -> Path:
|
|
265
|
+
return self.folder / CONFIGURATION_FILE_NAME
|
|
266
|
+
|
|
267
|
+
def update_configuration(self, text: str | None = None) -> ConfigurationModel:
|
|
268
|
+
logging.info("Updating configuration for workspace at %s", self.folder)
|
|
269
|
+
self.configuration = Workspace.load_configuration(self.get_configuration_path(), text=text)
|
|
270
|
+
logging.debug(self.configuration)
|
|
271
|
+
self.post_update_configuration()
|
|
272
|
+
return self.configuration
|
|
273
|
+
|
|
274
|
+
def post_update_configuration(self) -> None:
|
|
275
|
+
ignored = self.get_configuration().ignored
|
|
276
|
+
self.ignored_patterns = [re.compile(pattern) for pattern in ignored]
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def get_global_configuration() -> ConfigurationModel:
|
|
280
|
+
if Workspace.global_configuration is None:
|
|
281
|
+
Workspace.update_global_configuration()
|
|
282
|
+
assert Workspace.global_configuration is not None # to make Pylance happy
|
|
283
|
+
return Workspace.global_configuration
|
|
284
|
+
|
|
285
|
+
def get_configuration(self) -> ConfigurationModel:
|
|
286
|
+
if self.configuration is None:
|
|
287
|
+
self.update_configuration()
|
|
288
|
+
assert self.configuration is not None # to make Pylance happy
|
|
289
|
+
|
|
290
|
+
# merge with global configuration
|
|
291
|
+
global_config = Workspace.get_global_configuration()
|
|
292
|
+
|
|
293
|
+
return merge_pydantic_models(global_config, self.configuration)
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def load_configuration(config_path: Path, text: str | None = None) -> ConfigurationModel:
|
|
297
|
+
if not config_path.exists() or not config_path.is_file():
|
|
298
|
+
return ConfigurationModel()
|
|
299
|
+
|
|
300
|
+
# load the configuration file
|
|
301
|
+
try:
|
|
302
|
+
if text is not None:
|
|
303
|
+
data = json.loads(text)
|
|
304
|
+
else:
|
|
305
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
306
|
+
data = json.load(f)
|
|
307
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
308
|
+
logging.exception(f"Failed to load configuration file {config_path}. Loading default configuration.")
|
|
309
|
+
return ConfigurationModel()
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
# only validate a configuration file that is already a dictionary
|
|
313
|
+
if isinstance(data, dict):
|
|
314
|
+
return ConfigurationModel.model_validate(data)
|
|
315
|
+
|
|
316
|
+
logging.warning(f"Configuration file {config_path} should contain a dictionary as the root. Loading default configuration.")
|
|
317
|
+
return ConfigurationModel()
|
|
318
|
+
except ValidationError as e:
|
|
319
|
+
logging.warning(f"Configuration file validation error for {config_path}:\n{e}")
|
|
320
|
+
return ConfigurationModel()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"code": "en",
|
|
3
|
+
"strings": {
|
|
4
|
+
"PARSER_TOO_MANY_CLOSING_BRACKETS": "Too many closing brackets.",
|
|
5
|
+
"PARSER_BLOCK_MISSING_TYPE": "Block is missing a type.",
|
|
6
|
+
"PARSER_BLOCK_NOT_CLOSED": "Block not closed.",
|
|
7
|
+
"PARSER_UNPARSED_TOKENS": "Unparsed tokens found.",
|
|
8
|
+
|
|
9
|
+
"BLOCK_UNKNOWN_BLOCK": "'{type}' is an unknown script block.",
|
|
10
|
+
|
|
11
|
+
"BLOCK_UNEXPECTED_ID": "'{type}' block cannot have an ID ('{id}').",
|
|
12
|
+
"BLOCK_MISSING_ID": "'{type}' block is missing an ID.",
|
|
13
|
+
"BLOCK_HAS_ID_IN_PARENT": "'{type}' block cannot have an ID when inside parent block '{parent_type}', only for: {invalid_blocks}.",
|
|
14
|
+
"BLOCK_ID_CANNOT_CONTAIN_SPACES": "ID '{id}' for block '{type}' cannot contain spaces.",
|
|
15
|
+
"BLOCK_INVALID_ID": "'{type}' block has an invalid ID '{id}'. Valid IDs are: {validIDs}.",
|
|
16
|
+
"BLOCK_FORBIDDEN_ID": "'{type}' block has a forbidden ID '{id}'. Forbidden IDs are: {forbiddenIDs}.",
|
|
17
|
+
|
|
18
|
+
"VALUE_UNKNOWN_PARAMETER": "Unknown parameter '{key}' in block '{type}'.",
|
|
19
|
+
"VALUE_DEPRECATED_REPLACEMENT_VERSION": "This parameter is deprecated since version '{version}' and replaced by '{replacement}'. {description}",
|
|
20
|
+
"VALUE_DEPRECATED_REPLACEMENT": "This parameter is deprecated and replaced by '{replacement}'. {description}",
|
|
21
|
+
"VALUE_DEPRECATED_VERSION": "This parameter is deprecated since version '{version}'. {description}",
|
|
22
|
+
"VALUE_DEPRECATED": "This parameter is deprecated. {description}",
|
|
23
|
+
"VALUE_DUPLICATE": "Duplicate parameter '{key}' in block '{type}'.",
|
|
24
|
+
"VALUE_MISSING": "Parameter '{key}' is missing a value in block '{type}'.",
|
|
25
|
+
"VALUE_FORBIDDEN": "Parameter '{key}' in block '{type}' has forbidden values: {forbidden_values}. Allowed values are: {accepted_values}.",
|
|
26
|
+
"VALUE_INVALID_TYPE": "Type '{actual_type}' of '{key}' is invalid for value '{value}'. Expected type is '{expected_type}'.",
|
|
27
|
+
"VALUE_INVALID_OBJECT_FORMAT": "Values {values} for parameter '{key}' do not follow the expected 'key{key_value_separator}value' format.",
|
|
28
|
+
"VALUE_INVALID_OBJECT_KEY_TYPE": "Key '{obj_key}' for parameter '{key}' is of invalid type '{actual_type}'. Expected type is '{expected_type}'.",
|
|
29
|
+
"VALUE_INVALID_OBJECT_VALUE_TYPE": "Value '{obj_value}' for parameter '{key}' is of invalid type '{actual_type}'. Expected type is '{expected_type}'.",
|
|
30
|
+
"VALUE_WRONGLY_FORMATTED_KEY_VALUE_PAIR": "Parameter '{key}' in block '{type}' with value '{value}' is likely wrongly formatted. Verify you're not missing a comma or you're not using the '=' symbol incorrectly.",
|
|
31
|
+
"VALUE_WITH_NEWLINE": "Parameter '{key}' in block '{type}' has a newline in its value '{value}'. This is usually the symptom of a missing comma.",
|
|
32
|
+
"VALUE_INVALID_BLOCK_REFERENCE": "Value '{value}' has a wrongly formatted block reference. Expected format is 'module.block' or 'block'.",
|
|
33
|
+
"VALUE_CANNOT_PROVIDE_MODULE": "Value '{value}' cannot contain a module '{module}' reference, only direct block references are allowed.",
|
|
34
|
+
"VALUE_BLOCK_REF_CANNOT_BE_EMPTY": "Value '{value}' has an empty block reference, which is not allowed.",
|
|
35
|
+
"VALUE_UNMATCHED_BLOCK_REF": "Value '{value}' for parameter '{parameter}' does not match any existing block (expects '{expected_block}' block). Make sure the referenced block exists.",
|
|
36
|
+
"VALUE_MULTIPLE_BLOCK_REFS": "Multiple block references found for '{value}' for parameter '{parameter}' (expects '{expected_block}' block). Make sure duplicate block references are not present."
|
|
37
|
+
}
|
|
38
|
+
}
|
ZedScripts/main.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from lsprotocol import types
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from ..structure.lexer import TextRange
|
|
6
|
+
|
|
7
|
+
def make_linked_editing_ranges(ranges: list['TextRange']) -> types.LinkedEditingRanges:
|
|
8
|
+
# remove duplicate ranges
|
|
9
|
+
ranges = list(set(ranges))
|
|
10
|
+
|
|
11
|
+
# convert TextRange to types.Range
|
|
12
|
+
r = [range.to_lsp() for range in ranges]
|
|
13
|
+
return types.LinkedEditingRanges(ranges=r)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
r"""
|
|
2
|
+
Provides client and server capabilities tools
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from lsprotocol import types
|
|
8
|
+
|
|
9
|
+
from .semantic_tokens import get_tokens
|
|
10
|
+
|
|
11
|
+
_client_init: types.InitializeParams | None
|
|
12
|
+
|
|
13
|
+
def register_client_capabilities(params: types.InitializeParams):
|
|
14
|
+
global _client_init
|
|
15
|
+
_client_init = params
|
|
16
|
+
|
|
17
|
+
logging.info("Registering client capabilities")
|
|
18
|
+
|
|
19
|
+
client_info = params.client_info
|
|
20
|
+
if client_info is not None:
|
|
21
|
+
logging.info("Client info: %s %s", client_info.name, client_info.version)
|
|
22
|
+
else:
|
|
23
|
+
logging.info("Client info is not available")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## CLIENT CAPABILITIES
|
|
29
|
+
|
|
30
|
+
def get_diagnostics_capabilities() -> types.DiagnosticClientCapabilities | None:
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
assert _client_init is not None, "Client has not been initialized"
|
|
35
|
+
|
|
36
|
+
client_init = _client_init
|
|
37
|
+
client_capabilities = client_init.capabilities
|
|
38
|
+
text_document = client_capabilities.text_document
|
|
39
|
+
|
|
40
|
+
if text_document is None:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
return text_document.diagnostic
|
|
44
|
+
|
|
45
|
+
def is_support_pull_diagnostics() -> bool:
|
|
46
|
+
"""
|
|
47
|
+
Whenever pull mode for diagnostics is possible. It was added in
|
|
48
|
+
LSP 3.17 and VSCode's language server node package as of 09-2026
|
|
49
|
+
is at LSP 3.18.3 so some clients may not support it, simply for
|
|
50
|
+
other IDEs that don't get too many updates.
|
|
51
|
+
"""
|
|
52
|
+
return get_diagnostics_capabilities() is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
## SERVER CAPABILITIES
|
|
58
|
+
|
|
59
|
+
def get_server_capabilities() -> types.ServerCapabilities:
|
|
60
|
+
"""
|
|
61
|
+
Below are the explanation for diagnostics
|
|
62
|
+
|
|
63
|
+
### Diagnostics
|
|
64
|
+
Our diagnostics depend on other files so we prefer to diagnostic
|
|
65
|
+
the whole workspace.
|
|
66
|
+
They could take a while for large workspaces (i.e. the game files)
|
|
67
|
+
so we want to provide feedback when it takes a while.
|
|
68
|
+
Diagnostics in pull mode (diagnostic_provider != None) was only
|
|
69
|
+
introduced in LSP 3.17 and may not be supported by all clients.
|
|
70
|
+
"""
|
|
71
|
+
# diagnostic support in pull mode
|
|
72
|
+
if is_support_pull_diagnostics():
|
|
73
|
+
diagnostic_provider = types.DiagnosticOptions(
|
|
74
|
+
inter_file_dependencies = True,
|
|
75
|
+
workspace_diagnostics = True,
|
|
76
|
+
identifier = "zedscripts",
|
|
77
|
+
work_done_progress = True,
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
diagnostic_provider = None
|
|
81
|
+
|
|
82
|
+
# for onChange capabilities, for the token reparsing
|
|
83
|
+
# we should look into the delta method
|
|
84
|
+
# added in LSP 3.18
|
|
85
|
+
# https://pygls.readthedocs.io/en/latest/pygls/api-reference/types.html#lsprotocol.types.DidChangeTextDocumentParams
|
|
86
|
+
# https://pygls.readthedocs.io/en/latest/pygls/api-reference/types.html#lsprotocol.types.SemanticTokensOptions.full
|
|
87
|
+
# so we update the lexical tokens accordingly
|
|
88
|
+
# to then update the semantic tokens
|
|
89
|
+
# and only change the parts of the document that have actually been modified
|
|
90
|
+
|
|
91
|
+
# for now, we just retokenize the whole document
|
|
92
|
+
token_types, token_modifiers = get_tokens()
|
|
93
|
+
logging.debug("Token types: %s", token_types)
|
|
94
|
+
logging.debug("Token modifiers: %s", token_modifiers)
|
|
95
|
+
semantic_tokens_provider = types.SemanticTokensOptions(
|
|
96
|
+
legend = types.SemanticTokensLegend(
|
|
97
|
+
token_types = token_types,
|
|
98
|
+
token_modifiers = token_modifiers
|
|
99
|
+
),
|
|
100
|
+
full = True, # for deltas, we need to change that
|
|
101
|
+
work_done_progress = True,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return types.ServerCapabilities(
|
|
105
|
+
diagnostic_provider=diagnostic_provider,
|
|
106
|
+
semantic_tokens_provider=semantic_tokens_provider,
|
|
107
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
from typing import Any, TypedDict
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigurationType(enum.IntEnum):
|
|
6
|
+
DATA_SOURCE = enum.auto()
|
|
7
|
+
|
|
8
|
+
# TODO: implement fixed configuration values
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
default_configuration: dict[ConfigurationType, Any] = {
|
|
12
|
+
ConfigurationType.DATA_SOURCE: "latest"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Configuration:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.settings: dict[ConfigurationType, Any] = {}
|
|
20
|
+
|
|
21
|
+
def get(self, key: ConfigurationType, default=None):
|
|
22
|
+
return self.settings.get(key, default)
|
|
23
|
+
|
|
24
|
+
def set(self, key: ConfigurationType, value):
|
|
25
|
+
self.settings[key] = value
|
|
26
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
def is_port_available(port: int, host: str = "localhost", timeout: float = 0.5) -> bool:
|
|
8
|
+
"""Check if a port is available by attempting to bind to it."""
|
|
9
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
10
|
+
sock.settimeout(timeout)
|
|
11
|
+
try:
|
|
12
|
+
result = sock.connect_ex((host, port))
|
|
13
|
+
sock.close()
|
|
14
|
+
return result != 0 # Non-zero means port is not in use
|
|
15
|
+
except Exception:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def force_close_port(port: int) -> None:
|
|
20
|
+
"""Force close any process using the specified port."""
|
|
21
|
+
try:
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
# Windows: use netstat and taskkill
|
|
24
|
+
subprocess.run(
|
|
25
|
+
f'netstat -ano | findstr ":{port}" | findstr "LISTENING"',
|
|
26
|
+
shell=True,
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
)
|
|
30
|
+
# Alternative: use Get-Process on PowerShell
|
|
31
|
+
subprocess.run(
|
|
32
|
+
f'powershell -Command "Get-NetTCPConnection -LocalPort {port} -ErrorAction SilentlyContinue | Stop-Process -Force"',
|
|
33
|
+
shell=True,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
)
|
|
36
|
+
else:
|
|
37
|
+
# Linux/Mac: use lsof or fuser
|
|
38
|
+
try:
|
|
39
|
+
subprocess.run(
|
|
40
|
+
f"kill -9 $(lsof -t -i :{port})",
|
|
41
|
+
shell=True,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
timeout=2,
|
|
44
|
+
)
|
|
45
|
+
logging.debug(f"Forcefully closed port {port}")
|
|
46
|
+
except subprocess.TimeoutExpired:
|
|
47
|
+
logging.warning(f"Timeout while trying to close port {port}")
|
|
48
|
+
except Exception as e:
|
|
49
|
+
logging.debug(f"Could not force close port {port}: {e}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def wait_for_port_available(port: int, host: str = "localhost", max_retries: int = 10, retry_delay: float = 0.5) -> bool:
|
|
53
|
+
"""Wait for a port to become available, with retries."""
|
|
54
|
+
for attempt in range(max_retries):
|
|
55
|
+
if is_port_available(port, host):
|
|
56
|
+
return True
|
|
57
|
+
logging.debug(f"Port {port} still in use, waiting... (attempt {attempt + 1}/{max_retries})")
|
|
58
|
+
time.sleep(retry_delay)
|
|
59
|
+
return False
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lsprotocol.types import DiagnosticSeverity, DiagnosticTag
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from typing import TYPE_CHECKING, ClassVar, Any
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from lsprotocol import types
|
|
10
|
+
|
|
11
|
+
import ZedScripts
|
|
12
|
+
from ..utils import textrange_to_lsp
|
|
13
|
+
from ..enums.SyntaxErrorType import SyntaxErrorType
|
|
14
|
+
from ..structure.lexer import TextRange
|
|
15
|
+
from ..providers.locale import zedlocalizer
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ..enums.Diagnostic import DiagnosticType
|
|
19
|
+
|
|
20
|
+
type DiagnosticReport = (
|
|
21
|
+
types.UnchangedDocumentDiagnosticReport
|
|
22
|
+
| types.FullDocumentDiagnosticReport
|
|
23
|
+
)
|
|
24
|
+
"""Defines diagnostic report types to send to the client"""
|
|
25
|
+
|
|
26
|
+
type WorkspaceDiagnosticReport = (
|
|
27
|
+
types.WorkspaceUnchangedDocumentDiagnosticReport
|
|
28
|
+
| types.WorkspaceFullDocumentDiagnosticReport
|
|
29
|
+
)
|
|
30
|
+
"""Defines workspace diagnostic report types to send to the client"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DiagnosticDefinition:
|
|
34
|
+
by_type: ClassVar[dict[DiagnosticType, DiagnosticDefinition]] = {}
|
|
35
|
+
|
|
36
|
+
def __init__(self,
|
|
37
|
+
type: DiagnosticType,
|
|
38
|
+
severity: types.DiagnosticSeverity,
|
|
39
|
+
args: dict[str, Any] = {},
|
|
40
|
+
tags: list[DiagnosticTag] = [],
|
|
41
|
+
) -> None:
|
|
42
|
+
self.type: DiagnosticType = type
|
|
43
|
+
self.severity: types.DiagnosticSeverity = severity
|
|
44
|
+
self.args: dict[str, Any] = args
|
|
45
|
+
self.tags: list[DiagnosticTag] = tags
|
|
46
|
+
|
|
47
|
+
DiagnosticDefinition.by_type[type] = self
|
|
48
|
+
|
|
49
|
+
def get_name(self) -> str:
|
|
50
|
+
"""Provides an identifier for the diagnostic type."""
|
|
51
|
+
return self.type.name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DiagnosticInfo:
|
|
56
|
+
type: DiagnosticType
|
|
57
|
+
location: TextRange
|
|
58
|
+
args: dict[str, Any]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DiagnosticCollection(list[DiagnosticInfo]):
|
|
62
|
+
def to_lsp(self) -> list[types.Diagnostic]:
|
|
63
|
+
"""
|
|
64
|
+
Converts the different diagnostic information into LSP-compatible diagnostics.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
list[types.Diagnostic]: A list of LSP-compatible diagnostic objects.
|
|
68
|
+
"""
|
|
69
|
+
lsp_diagnostics: list[types.Diagnostic] = []
|
|
70
|
+
for diagnostic in self:
|
|
71
|
+
definition = DiagnosticDefinition.by_type[diagnostic.type]
|
|
72
|
+
|
|
73
|
+
# ensure that the correct arguments are always passed
|
|
74
|
+
for name, arg_type in definition.args.items():
|
|
75
|
+
assert name in diagnostic.args, f"Missing argument '{name}' for diagnostic '{definition.get_name()}'"
|
|
76
|
+
assert isinstance(diagnostic.args[name], arg_type), f"Argument '{name}' for diagnostic '{definition.get_name()}' must be of type '{arg_type.__name__}'"
|
|
77
|
+
|
|
78
|
+
lsp_diagnostics.append(
|
|
79
|
+
types.Diagnostic(
|
|
80
|
+
range=textrange_to_lsp(diagnostic.location),
|
|
81
|
+
message=zedlocalizer.localize_string(definition.type, definition,
|
|
82
|
+
args=diagnostic.args),
|
|
83
|
+
severity=definition.severity,
|
|
84
|
+
source=ZedScripts.SOURCE,
|
|
85
|
+
code=definition.get_name(),
|
|
86
|
+
tags=definition.tags
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return lsp_diagnostics
|
|
90
|
+
|
|
91
|
+
def add(self, type: DiagnosticType, location: TextRange, args: dict[str, Any] = {}) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Adds a new diagnostic to the collection.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
type (DiagnosticType): The type of the diagnostic.
|
|
97
|
+
location (TextRange): The location in the text where the diagnostic applies.
|
|
98
|
+
args (dict[str, Any], optional): Additional arguments for the diagnostic. Defaults to {}.
|
|
99
|
+
"""
|
|
100
|
+
self.append(DiagnosticInfo(type=type, location=location, args=args))
|
|
101
|
+
|