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,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,7 @@
1
+ from ZedScripts.server import zedserver
2
+
3
+ def main() -> None:
4
+ zedserver.startup()
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -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
+