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.
- {avrae_ls-0.4.0/src/avrae_ls.egg-info → avrae_ls-0.4.1}/PKG-INFO +1 -1
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/pyproject.toml +1 -1
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/alias_preview.py +175 -9
- avrae_ls-0.4.1/src/avrae_ls/code_actions.py +282 -0
- avrae_ls-0.4.1/src/avrae_ls/codes.py +3 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/completions.py +485 -78
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/diagnostics.py +62 -5
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/server.py +14 -5
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/signature_help.py +56 -5
- {avrae_ls-0.4.0 → avrae_ls-0.4.1/src/avrae_ls.egg-info}/PKG-INFO +1 -1
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls.egg-info/SOURCES.txt +4 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_alias_preview.py +60 -6
- avrae_ls-0.4.1/tests/test_code_actions.py +153 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_completions.py +37 -11
- avrae_ls-0.4.1/tests/test_diagnostics.py +128 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_hover.py +17 -0
- avrae_ls-0.4.1/tests/test_signature_help.py +135 -0
- avrae_ls-0.4.0/tests/test_diagnostics.py +0 -236
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/LICENSE +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/README.md +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/setup.cfg +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/__init__.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/__main__.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/api.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/argparser.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/argument_parsing.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/config.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/context.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/cvars.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/dice.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/parser.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/runtime.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls/symbols.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls.egg-info/dependency_links.txt +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls.egg-info/entry_points.txt +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls.egg-info/requires.txt +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/avrae_ls.egg-info/top_level.txt +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/LICENSE +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/__init__.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/exceptions.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/helpers.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/interpreter.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/string.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/types.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/utils.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/src/draconic/versions.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_api.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_argparser_unit.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_argument_parsing.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_config_env.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_cvars.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_gvars.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_runtime.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_runtime_dice.py +0 -0
- {avrae_ls-0.4.0 → avrae_ls-0.4.1}/tests/test_symbols.py +0 -0
|
@@ -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
|
|
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,
|
|
173
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|