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/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
- def _parse_coins(args: str):
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
- # Fallback: accept numeric as gp, otherwise empty mapping.
114
- try:
115
- gp = float(str(args))
116
- return {"pp": 0, "gp": gp, "ep": 0, "sp": 0, "cp": 0, "total": gp}
117
- except Exception:
118
- return {"pp": 0, "gp": 0, "ep": 0, "sp": 0, "cp": 0, "total": 0}
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
- try:
425
- resp = httpx.post(url, json={"signature": signature}, headers=headers, timeout=5)
426
- except Exception as exc:
427
- raise ValueError(f"Failed to verify signature: {exc}") from exc
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 = payload.get("error") if isinstance(payload, dict) else None
436
- raise ValueError(message or f"Failed to verify signature: HTTP {resp.status_code}")
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(message or "Failed to verify signature: unsuccessful response")
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 .symbols import build_symbol_table, document_symbols, find_definition_range
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
- preview_output, command_name, validation_error = simulate_command(rendered.command)
299
+ preview = simulate_command(rendered.command)
256
300
 
257
301
  response: dict[str, Any] = {
258
302
  "stdout": rendered.stdout,
259
- "result": preview_output if preview_output is not None else rendered.last_value,
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:
@@ -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
- for node in ast.walk(tree):
161
- if isinstance(node, ast.Call):
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
- target_call = node
168
- break
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 = min(len(target_call.args), max(len(fsig.params) - 1, 0))
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 _symbols_from_code(code: str, line_offset: int) -> List[SymbolEntry]:
71
- parser = draconic.DraconicInterpreter()
72
- local_offset = line_offset
73
- try:
74
- body = parser.parse(code)
75
- except draconic.DraconicSyntaxError:
76
- wrapped, added = _wrap_draconic(code)
77
- try:
78
- body = parser.parse(wrapped)
79
- local_offset += -added
80
- except draconic.DraconicSyntaxError:
81
- return []
82
- except Exception as exc: # pragma: no cover - defensive
83
- log.debug("Symbol extraction failed: %s", exc)
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) + 1,
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
- start = types.Position(
128
- line=max((lineno or 1) - 1, 0),
129
- character=max((col_offset or 1) - 1, 0),
130
- )
131
- end = types.Position(
132
- line=max(((end_lineno or lineno or 1) - 1), 0),
133
- character=max(((end_col_offset or col_offset or 1) - 1), 0),
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 rng
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=rng.start.character),
143
- end=types.Position(line=max(rng.end.line + line_offset, 0), character=rng.end.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