avrae-ls 0.3.1__py3-none-any.whl → 0.4.1__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.
avrae_ls/argparser.py CHANGED
@@ -2,7 +2,7 @@ import collections
2
2
  import itertools
3
3
  import re
4
4
  import string
5
- from typing import Iterator
5
+ from typing import ClassVar, Iterator
6
6
 
7
7
 
8
8
  class BadArgument(Exception):
@@ -169,6 +169,19 @@ def argparse(args, character=None, splitter=argsplit, parse_ephem=True) -> "Pars
169
169
 
170
170
 
171
171
  class ParsedArguments:
172
+ ATTRS: ClassVar[list[str]] = []
173
+ METHODS: ClassVar[list[str]] = [
174
+ "get",
175
+ "last",
176
+ "adv",
177
+ "join",
178
+ "ignore",
179
+ "update",
180
+ "update_nx",
181
+ "set_context",
182
+ "add_context",
183
+ ]
184
+
172
185
  def __init__(self, args: list[Argument]):
173
186
  self._parsed = collections.defaultdict(lambda: [])
174
187
  for arg in args:
@@ -299,7 +312,7 @@ class ParsedArguments:
299
312
  def __contains__(self, item):
300
313
  return item in self._parsed and self._parsed[item]
301
314
 
302
- def __len__(self):
315
+ def __len__(self) -> int:
303
316
  return len(self._parsed)
304
317
 
305
318
  def __setitem__(self, key, value):
@@ -317,7 +330,7 @@ class ParsedArguments:
317
330
  if arg in self._parsed:
318
331
  del self._parsed[arg]
319
332
 
320
- def __iter__(self):
333
+ def __iter__(self) -> Iterator[str]:
321
334
  return iter(self._parsed.keys())
322
335
 
323
336
  def __repr__(self):
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Iterable, List, Sequence
9
+
10
+ from lsprotocol import types
11
+
12
+ from .codes import MISSING_GVAR_CODE, UNDEFINED_NAME_CODE, UNSUPPORTED_IMPORT_CODE
13
+ from .parser import DraconicBlock, find_draconic_blocks
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ # Workspace-relative file that can extend snippet-based code actions without touching code.
18
+ SNIPPET_FILENAME = ".avraels.snippets.json"
19
+
20
+
21
+ @dataclass
22
+ class Snippet:
23
+ key: str
24
+ title: str
25
+ body: str
26
+ description: str | None = None
27
+ kind: str = types.CodeActionKind.RefactorRewrite
28
+
29
+
30
+ DEFAULT_SNIPPETS: tuple[Snippet, ...] = (
31
+ Snippet(
32
+ key="drac2Wrapper",
33
+ title="Wrap in <drac2>…</drac2>",
34
+ body="<drac2>\n{content}\n</drac2>\n",
35
+ description="Wrap the current selection or file in a draconic block.",
36
+ kind=types.CodeActionKind.RefactorRewrite,
37
+ ),
38
+ )
39
+
40
+
41
+ def code_actions_for_document(
42
+ source: str,
43
+ params: types.CodeActionParams,
44
+ workspace_root: Path,
45
+ ) -> List[types.CodeAction]:
46
+ """Collect code actions for a document without requiring a running server."""
47
+ actions: list[types.CodeAction] = []
48
+ blocks = find_draconic_blocks(source)
49
+ snippets = _load_snippets(workspace_root)
50
+ only_kinds = list(params.context.only or [])
51
+
52
+ actions.extend(_snippet_actions(source, params, snippets, only_kinds, blocks))
53
+ actions.extend(_diagnostic_actions(source, params, blocks, only_kinds))
54
+ return actions
55
+
56
+
57
+ def _diagnostic_actions(
58
+ source: str,
59
+ params: types.CodeActionParams,
60
+ blocks: list[DraconicBlock],
61
+ only_kinds: Sequence[str],
62
+ ) -> Iterable[types.CodeAction]:
63
+ for diag in params.context.diagnostics or []:
64
+ if diag.code == UNDEFINED_NAME_CODE and _kind_allowed(types.CodeActionKind.QuickFix, only_kinds):
65
+ name = (diag.data or {}).get("name") if isinstance(diag.data, dict) else None
66
+ if name:
67
+ yield _stub_variable_action(source, params.text_document.uri, blocks, diag, name)
68
+ if diag.code == MISSING_GVAR_CODE and _kind_allowed(types.CodeActionKind.QuickFix, only_kinds):
69
+ gvar_id = (diag.data or {}).get("gvar") if isinstance(diag.data, dict) else None
70
+ if gvar_id:
71
+ yield _using_stub_action(source, params.text_document.uri, blocks, diag, gvar_id)
72
+ if diag.code == UNSUPPORTED_IMPORT_CODE and _kind_allowed(types.CodeActionKind.QuickFix, only_kinds):
73
+ module = (diag.data or {}).get("module") if isinstance(diag.data, dict) else None
74
+ yield _rewrite_import_action(source, params.text_document.uri, diag, module)
75
+
76
+
77
+ def _snippet_actions(
78
+ source: str,
79
+ params: types.CodeActionParams,
80
+ snippets: Sequence[Snippet],
81
+ only_kinds: Sequence[str],
82
+ blocks: list[DraconicBlock],
83
+ ) -> Iterable[types.CodeAction]:
84
+ # Skip wrapper suggestions when a draconic block already exists.
85
+ has_draconic = bool(blocks)
86
+ selection_text = _text_in_range(source, params.range)
87
+ for snippet in snippets:
88
+ if not _kind_allowed(snippet.kind, only_kinds):
89
+ continue
90
+ if snippet.key == "drac2Wrapper" and has_draconic:
91
+ continue
92
+ rendered = _render_snippet(snippet, selection_text or source)
93
+ edit_range = params.range
94
+ if not selection_text:
95
+ edit_range = _full_range(source)
96
+ edit = types.TextEdit(range=edit_range, new_text=rendered)
97
+ action = types.CodeAction(
98
+ title=snippet.title,
99
+ kind=snippet.kind,
100
+ edit=types.WorkspaceEdit(changes={params.text_document.uri: [edit]}),
101
+ )
102
+ if snippet.description:
103
+ action.diagnostics = None
104
+ action.command = None
105
+ yield action
106
+
107
+
108
+ def _stub_variable_action(
109
+ source: str,
110
+ uri: str,
111
+ blocks: list[DraconicBlock],
112
+ diag: types.Diagnostic,
113
+ name: str,
114
+ ) -> types.CodeAction:
115
+ insertion_line, indent = _block_insertion(blocks, diag.range.start.line, source)
116
+ edit = types.TextEdit(
117
+ range=types.Range(
118
+ start=types.Position(line=insertion_line, character=indent),
119
+ end=types.Position(line=insertion_line, character=indent),
120
+ ),
121
+ new_text=f"{' ' * indent}{name} = None\n",
122
+ )
123
+ return types.CodeAction(
124
+ title=f"Create stub variable '{name}'",
125
+ kind=types.CodeActionKind.QuickFix,
126
+ diagnostics=[diag],
127
+ edit=types.WorkspaceEdit(changes={uri: [edit]}),
128
+ )
129
+
130
+
131
+ def _using_stub_action(
132
+ source: str,
133
+ uri: str,
134
+ blocks: list[DraconicBlock],
135
+ diag: types.Diagnostic,
136
+ gvar_id: str,
137
+ ) -> types.CodeAction:
138
+ alias = _sanitize_symbol(gvar_id)
139
+ insertion_line, indent = _block_insertion(blocks, diag.range.start.line, source)
140
+ text = f"{' ' * indent}using({alias}=\"{gvar_id}\")\n"
141
+ edit = types.TextEdit(
142
+ range=types.Range(
143
+ start=types.Position(line=insertion_line, character=indent),
144
+ end=types.Position(line=insertion_line, character=indent),
145
+ ),
146
+ new_text=text,
147
+ )
148
+ return types.CodeAction(
149
+ title=f"Add using() stub for gvar '{gvar_id}'",
150
+ kind=types.CodeActionKind.QuickFix,
151
+ diagnostics=[diag],
152
+ edit=types.WorkspaceEdit(changes={uri: [edit]}),
153
+ )
154
+
155
+
156
+ def _rewrite_import_action(
157
+ source: str,
158
+ uri: str,
159
+ diag: types.Diagnostic,
160
+ module: str | None,
161
+ ) -> types.CodeAction:
162
+ target = module or "module"
163
+ replacement = f"using({target}=\"<gvar-id>\")"
164
+ edit = types.TextEdit(range=diag.range, new_text=replacement)
165
+ return types.CodeAction(
166
+ title="Replace import with using()",
167
+ kind=types.CodeActionKind.QuickFix,
168
+ diagnostics=[diag],
169
+ edit=types.WorkspaceEdit(changes={uri: [edit]}),
170
+ )
171
+
172
+
173
+ def _block_insertion(blocks: list[DraconicBlock], line: int, source: str) -> tuple[int, int]:
174
+ for block in blocks:
175
+ start = block.line_offset
176
+ end = block.line_offset + block.line_count
177
+ if start <= line <= end:
178
+ indent = _line_indent(source, start, default=block.char_offset)
179
+ return start, indent
180
+ return 0, _line_indent(source, 0, default=0)
181
+
182
+
183
+ def _line_indent(source: str, line: int, default: int = 0) -> int:
184
+ lines = source.splitlines()
185
+ if 0 <= line < len(lines):
186
+ match = re.match(r"(\s*)", lines[line])
187
+ if match:
188
+ return len(match.group(1))
189
+ return default
190
+
191
+
192
+ def _sanitize_symbol(label: str) -> str:
193
+ cleaned = re.sub(r"\W+", "_", str(label))
194
+ if cleaned and cleaned[0].isdigit():
195
+ cleaned = f"gvar_{cleaned}"
196
+ return cleaned or "gvar_import"
197
+
198
+
199
+ def _kind_allowed(kind: str, only: Sequence[str]) -> bool:
200
+ if not only:
201
+ return True
202
+ for requested in only:
203
+ if kind.startswith(requested) or requested.startswith(kind):
204
+ return True
205
+ return False
206
+
207
+
208
+ def _text_in_range(source: str, rng: types.Range) -> str:
209
+ if rng.start == rng.end:
210
+ return ""
211
+ start = _offset_at_position(source, rng.start)
212
+ end = _offset_at_position(source, rng.end)
213
+ return source[start:end]
214
+
215
+
216
+ def _offset_at_position(source: str, pos: types.Position) -> int:
217
+ if pos.line < 0:
218
+ return 0
219
+ lines = source.splitlines(keepends=True)
220
+ if not lines:
221
+ return 0
222
+ if pos.line >= len(lines):
223
+ return len(source)
224
+ offset = sum(len(line) for line in lines[: pos.line])
225
+ return min(offset + pos.character, offset + len(lines[pos.line]))
226
+
227
+
228
+ def _render_snippet(snippet: Snippet, selection: str) -> str:
229
+ if "{content}" in snippet.body:
230
+ return snippet.body.replace("{content}", selection)
231
+ return snippet.body
232
+
233
+
234
+ def _full_range(source: str) -> types.Range:
235
+ lines = source.splitlines()
236
+ if not lines:
237
+ return types.Range(start=types.Position(line=0, character=0), end=types.Position(line=0, character=0))
238
+ end_line = len(lines) - 1
239
+ end_char = len(lines[-1])
240
+ return types.Range(
241
+ start=types.Position(line=0, character=0),
242
+ end=types.Position(line=end_line, character=end_char),
243
+ )
244
+
245
+
246
+ def _load_snippets(root: Path) -> List[Snippet]:
247
+ snippets: list[Snippet] = list(DEFAULT_SNIPPETS)
248
+ user_file = root / SNIPPET_FILENAME
249
+ if not user_file.exists():
250
+ return snippets
251
+ try:
252
+ raw = json.loads(user_file.read_text())
253
+ except Exception as exc: # pragma: no cover - best-effort load
254
+ log.warning("Failed to read snippet file %s: %s", user_file, exc)
255
+ return snippets
256
+
257
+ def _coerce(entry) -> Snippet | None:
258
+ if not isinstance(entry, dict):
259
+ return None
260
+ key = str(entry.get("key") or entry.get("title") or "")
261
+ body = entry.get("body")
262
+ title = entry.get("title") or key
263
+ if not key or not isinstance(body, str):
264
+ return None
265
+ return Snippet(
266
+ key=key,
267
+ title=str(title),
268
+ body=body,
269
+ description=str(entry.get("description") or ""),
270
+ kind=str(entry.get("kind") or types.CodeActionKind.RefactorRewrite),
271
+ )
272
+
273
+ entries: Iterable[dict] = []
274
+ if isinstance(raw, dict):
275
+ entries = raw.values()
276
+ elif isinstance(raw, list):
277
+ entries = raw
278
+ for entry in entries:
279
+ coerced = _coerce(entry)
280
+ if coerced:
281
+ snippets.append(coerced)
282
+ return snippets
avrae_ls/codes.py ADDED
@@ -0,0 +1,3 @@
1
+ UNDEFINED_NAME_CODE = "avrae.undefinedName"
2
+ MISSING_GVAR_CODE = "avrae.missingGvar"
3
+ UNSUPPORTED_IMPORT_CODE = "avrae.unsupportedImport"