avrae-ls 0.4.0__tar.gz → 0.4.1__tar.gz

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.
Files changed (55) hide show
  1. {avrae_ls-0.4.0/src/avrae_ls.egg-info → avrae_ls-0.4.1}/PKG-INFO +1 -1
  2. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/pyproject.toml +1 -1
  3. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/alias_preview.py +175 -9
  4. avrae_ls-0.4.1/src/avrae_ls/code_actions.py +282 -0
  5. avrae_ls-0.4.1/src/avrae_ls/codes.py +3 -0
  6. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/completions.py +485 -78
  7. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/diagnostics.py +62 -5
  8. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/server.py +14 -5
  9. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/signature_help.py +56 -5
  10. {avrae_ls-0.4.0 → avrae_ls-0.4.1/src/avrae_ls.egg-info}/PKG-INFO +1 -1
  11. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls.egg-info/SOURCES.txt +4 -0
  12. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_alias_preview.py +60 -6
  13. avrae_ls-0.4.1/tests/test_code_actions.py +153 -0
  14. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_completions.py +37 -11
  15. avrae_ls-0.4.1/tests/test_diagnostics.py +128 -0
  16. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_hover.py +17 -0
  17. avrae_ls-0.4.1/tests/test_signature_help.py +135 -0
  18. avrae_ls-0.4.0/tests/test_diagnostics.py +0 -236
  19. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/LICENSE +0 -0
  20. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/README.md +0 -0
  21. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/setup.cfg +0 -0
  22. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/__init__.py +0 -0
  23. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/__main__.py +0 -0
  24. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/api.py +0 -0
  25. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/argparser.py +0 -0
  26. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/argument_parsing.py +0 -0
  27. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/config.py +0 -0
  28. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/context.py +0 -0
  29. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/cvars.py +0 -0
  30. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/dice.py +0 -0
  31. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/parser.py +0 -0
  32. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/runtime.py +0 -0
  33. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/symbols.py +0 -0
  34. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls.egg-info/dependency_links.txt +0 -0
  35. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls.egg-info/entry_points.txt +0 -0
  36. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls.egg-info/requires.txt +0 -0
  37. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls.egg-info/top_level.txt +0 -0
  38. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/LICENSE +0 -0
  39. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/__init__.py +0 -0
  40. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/exceptions.py +0 -0
  41. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/helpers.py +0 -0
  42. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/interpreter.py +0 -0
  43. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/string.py +0 -0
  44. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/types.py +0 -0
  45. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/utils.py +0 -0
  46. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/versions.py +0 -0
  47. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_api.py +0 -0
  48. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_argparser_unit.py +0 -0
  49. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_argument_parsing.py +0 -0
  50. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_config_env.py +0 -0
  51. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_cvars.py +0 -0
  52. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_gvars.py +0 -0
  53. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_runtime.py +0 -0
  54. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_runtime_dice.py +0 -0
  55. {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_symbols.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avrae-ls
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Language server for Avrae draconic aliases
5
5
  Author: 1drturtle
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "avrae-ls"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Language server for Avrae draconic aliases"
9
9
  authors = [
10
10
  { name = "1drturtle" }
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import re
4
4
  import shlex
5
- from dataclasses import dataclass
5
+ from dataclasses import asdict, dataclass, field
6
6
  from typing import Any, Optional, Tuple
7
7
 
8
8
  from .parser import DRACONIC_RE
@@ -19,6 +19,45 @@ class RenderedAlias:
19
19
  last_value: Any | None = None
20
20
 
21
21
 
22
+ @dataclass
23
+ class EmbedFieldPreview:
24
+ name: str
25
+ value: str
26
+ inline: bool = False
27
+
28
+
29
+ @dataclass
30
+ class EmbedPreview:
31
+ title: str | None = None
32
+ description: str | None = None
33
+ footer: str | None = None
34
+ thumbnail: str | None = None
35
+ image: str | None = None
36
+ color: str | None = None
37
+ timeout: int | None = None
38
+ fields: list[EmbedFieldPreview] = field(default_factory=list)
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "title": self.title,
43
+ "description": self.description,
44
+ "footer": self.footer,
45
+ "thumbnail": self.thumbnail,
46
+ "image": self.image,
47
+ "color": self.color,
48
+ "timeout": self.timeout,
49
+ "fields": [asdict(f) for f in self.fields],
50
+ }
51
+
52
+
53
+ @dataclass
54
+ class SimulatedCommand:
55
+ preview: str | None
56
+ command_name: str | None
57
+ validation_error: str | None
58
+ embed: EmbedPreview | None = None
59
+
60
+
22
61
  def _strip_alias_header(text: str) -> str:
23
62
  lines = text.splitlines()
24
63
  if lines and lines[0].lstrip().startswith("!alias"):
@@ -82,6 +121,58 @@ def validate_embed_payload(payload: str) -> Tuple[bool, str | None]:
82
121
  return _validate_embed_flags(text)
83
122
 
84
123
 
124
+ def parse_embed_payload(payload: str) -> EmbedPreview:
125
+ """Parse an embed payload into a structured preview object."""
126
+ tokens = shlex.split(payload.strip())
127
+ preview = EmbedPreview()
128
+
129
+ i = 0
130
+ while i < len(tokens):
131
+ tok = tokens[i]
132
+ if not tok.startswith("-"):
133
+ i += 1
134
+ continue
135
+ key = tok.lower()
136
+ next_val = tokens[i + 1] if i + 1 < len(tokens) else None
137
+ if key == "-title":
138
+ preview.title = next_val or ""
139
+ i += 2
140
+ continue
141
+ if key == "-desc":
142
+ preview.description = next_val or ""
143
+ i += 2
144
+ continue
145
+ if key == "-footer":
146
+ preview.footer = next_val or ""
147
+ i += 2
148
+ continue
149
+ if key == "-thumb":
150
+ preview.thumbnail = next_val or ""
151
+ i += 2
152
+ continue
153
+ if key == "-image":
154
+ preview.image = next_val or ""
155
+ i += 2
156
+ continue
157
+ if key == "-color":
158
+ preview.color = _normalize_color(next_val)
159
+ i += 2 if next_val is not None else 1
160
+ continue
161
+ if key == "-t":
162
+ preview.timeout = _parse_timeout(next_val)
163
+ i += 2
164
+ continue
165
+ if key == "-f":
166
+ field = _parse_field_value(next_val)
167
+ if field:
168
+ preview.fields.append(field)
169
+ i += 2
170
+ continue
171
+ i += 1
172
+
173
+ return preview
174
+
175
+
85
176
  def _validate_embed_flags(text: str) -> Tuple[bool, str | None]:
86
177
  """Validate embed flags according to Avrae's help text."""
87
178
  if not text:
@@ -164,17 +255,92 @@ def _validate_timeout_arg(value: str | None) -> Tuple[bool, str | None, int]:
164
255
  return True, None, consumed
165
256
 
166
257
 
167
- def simulate_command(command: str) -> tuple[str | None, str | None, str | None]:
258
+ def _parse_timeout(value: str | None) -> int | None:
259
+ if value is None:
260
+ return None
261
+ try:
262
+ return int(value)
263
+ except (TypeError, ValueError):
264
+ return None
265
+
266
+
267
+ def _normalize_color(value: str | None) -> str | None:
268
+ if value is None:
269
+ return None
270
+ if not value:
271
+ return None
272
+ match = re.match(r"^(?:#|0x)?([0-9a-fA-F]{6})$", value)
273
+ if not match:
274
+ return value
275
+ return f"#{match.group(1)}"
276
+
277
+
278
+ def _parse_field_value(value: str | None) -> EmbedFieldPreview | None:
279
+ if value is None:
280
+ return None
281
+ parts = value.split("|")
282
+ if len(parts) < 2:
283
+ return None
284
+ inline_flag = parts[2].lower() == "inline" if len(parts) == 3 else False
285
+ return EmbedFieldPreview(name=parts[0], value=parts[1], inline=inline_flag)
286
+
287
+
288
+ def simulate_command(command: str) -> SimulatedCommand:
168
289
  """Very small shim to preview common commands."""
169
- text = command.strip()
290
+ text = _strip_alias_header(command).strip()
170
291
  if not text:
171
- return None, None, None
172
- head, *rest = text.split(maxsplit=1)
173
- payload = rest[0] if rest else ""
292
+ return SimulatedCommand(None, None, None, None)
293
+ head, payload = _extract_command_head_and_payload(text)
294
+ if not head:
295
+ return SimulatedCommand(None, None, None, None)
174
296
  lowered = head.lower()
175
297
  if lowered == "echo":
176
- return payload, "echo", None
298
+ return SimulatedCommand(payload, "echo", None, None)
177
299
  if lowered == "embed":
178
300
  valid, error = validate_embed_payload(payload)
179
- return payload, "embed", error
180
- return None, head, None
301
+ embed_preview = parse_embed_payload(payload) if valid else None
302
+ return SimulatedCommand(payload, "embed", error, embed_preview)
303
+ if head.startswith("-") and _is_embed_flag(head):
304
+ payload = text
305
+ valid, error = validate_embed_payload(payload)
306
+ embed_preview = parse_embed_payload(payload) if valid else None
307
+ return SimulatedCommand(payload, "embed", error, embed_preview)
308
+ return SimulatedCommand(None, head, None, None)
309
+
310
+
311
+ def _extract_command_head_and_payload(text: str) -> tuple[str | None, str]:
312
+ """Prefer the first non-empty line; fall back to any embed line later."""
313
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
314
+ if not lines:
315
+ return None, ""
316
+ head, payload = _split_head_and_payload_from_line(lines[0])
317
+ if _is_embed_flag(head):
318
+ # Treat the entire payload (including the head line) as embed flags so multiple lines are preserved.
319
+ return head, "\n".join(lines)
320
+ if head and head.lower() in ("embed", "echo"):
321
+ return head, _merge_payload(payload, lines[1:])
322
+ for idx, line in enumerate(lines[1:], start=1):
323
+ possible_head, possible_payload = _split_head_and_payload_from_line(line)
324
+ if possible_head and (possible_head.lower() == "embed" or _is_embed_flag(possible_head)):
325
+ return possible_head, _merge_payload(possible_payload, lines[idx + 1 :])
326
+ return head, _merge_payload(payload, lines[1:])
327
+
328
+
329
+ def _split_head_and_payload_from_line(line: str) -> tuple[str | None, str]:
330
+ if not line:
331
+ return None, ""
332
+ parts = line.split(maxsplit=1)
333
+ head = parts[0]
334
+ payload = parts[1] if len(parts) > 1 else ""
335
+ return head, payload
336
+
337
+
338
+ def _merge_payload(first_payload: str, trailing_lines: list[str]) -> str:
339
+ payload = first_payload
340
+ if trailing_lines:
341
+ payload = (payload + "\n" if payload else "") + "\n".join(trailing_lines)
342
+ return payload
343
+
344
+
345
+ def _is_embed_flag(flag: str) -> bool:
346
+ return flag.lower() in {"-title", "-desc", "-thumb", "-image", "-footer", "-f", "-color", "-t"}
@@ -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
@@ -0,0 +1,3 @@
1
+ UNDEFINED_NAME_CODE = "avrae.undefinedName"
2
+ MISSING_GVAR_CODE = "avrae.missingGvar"
3
+ UNSUPPORTED_IMPORT_CODE = "avrae.unsupportedImport"