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/alias_preview.py +175 -9
- avrae_ls/api.py +229 -229
- avrae_ls/argparser.py +16 -3
- avrae_ls/code_actions.py +282 -0
- avrae_ls/codes.py +3 -0
- avrae_ls/completions.py +489 -78
- avrae_ls/config.py +61 -2
- avrae_ls/context.py +62 -1
- avrae_ls/diagnostics.py +267 -5
- avrae_ls/parser.py +7 -2
- avrae_ls/runtime.py +94 -15
- avrae_ls/server.py +52 -6
- avrae_ls/signature_help.py +56 -5
- avrae_ls/symbols.py +149 -33
- avrae_ls-0.4.1.dist-info/METADATA +86 -0
- avrae_ls-0.4.1.dist-info/RECORD +34 -0
- avrae_ls-0.3.1.dist-info/METADATA +0 -47
- avrae_ls-0.3.1.dist-info/RECORD +0 -32
- {avrae_ls-0.3.1.dist-info → avrae_ls-0.4.1.dist-info}/WHEEL +0 -0
- {avrae_ls-0.3.1.dist-info → avrae_ls-0.4.1.dist-info}/entry_points.txt +0 -0
- {avrae_ls-0.3.1.dist-info → avrae_ls-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {avrae_ls-0.3.1.dist-info → avrae_ls-0.4.1.dist-info}/top_level.txt +0 -0
avrae_ls/runtime.py
CHANGED
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import logging
|
|
7
7
|
import math
|
|
8
8
|
import random
|
|
9
|
+
import re
|
|
9
10
|
import time
|
|
10
11
|
from types import SimpleNamespace
|
|
11
12
|
try: # optional dependency
|
|
@@ -98,7 +99,66 @@ def _vroll_dice(dice: str, multiply: int = 1, add: int = 0) -> SimpleRollResult
|
|
|
98
99
|
return SimpleRollResult(rolled)
|
|
99
100
|
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
@dataclass
|
|
103
|
+
class _CoinsArgs:
|
|
104
|
+
pp: int = 0
|
|
105
|
+
gp: int = 0
|
|
106
|
+
ep: int = 0
|
|
107
|
+
sp: int = 0
|
|
108
|
+
cp: int = 0
|
|
109
|
+
explicit: bool = False
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def total(self) -> float:
|
|
113
|
+
return (self.pp * 10) + self.gp + (self.ep * 0.5) + (self.sp * 0.1) + (self.cp * 0.01)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_coin_args(args: str) -> _CoinsArgs:
|
|
117
|
+
cleaned = str(args).replace(",", "")
|
|
118
|
+
try:
|
|
119
|
+
return _parse_coin_args_float(float(cleaned))
|
|
120
|
+
except ValueError:
|
|
121
|
+
return _parse_coin_args_re(cleaned)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_coin_args_float(coins: float) -> _CoinsArgs:
|
|
125
|
+
total_copper = int(round(coins * 100, 1))
|
|
126
|
+
if coins < 0:
|
|
127
|
+
return _CoinsArgs(cp=total_copper)
|
|
128
|
+
return _CoinsArgs(
|
|
129
|
+
gp=total_copper // 100,
|
|
130
|
+
sp=(total_copper % 100) // 10,
|
|
131
|
+
cp=total_copper % 10,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_coin_args_re(args: str) -> _CoinsArgs:
|
|
136
|
+
is_valid = re.fullmatch(r"(([+-]?\d+)\s*([pgesc]p)\s*)+", args, re.IGNORECASE)
|
|
137
|
+
if not is_valid:
|
|
138
|
+
raise avrae_argparser.InvalidArgument(
|
|
139
|
+
"Coins must be a number or a currency string, e.g. `+101.2` or `10cp +101gp -2sp`."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
out = _CoinsArgs(explicit=True)
|
|
143
|
+
for coin_match in re.finditer(r"(?P<amount>[+-]?\d+)\s*(?P<currency>[pgesc]p)", args, re.IGNORECASE):
|
|
144
|
+
amount = int(coin_match["amount"])
|
|
145
|
+
currency = coin_match["currency"].lower()
|
|
146
|
+
|
|
147
|
+
if currency == "pp":
|
|
148
|
+
out.pp += amount
|
|
149
|
+
elif currency == "gp":
|
|
150
|
+
out.gp += amount
|
|
151
|
+
elif currency == "ep":
|
|
152
|
+
out.ep += amount
|
|
153
|
+
elif currency == "sp":
|
|
154
|
+
out.sp += amount
|
|
155
|
+
else:
|
|
156
|
+
out.cp += amount
|
|
157
|
+
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_coins(args: str, include_total: bool = True):
|
|
102
162
|
try:
|
|
103
163
|
from avrae.aliasing.api.functions import parse_coins as avrae_parse_coins
|
|
104
164
|
except Exception:
|
|
@@ -106,16 +166,21 @@ def _parse_coins(args: str):
|
|
|
106
166
|
|
|
107
167
|
if avrae_parse_coins:
|
|
108
168
|
try:
|
|
109
|
-
return avrae_parse_coins(str(args))
|
|
169
|
+
return avrae_parse_coins(str(args), include_total=include_total)
|
|
110
170
|
except Exception:
|
|
111
171
|
pass
|
|
112
172
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
173
|
+
coin_args = _parse_coin_args(str(args))
|
|
174
|
+
parsed = {
|
|
175
|
+
"pp": coin_args.pp,
|
|
176
|
+
"gp": coin_args.gp,
|
|
177
|
+
"ep": coin_args.ep,
|
|
178
|
+
"sp": coin_args.sp,
|
|
179
|
+
"cp": coin_args.cp,
|
|
180
|
+
}
|
|
181
|
+
if include_total:
|
|
182
|
+
parsed["total"] = coin_args.total
|
|
183
|
+
return parsed
|
|
119
184
|
|
|
120
185
|
|
|
121
186
|
def _default_builtins() -> Dict[str, Any]:
|
|
@@ -414,6 +479,9 @@ class MockExecutor:
|
|
|
414
479
|
verify_cache_sig = sig_str
|
|
415
480
|
verify_cache_error = None
|
|
416
481
|
verify_cache_result = None
|
|
482
|
+
timeout = float(service_cfg.verify_timeout if service_cfg else AvraeServiceConfig.verify_timeout)
|
|
483
|
+
retries = int(service_cfg.verify_retries if service_cfg else AvraeServiceConfig.verify_retries)
|
|
484
|
+
retries = max(0, retries)
|
|
417
485
|
|
|
418
486
|
def _call_verify_api(signature: str) -> Dict[str, Any]:
|
|
419
487
|
base_url = (service_cfg.base_url if service_cfg else AvraeServiceConfig.base_url).rstrip("/")
|
|
@@ -421,10 +489,18 @@ class MockExecutor:
|
|
|
421
489
|
headers = {"Content-Type": "application/json"}
|
|
422
490
|
if service_cfg and service_cfg.token:
|
|
423
491
|
headers["Authorization"] = str(service_cfg.token)
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
492
|
+
last_exc: Exception | None = None
|
|
493
|
+
for attempt in range(retries + 1):
|
|
494
|
+
try:
|
|
495
|
+
resp = httpx.post(url, json={"signature": signature}, headers=headers, timeout=timeout)
|
|
496
|
+
break
|
|
497
|
+
except Exception as exc:
|
|
498
|
+
last_exc = exc
|
|
499
|
+
if attempt >= retries:
|
|
500
|
+
raise ValueError(f"Failed to verify signature: {exc}") from exc
|
|
501
|
+
continue
|
|
502
|
+
else: # pragma: no cover - defensive
|
|
503
|
+
raise ValueError(f"Failed to verify signature: {last_exc}") from last_exc
|
|
428
504
|
|
|
429
505
|
try:
|
|
430
506
|
payload = resp.json()
|
|
@@ -432,14 +508,17 @@ class MockExecutor:
|
|
|
432
508
|
raise ValueError("Failed to verify signature: invalid response body") from exc
|
|
433
509
|
|
|
434
510
|
if resp.status_code != 200:
|
|
435
|
-
message =
|
|
436
|
-
|
|
511
|
+
message = None
|
|
512
|
+
if isinstance(payload, dict):
|
|
513
|
+
message = payload.get("error") or payload.get("message")
|
|
514
|
+
detail = f"{message} (HTTP {resp.status_code})" if message else f"HTTP {resp.status_code}"
|
|
515
|
+
raise ValueError(f"Failed to verify signature: {detail}")
|
|
437
516
|
|
|
438
517
|
if not isinstance(payload, dict):
|
|
439
518
|
raise ValueError("Failed to verify signature: invalid response")
|
|
440
519
|
if payload.get("success") is not True:
|
|
441
520
|
message = payload.get("error")
|
|
442
|
-
raise ValueError(
|
|
521
|
+
raise ValueError(f"Failed to verify signature: {message or 'unsuccessful response'}")
|
|
443
522
|
|
|
444
523
|
data = payload.get("data")
|
|
445
524
|
if not isinstance(data, dict):
|
avrae_ls/server.py
CHANGED
|
@@ -18,7 +18,8 @@ from .alias_preview import render_alias_command, simulate_command
|
|
|
18
18
|
from .parser import find_draconic_blocks
|
|
19
19
|
from .signature_help import load_signatures, signature_help_for_code
|
|
20
20
|
from .completions import gather_suggestions, completion_items_for_position, hover_for_position
|
|
21
|
-
from .
|
|
21
|
+
from .code_actions import code_actions_for_document
|
|
22
|
+
from .symbols import build_symbol_table, document_symbols, find_definition_range, find_references, range_for_word
|
|
22
23
|
from .argument_parsing import apply_argument_parsing
|
|
23
24
|
|
|
24
25
|
__version__ = "0.1.0"
|
|
@@ -142,6 +143,43 @@ def on_definition(server: AvraeLanguageServer, params: types.DefinitionParams):
|
|
|
142
143
|
return types.Location(uri=params.text_document.uri, range=rng)
|
|
143
144
|
|
|
144
145
|
|
|
146
|
+
@ls.feature(types.TEXT_DOCUMENT_REFERENCES)
|
|
147
|
+
def on_references(server: AvraeLanguageServer, params: types.ReferenceParams):
|
|
148
|
+
doc = server.workspace.get_text_document(params.text_document.uri)
|
|
149
|
+
table = build_symbol_table(doc.source)
|
|
150
|
+
word = doc.word_at_position(params.position)
|
|
151
|
+
if not word or not table.lookup(word):
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
ranges = find_references(table, doc.source, word, include_declaration=params.context.include_declaration)
|
|
155
|
+
return [types.Location(uri=params.text_document.uri, range=rng) for rng in ranges]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@ls.feature(types.TEXT_DOCUMENT_PREPARE_RENAME)
|
|
159
|
+
def on_prepare_rename(server: AvraeLanguageServer, params: types.PrepareRenameParams):
|
|
160
|
+
doc = server.workspace.get_text_document(params.text_document.uri)
|
|
161
|
+
table = build_symbol_table(doc.source)
|
|
162
|
+
word = doc.word_at_position(params.position)
|
|
163
|
+
if not word or not table.lookup(word):
|
|
164
|
+
return None
|
|
165
|
+
return range_for_word(doc.source, params.position)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@ls.feature(types.TEXT_DOCUMENT_RENAME)
|
|
169
|
+
def on_rename(server: AvraeLanguageServer, params: types.RenameParams):
|
|
170
|
+
doc = server.workspace.get_text_document(params.text_document.uri)
|
|
171
|
+
table = build_symbol_table(doc.source)
|
|
172
|
+
word = doc.word_at_position(params.position)
|
|
173
|
+
if not word or not table.lookup(word) or not params.new_name:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
ranges = find_references(table, doc.source, word, include_declaration=True)
|
|
177
|
+
if not ranges:
|
|
178
|
+
return None
|
|
179
|
+
edits = [types.TextEdit(range=rng, new_text=params.new_name) for rng in ranges]
|
|
180
|
+
return types.WorkspaceEdit(changes={params.text_document.uri: edits})
|
|
181
|
+
|
|
182
|
+
|
|
145
183
|
@ls.feature(types.WORKSPACE_SYMBOL)
|
|
146
184
|
def on_workspace_symbol(server: AvraeLanguageServer, params: types.WorkspaceSymbolParams):
|
|
147
185
|
symbols: list[types.SymbolInformation] = []
|
|
@@ -223,6 +261,12 @@ def on_hover(server: AvraeLanguageServer, params: types.HoverParams):
|
|
|
223
261
|
return None
|
|
224
262
|
|
|
225
263
|
|
|
264
|
+
@ls.feature(types.TEXT_DOCUMENT_CODE_ACTION)
|
|
265
|
+
def on_code_action(server: AvraeLanguageServer, params: types.CodeActionParams):
|
|
266
|
+
doc = server.workspace.get_text_document(params.text_document.uri)
|
|
267
|
+
return code_actions_for_document(doc.source, params, server.workspace_root)
|
|
268
|
+
|
|
269
|
+
|
|
226
270
|
@ls.command(RUN_ALIAS_COMMAND)
|
|
227
271
|
async def run_alias(server: AvraeLanguageServer, *args: Any):
|
|
228
272
|
payload = args[0] if args else {}
|
|
@@ -252,18 +296,20 @@ async def run_alias(server: AvraeLanguageServer, *args: Any):
|
|
|
252
296
|
server.state.context_builder.gvar_resolver,
|
|
253
297
|
args=alias_args,
|
|
254
298
|
)
|
|
255
|
-
|
|
299
|
+
preview = simulate_command(rendered.command)
|
|
256
300
|
|
|
257
301
|
response: dict[str, Any] = {
|
|
258
302
|
"stdout": rendered.stdout,
|
|
259
|
-
"result":
|
|
303
|
+
"result": preview.preview if preview.preview is not None else rendered.last_value,
|
|
260
304
|
"command": rendered.command,
|
|
261
|
-
"commandName": command_name,
|
|
305
|
+
"commandName": preview.command_name,
|
|
262
306
|
}
|
|
263
307
|
if rendered.error:
|
|
264
308
|
response["error"] = _format_runtime_error(rendered.error)
|
|
265
|
-
if validation_error:
|
|
266
|
-
response["validationError"] = validation_error
|
|
309
|
+
if preview.validation_error:
|
|
310
|
+
response["validationError"] = preview.validation_error
|
|
311
|
+
if preview.embed:
|
|
312
|
+
response["embed"] = preview.embed.to_dict()
|
|
267
313
|
if uri:
|
|
268
314
|
extra = []
|
|
269
315
|
if rendered.error:
|
avrae_ls/signature_help.py
CHANGED
|
@@ -157,15 +157,29 @@ def signature_help_for_code(code: str, line: int, character: int, sigs: Dict[str
|
|
|
157
157
|
return None
|
|
158
158
|
|
|
159
159
|
target_call: ast.Call | None = None
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
target_depth = -1
|
|
161
|
+
|
|
162
|
+
class Finder(ast.NodeVisitor):
|
|
163
|
+
def __init__(self):
|
|
164
|
+
self.stack: list[ast.AST] = []
|
|
165
|
+
|
|
166
|
+
def visit_Call(self, node: ast.Call):
|
|
167
|
+
nonlocal target_call, target_depth
|
|
162
168
|
if hasattr(node, "lineno") and hasattr(node, "col_offset"):
|
|
163
169
|
start = (node.lineno - 1, node.col_offset)
|
|
164
170
|
end_line = getattr(node, "end_lineno", node.lineno) - 1
|
|
165
171
|
end_col = getattr(node, "end_col_offset", node.col_offset)
|
|
166
172
|
if _pos_within((line, character), start, (end_line, end_col)):
|
|
167
|
-
|
|
168
|
-
|
|
173
|
+
depth = len(self.stack)
|
|
174
|
+
# Prefer the most nested call covering the cursor
|
|
175
|
+
if depth >= target_depth:
|
|
176
|
+
target_call = node
|
|
177
|
+
target_depth = depth
|
|
178
|
+
self.stack.append(node)
|
|
179
|
+
self.generic_visit(node)
|
|
180
|
+
self.stack.pop()
|
|
181
|
+
|
|
182
|
+
Finder().visit(tree)
|
|
169
183
|
|
|
170
184
|
if not target_call:
|
|
171
185
|
return None
|
|
@@ -184,7 +198,7 @@ def signature_help_for_code(code: str, line: int, character: int, sigs: Dict[str
|
|
|
184
198
|
documentation=fsig.doc,
|
|
185
199
|
parameters=[types.ParameterInformation(label=p) for p in fsig.params],
|
|
186
200
|
)
|
|
187
|
-
active_param =
|
|
201
|
+
active_param = _active_param_index(target_call, (line, character), fsig.params)
|
|
188
202
|
return types.SignatureHelp(signatures=[sig_info], active_signature=0, active_parameter=active_param)
|
|
189
203
|
|
|
190
204
|
|
|
@@ -199,3 +213,40 @@ def _pos_within(pos: Tuple[int, int], start: Tuple[int, int], end: Tuple[int, in
|
|
|
199
213
|
if line == el and col > ec:
|
|
200
214
|
return False
|
|
201
215
|
return True
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _active_param_index(call: ast.Call, cursor: Tuple[int, int], params: List[str]) -> int:
|
|
219
|
+
if not params:
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
# Build spans for positional args and keywords in source order.
|
|
223
|
+
spans: list[tuple[Tuple[int, int], Tuple[int, int], ast.AST]] = []
|
|
224
|
+
for arg in call.args:
|
|
225
|
+
spans.append((_node_start(arg), _node_end(arg), arg))
|
|
226
|
+
for kw in call.keywords:
|
|
227
|
+
spans.append((_node_start(kw), _node_end(kw), kw))
|
|
228
|
+
spans.sort(key=lambda s: (s[0][0], s[0][1]))
|
|
229
|
+
|
|
230
|
+
def _clamp(idx: int) -> int:
|
|
231
|
+
return max(0, min(idx, max(len(params) - 1, 0)))
|
|
232
|
+
|
|
233
|
+
for idx, (start, end, node) in enumerate(spans):
|
|
234
|
+
if _pos_within(cursor, start, end):
|
|
235
|
+
if isinstance(node, ast.keyword) and node.arg and node.arg in params:
|
|
236
|
+
return _clamp(params.index(node.arg))
|
|
237
|
+
return _clamp(idx)
|
|
238
|
+
|
|
239
|
+
# If cursor is after some args but not inside one, infer next argument slot.
|
|
240
|
+
before_count = sum(1 for start, _, _ in spans if start <= cursor)
|
|
241
|
+
return _clamp(before_count)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _node_start(node: ast.AST) -> Tuple[int, int]:
|
|
245
|
+
return (getattr(node, "lineno", 1) - 1, getattr(node, "col_offset", 0))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _node_end(node: ast.AST) -> Tuple[int, int]:
|
|
249
|
+
return (
|
|
250
|
+
getattr(node, "end_lineno", getattr(node, "lineno", 1)) - 1,
|
|
251
|
+
getattr(node, "end_col_offset", getattr(node, "col_offset", 0)),
|
|
252
|
+
)
|
avrae_ls/symbols.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import ast
|
|
4
4
|
import logging
|
|
5
5
|
from dataclasses import dataclass
|
|
6
|
-
from typing import Dict, List, Optional
|
|
6
|
+
from typing import Dict, Iterable, List, Optional
|
|
7
7
|
|
|
8
8
|
import draconic
|
|
9
9
|
from lsprotocol import types
|
|
@@ -40,10 +40,10 @@ def build_symbol_table(source: str) -> SymbolTable:
|
|
|
40
40
|
parsed_source = apply_argument_parsing(source)
|
|
41
41
|
blocks = find_draconic_blocks(parsed_source)
|
|
42
42
|
if not blocks:
|
|
43
|
-
entries.extend(_symbols_from_code(parsed_source, 0))
|
|
43
|
+
entries.extend(_symbols_from_code(parsed_source, 0, 0))
|
|
44
44
|
else:
|
|
45
45
|
for block in blocks:
|
|
46
|
-
entries.extend(_symbols_from_code(block.code, block.line_offset))
|
|
46
|
+
entries.extend(_symbols_from_code(block.code, block.line_offset, block.char_offset))
|
|
47
47
|
return SymbolTable(entries)
|
|
48
48
|
|
|
49
49
|
|
|
@@ -67,31 +67,72 @@ def find_definition_range(table: SymbolTable, name: str) -> types.Range | None:
|
|
|
67
67
|
return None
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
70
|
+
def find_references(
|
|
71
|
+
table: SymbolTable, source: str, name: str, include_declaration: bool = True
|
|
72
|
+
) -> List[types.Range]:
|
|
73
|
+
parsed_source = apply_argument_parsing(source)
|
|
74
|
+
ranges: list[types.Range] = []
|
|
75
|
+
entry = table.lookup(name)
|
|
76
|
+
include_stores = include_declaration and entry is None
|
|
77
|
+
if include_declaration:
|
|
78
|
+
if entry:
|
|
79
|
+
ranges.append(entry.selection_range)
|
|
80
|
+
|
|
81
|
+
blocks = find_draconic_blocks(parsed_source)
|
|
82
|
+
if not blocks:
|
|
83
|
+
ranges.extend(_references_from_code(parsed_source, name, 0, 0, include_stores))
|
|
84
|
+
else:
|
|
85
|
+
for block in blocks:
|
|
86
|
+
ranges.extend(
|
|
87
|
+
_references_from_code(block.code, name, block.line_offset, block.char_offset, include_stores)
|
|
88
|
+
)
|
|
89
|
+
return _dedupe_ranges(ranges)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def range_for_word(source: str, position: types.Position) -> types.Range | None:
|
|
93
|
+
lines = source.splitlines()
|
|
94
|
+
if position.line >= len(lines):
|
|
95
|
+
return None
|
|
96
|
+
line = lines[position.line]
|
|
97
|
+
if position.character > len(line):
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def _is_ident(ch: str) -> bool:
|
|
101
|
+
return ch.isalnum() or ch == "_"
|
|
102
|
+
|
|
103
|
+
start_idx = position.character
|
|
104
|
+
while start_idx > 0 and _is_ident(line[start_idx - 1]):
|
|
105
|
+
start_idx -= 1
|
|
106
|
+
|
|
107
|
+
end_idx = position.character
|
|
108
|
+
while end_idx < len(line) and _is_ident(line[end_idx]):
|
|
109
|
+
end_idx += 1
|
|
110
|
+
|
|
111
|
+
if start_idx == end_idx:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
return types.Range(
|
|
115
|
+
start=types.Position(line=position.line, character=start_idx),
|
|
116
|
+
end=types.Position(line=position.line, character=end_idx),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _symbols_from_code(code: str, line_offset: int, char_offset: int) -> List[SymbolEntry]:
|
|
121
|
+
body, offset_adjust = _parse_draconic(code)
|
|
122
|
+
if not body:
|
|
84
123
|
return []
|
|
85
124
|
|
|
125
|
+
local_offset = line_offset + offset_adjust
|
|
126
|
+
|
|
86
127
|
entries: list[SymbolEntry] = []
|
|
87
128
|
for node in body:
|
|
88
|
-
entry = _entry_from_node(node, local_offset)
|
|
129
|
+
entry = _entry_from_node(node, local_offset, char_offset)
|
|
89
130
|
if entry:
|
|
90
131
|
entries.append(entry)
|
|
91
132
|
return entries
|
|
92
133
|
|
|
93
134
|
|
|
94
|
-
def _entry_from_node(node: ast.AST, line_offset: int = 0) -> SymbolEntry | None:
|
|
135
|
+
def _entry_from_node(node: ast.AST, line_offset: int = 0, char_offset: int = 0) -> SymbolEntry | None:
|
|
95
136
|
if isinstance(node, ast.FunctionDef):
|
|
96
137
|
kind = types.SymbolKind.Function
|
|
97
138
|
name = node.name
|
|
@@ -103,6 +144,7 @@ def _entry_from_node(node: ast.AST, line_offset: int = 0) -> SymbolEntry | None:
|
|
|
103
144
|
if isinstance(target, ast.Name):
|
|
104
145
|
kind = types.SymbolKind.Variable
|
|
105
146
|
name = target.id
|
|
147
|
+
node = target
|
|
106
148
|
else:
|
|
107
149
|
return None
|
|
108
150
|
else:
|
|
@@ -110,37 +152,99 @@ def _entry_from_node(node: ast.AST, line_offset: int = 0) -> SymbolEntry | None:
|
|
|
110
152
|
|
|
111
153
|
rng = _range_from_positions(
|
|
112
154
|
getattr(node, "lineno", 1),
|
|
113
|
-
getattr(node, "col_offset", 0)
|
|
155
|
+
getattr(node, "col_offset", 0),
|
|
114
156
|
getattr(node, "end_lineno", None),
|
|
115
157
|
getattr(node, "end_col_offset", None),
|
|
116
158
|
)
|
|
117
|
-
rng = _shift_range(rng, line_offset)
|
|
159
|
+
rng = _shift_range(rng, line_offset, char_offset)
|
|
118
160
|
return SymbolEntry(name=name, kind=kind, range=rng, selection_range=rng)
|
|
119
161
|
|
|
120
162
|
|
|
163
|
+
class _ReferenceCollector(ast.NodeVisitor):
|
|
164
|
+
def __init__(self, target: str, include_stores: bool):
|
|
165
|
+
super().__init__()
|
|
166
|
+
self._target = target
|
|
167
|
+
self._include_stores = include_stores
|
|
168
|
+
self.ranges: list[types.Range] = []
|
|
169
|
+
|
|
170
|
+
def visit_Name(self, node: ast.Name): # type: ignore[override]
|
|
171
|
+
if node.id == self._target:
|
|
172
|
+
if isinstance(node.ctx, ast.Store) and not self._include_stores:
|
|
173
|
+
return
|
|
174
|
+
rng = _range_from_positions(
|
|
175
|
+
getattr(node, "lineno", 1),
|
|
176
|
+
getattr(node, "col_offset", 0),
|
|
177
|
+
getattr(node, "end_lineno", None),
|
|
178
|
+
getattr(node, "end_col_offset", None),
|
|
179
|
+
)
|
|
180
|
+
self.ranges.append(rng)
|
|
181
|
+
self.generic_visit(node)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _references_from_code(
|
|
185
|
+
code: str,
|
|
186
|
+
name: str,
|
|
187
|
+
line_offset: int,
|
|
188
|
+
char_offset: int,
|
|
189
|
+
include_stores: bool,
|
|
190
|
+
) -> List[types.Range]:
|
|
191
|
+
body, offset_adjust = _parse_draconic(code)
|
|
192
|
+
if not body:
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
collector = _ReferenceCollector(name, include_stores)
|
|
196
|
+
for node in body:
|
|
197
|
+
collector.visit(node)
|
|
198
|
+
|
|
199
|
+
local_offset = line_offset + offset_adjust
|
|
200
|
+
return [_shift_range(rng, local_offset, char_offset) for rng in collector.ranges]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _parse_draconic(code: str) -> tuple[list[ast.AST], int]:
|
|
204
|
+
parser = draconic.DraconicInterpreter()
|
|
205
|
+
try:
|
|
206
|
+
return parser.parse(code), 0
|
|
207
|
+
except draconic.DraconicSyntaxError:
|
|
208
|
+
wrapped, added = _wrap_draconic(code)
|
|
209
|
+
try:
|
|
210
|
+
return parser.parse(wrapped), -added
|
|
211
|
+
except draconic.DraconicSyntaxError:
|
|
212
|
+
return [], 0
|
|
213
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
214
|
+
log.debug("Symbol extraction failed: %s", exc)
|
|
215
|
+
return [], 0
|
|
216
|
+
|
|
217
|
+
|
|
121
218
|
def _range_from_positions(
|
|
122
219
|
lineno: int | None,
|
|
123
220
|
col_offset: int | None,
|
|
124
221
|
end_lineno: int | None,
|
|
125
222
|
end_col_offset: int | None,
|
|
126
223
|
) -> types.Range:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
224
|
+
start_line = max((lineno or 1) - 1, 0)
|
|
225
|
+
start_char = max(col_offset or 0, 0)
|
|
226
|
+
end_line = max(((end_lineno or lineno or 1) - 1), 0)
|
|
227
|
+
raw_end_char = end_col_offset if end_col_offset is not None else col_offset
|
|
228
|
+
end_char = max(raw_end_char or start_char, start_char)
|
|
229
|
+
if end_char <= start_char:
|
|
230
|
+
end_char = start_char + 1
|
|
231
|
+
return types.Range(
|
|
232
|
+
start=types.Position(line=start_line, character=start_char),
|
|
233
|
+
end=types.Position(line=end_line, character=end_char),
|
|
134
234
|
)
|
|
135
|
-
return types.Range(start=start, end=end)
|
|
136
235
|
|
|
137
236
|
|
|
138
|
-
def _shift_range(rng: types.Range, line_offset: int) -> types.Range:
|
|
237
|
+
def _shift_range(rng: types.Range, line_offset: int, char_offset: int = 0) -> types.Range:
|
|
238
|
+
start_char = rng.start.character + (char_offset if rng.start.line == 0 else 0)
|
|
239
|
+
end_char = rng.end.character + (char_offset if rng.end.line == 0 else 0)
|
|
139
240
|
if line_offset == 0:
|
|
140
|
-
return
|
|
241
|
+
return types.Range(
|
|
242
|
+
start=types.Position(line=rng.start.line, character=start_char),
|
|
243
|
+
end=types.Position(line=rng.end.line, character=end_char),
|
|
244
|
+
)
|
|
141
245
|
return types.Range(
|
|
142
|
-
start=types.Position(line=max(rng.start.line + line_offset, 0), character=
|
|
143
|
-
end=types.Position(line=max(rng.end.line + line_offset, 0), character=
|
|
246
|
+
start=types.Position(line=max(rng.start.line + line_offset, 0), character=max(start_char, 0)),
|
|
247
|
+
end=types.Position(line=max(rng.end.line + line_offset, 0), character=max(end_char, 0)),
|
|
144
248
|
)
|
|
145
249
|
|
|
146
250
|
|
|
@@ -148,3 +252,15 @@ def _wrap_draconic(code: str) -> tuple[str, int]:
|
|
|
148
252
|
indented = "\n".join(f" {line}" for line in code.splitlines())
|
|
149
253
|
wrapped = f"def __alias_main__():\n{indented}\n__alias_main__()"
|
|
150
254
|
return wrapped, 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _dedupe_ranges(ranges: Iterable[types.Range]) -> List[types.Range]:
|
|
258
|
+
seen = set()
|
|
259
|
+
unique: list[types.Range] = []
|
|
260
|
+
for rng in ranges:
|
|
261
|
+
key = (rng.start.line, rng.start.character, rng.end.line, rng.end.character)
|
|
262
|
+
if key in seen:
|
|
263
|
+
continue
|
|
264
|
+
seen.add(key)
|
|
265
|
+
unique.append(rng)
|
|
266
|
+
return unique
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: avrae-ls
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary: Language server for Avrae draconic aliases
|
|
5
|
+
Author: 1drturtle
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pygls>=1.3.1
|
|
10
|
+
Requires-Dist: lsprotocol>=2023.0.1
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: d20>=1.1.2
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-cov>=7.0.0; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Avrae Draconic Alias Language Server
|
|
21
|
+
|
|
22
|
+
Language Server Protocol (LSP) implementation targeting Avrae-style draconic aliases. It provides syntax/semantic diagnostics, a mocked execution command, and a thin configuration layer driven by a workspace `.avraels.json` file. Credit to Avrae team for all code yoinked!
|
|
23
|
+
|
|
24
|
+
## Install (released package)
|
|
25
|
+
|
|
26
|
+
- CLI/server via `uv tool` (preferred): `uv tool install avrae-ls` then `avrae-ls --help` to see stdio/TCP options (same as `python -m avrae_ls`). The VS Code extension uses this invocation by default. The draconic interpreter is vendored, so no Git deps are needed.
|
|
27
|
+
|
|
28
|
+
## VS Code extension (released)
|
|
29
|
+
|
|
30
|
+
- Install from VSIX: download `avrae-ls-client.vsix` from the GitHub releases page, then in VS Code run “Extensions: Install from VSIX” and select the file.
|
|
31
|
+
- Open your alias workspace; commands like `Avrae: Show Alias Preview` and `Avrae: Run Alias` will be available.
|
|
32
|
+
|
|
33
|
+
## Developing locally
|
|
34
|
+
|
|
35
|
+
- Prereqs: [uv](https://github.com/astral-sh/uv) and Node.js.
|
|
36
|
+
- Install deps: `uv sync --all-extras` then `make vscode-deps`.
|
|
37
|
+
- Build everything locally: `make package` (wheel + VSIX in `dist/`).
|
|
38
|
+
- Run tests/lint: `make check`.
|
|
39
|
+
- Run via uv tool from source: `uv tool install --from . avrae-ls`.
|
|
40
|
+
- Run diagnostics for a single file (stdout + stderr logs): `avrae-ls --analyze path/to/alias.txt --log-level DEBUG`.
|
|
41
|
+
|
|
42
|
+
## How to test
|
|
43
|
+
|
|
44
|
+
- Quick check (ruff + pytest): `make check` (uses `uv run ruff` and `uv run pytest` under the hood).
|
|
45
|
+
- Lint only: `make lint` or `uv run ruff check src tests`.
|
|
46
|
+
- Tests only (with coverage): `make test` or `uv run pytest tests --cov=src`.
|
|
47
|
+
- CLI smoke test without installing: `uv run python -m avrae_ls --analyze path/to/alias.txt`.
|
|
48
|
+
|
|
49
|
+
## Runtime differences (mock vs. live Avrae)
|
|
50
|
+
|
|
51
|
+
- Mock execution never writes back to Avrae: cvar/uvar/gvar mutations only live for the current run and reset before the next.
|
|
52
|
+
- Network is limited to gvar fetches (when `enableGvarFetch` is true) and `verify_signature`; other Avrae/Discord calls are replaced with mocked context data from `.avraels.json`.
|
|
53
|
+
- `get_gvar`/`using` values are pulled from local var files first; remote fetches go to `https://api.avrae.io/customizations/gvars/<id>` (or your `avraeService.baseUrl`) using `avraeService.token` and are cached for the session.
|
|
54
|
+
- `signature()` returns a mock string (`mock-signature:<int>`). `verify_signature()` POSTs to `/bot/signature/verify`, respects `verifySignatureTimeout`/`verifySignatureRetries`, reuses the last successful response per signature, and includes `avraeService.token` if present.
|
|
55
|
+
|
|
56
|
+
## Troubleshooting gvar fetch / verify_signature
|
|
57
|
+
|
|
58
|
+
- `get_gvar` returns `None` or `using(...)` raises `ModuleNotFoundError`: ensure the workspace `.avraels.json` sets `enableGvarFetch: true`, includes a valid `avraeService.token`, or seed the gvar in a var file referenced by `varFiles`.
|
|
59
|
+
- HTTP 401/403/404 from fetch/verify calls: check the token (401/403) and the gvar/signature id (404). Override `avraeService.baseUrl` if you mirror the API.
|
|
60
|
+
- Slow or flaky calls: tune `verifySignatureTimeout` / `verifySignatureRetries`, or disable remote fetches by flipping `enableGvarFetch` off to rely purely on local vars.
|
|
61
|
+
|
|
62
|
+
## Other editors (stdio)
|
|
63
|
+
|
|
64
|
+
- Any client can launch the server with stdio: `avrae-ls --stdio` (flag accepted for client compatibility) or `python -m avrae_ls`. The server will also auto-discover `.avraels.json` in parent folders.
|
|
65
|
+
- Neovim (nvim-lspconfig example):
|
|
66
|
+
```lua
|
|
67
|
+
require("lspconfig").avraels.setup({
|
|
68
|
+
cmd = { "avrae-ls", "--stdio" },
|
|
69
|
+
filetypes = { "avrae" },
|
|
70
|
+
root_dir = require("lspconfig.util").root_pattern(".avraels.json", ".git"),
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
- Emacs (lsp-mode snippet):
|
|
74
|
+
```elisp
|
|
75
|
+
(lsp-register-client
|
|
76
|
+
(make-lsp-client
|
|
77
|
+
:new-connection (lsp-stdio-connection '("avrae-ls" "--stdio"))
|
|
78
|
+
:major-modes '(fundamental-mode) ;; bind to your Avrae alias mode
|
|
79
|
+
:server-id 'avrae-ls))
|
|
80
|
+
```
|
|
81
|
+
- VS Code commands to mirror: `Avrae: Run Alias (Mock)`, `Avrae: Show Alias Preview`, and `Avrae: Reload Workspace Config` run against the same server binary.
|
|
82
|
+
|
|
83
|
+
## Releasing (maintainers)
|
|
84
|
+
|
|
85
|
+
1. Bump `pyproject.toml` / `package.json`
|
|
86
|
+
2. Create Github release
|