avrae-ls 0.6.0__py3-none-any.whl → 0.6.2__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.
@@ -0,0 +1,826 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import inspect
5
+ import logging
6
+ from typing import Any, Dict, Iterable, List, Sequence, Set
7
+
8
+ import draconic
9
+ from lsprotocol import types
10
+
11
+ from .alias_preview import simulate_command
12
+ from .codes import MISSING_GVAR_CODE, UNDEFINED_NAME_CODE, UNSUPPORTED_IMPORT_CODE
13
+ from .argument_parsing import apply_argument_parsing
14
+ from .completions import _infer_type_map, _resolve_type_name, _type_meta
15
+ from .config import DiagnosticSettings
16
+ from .context import ContextData, GVarResolver
17
+ from .parser import find_draconic_blocks
18
+ from .runtime import MockExecutor, _default_builtins
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ SEVERITY = {
23
+ "error": types.DiagnosticSeverity.Error,
24
+ "warning": types.DiagnosticSeverity.Warning,
25
+ "info": types.DiagnosticSeverity.Information,
26
+ }
27
+
28
+
29
+ class DiagnosticProvider:
30
+ def __init__(self, executor: MockExecutor, settings: DiagnosticSettings):
31
+ self._executor = executor
32
+ self._settings = settings
33
+ self._builtin_signatures = _build_builtin_signatures()
34
+
35
+ async def analyze(
36
+ self,
37
+ source: str,
38
+ ctx_data: ContextData,
39
+ gvar_resolver: GVarResolver,
40
+ ) -> List[types.Diagnostic]:
41
+ diagnostics: list[types.Diagnostic] = []
42
+
43
+ source = apply_argument_parsing(source)
44
+ blocks = find_draconic_blocks(source)
45
+ if not blocks:
46
+ plain = _plain_command_diagnostics(source)
47
+ if plain is not None:
48
+ diagnostics.extend(plain)
49
+ return diagnostics
50
+ diagnostics.extend(await self._analyze_code(source, ctx_data, gvar_resolver))
51
+ return diagnostics
52
+
53
+ for block in blocks:
54
+ block_diags = await self._analyze_code(block.code, ctx_data, gvar_resolver)
55
+ diagnostics.extend(_shift_diagnostics(block_diags, block.line_offset, block.char_offset))
56
+ return diagnostics
57
+
58
+ async def _analyze_code(
59
+ self,
60
+ code: str,
61
+ ctx_data: ContextData,
62
+ gvar_resolver: GVarResolver,
63
+ ) -> List[types.Diagnostic]:
64
+ diagnostics: list[types.Diagnostic] = []
65
+ parser = draconic.DraconicInterpreter()
66
+ line_shift = 0
67
+ try:
68
+ body = parser.parse(code)
69
+ except draconic.DraconicSyntaxError as exc:
70
+ wrapped, added = _wrap_draconic(code)
71
+ try:
72
+ body = parser.parse(wrapped)
73
+ line_shift = -added
74
+ except draconic.DraconicSyntaxError:
75
+ diagnostics.append(_syntax_diagnostic(exc))
76
+ return diagnostics
77
+ except SyntaxError as exc:
78
+ diagnostics.append(_syntax_from_std(exc))
79
+ return diagnostics
80
+
81
+ diagnostics.extend(
82
+ self._check_unknown_names(body, ctx_data, self._settings.semantic_level)
83
+ )
84
+ diagnostics.extend(await _check_gvars(body, gvar_resolver, self._settings))
85
+ diagnostics.extend(_check_imports(body, self._settings.semantic_level))
86
+ diagnostics.extend(_check_call_args(body, self._builtin_signatures, self._settings.semantic_level))
87
+ diagnostics.extend(_check_private_method_calls(body))
88
+ diagnostics.extend(
89
+ _check_api_misuse(body, code, ctx_data, self._settings.semantic_level)
90
+ )
91
+ if line_shift:
92
+ diagnostics = _shift_diagnostics(diagnostics, line_shift, 0)
93
+ return diagnostics
94
+
95
+ def _check_unknown_names(
96
+ self,
97
+ body: Sequence[ast.AST],
98
+ ctx_data: ContextData,
99
+ severity_level: str,
100
+ ) -> List[types.Diagnostic]:
101
+ known: Set[str] = set(self._executor.available_names(ctx_data))
102
+ diagnostics: list[types.Diagnostic] = []
103
+
104
+ class Walker(ast.NodeVisitor):
105
+ def __init__(self, base_known: Set[str]):
106
+ self._base_known = base_known
107
+ self._scopes: list[Set[str]] = [set()]
108
+
109
+ @property
110
+ def _current(self) -> Set[str]:
111
+ return self._scopes[-1]
112
+
113
+ def _define(self, names: Iterable[str]) -> None:
114
+ self._current.update(names)
115
+
116
+ def _is_defined(self, name: str) -> bool:
117
+ if name in self._base_known:
118
+ return True
119
+ return any(name in scope for scope in reversed(self._scopes))
120
+
121
+ def visit_Assign(self, node: ast.Assign):
122
+ self.visit(node.value)
123
+ for target in node.targets:
124
+ self._define(_names_in_target(target))
125
+
126
+ def visit_AnnAssign(self, node: ast.AnnAssign):
127
+ if node.value:
128
+ self.visit(node.value)
129
+ self._define(_names_in_target(node.target))
130
+
131
+ def visit_AugAssign(self, node: ast.AugAssign):
132
+ self.visit(node.value)
133
+ self._define(_names_in_target(node.target))
134
+
135
+ def visit_FunctionDef(self, node: ast.FunctionDef):
136
+ self._define([node.name])
137
+ self._with_new_scope(self._bind_args, node.args, node.body)
138
+
139
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
140
+ self._define([node.name])
141
+ self._with_new_scope(self._bind_args, node.args, node.body)
142
+
143
+ def visit_ClassDef(self, node: ast.ClassDef):
144
+ self._define([node.name])
145
+ self._with_new_scope(lambda _: None, None, node.body)
146
+
147
+ def visit_For(self, node: ast.For):
148
+ self.visit(node.iter)
149
+ self._define(_names_in_target(node.target))
150
+ for stmt in node.body:
151
+ self.visit(stmt)
152
+ for stmt in node.orelse:
153
+ self.visit(stmt)
154
+
155
+ def visit_AsyncFor(self, node: ast.AsyncFor):
156
+ self.visit(node.iter)
157
+ self._define(_names_in_target(node.target))
158
+ for stmt in node.body:
159
+ self.visit(stmt)
160
+ for stmt in node.orelse:
161
+ self.visit(stmt)
162
+
163
+ def visit_With(self, node: ast.With):
164
+ for item in node.items:
165
+ if item.optional_vars:
166
+ self._define(_names_in_target(item.optional_vars))
167
+ for stmt in node.body:
168
+ self.visit(stmt)
169
+
170
+ def visit_AsyncWith(self, node: ast.AsyncWith):
171
+ for item in node.items:
172
+ if item.optional_vars:
173
+ self._define(_names_in_target(item.optional_vars))
174
+ for stmt in node.body:
175
+ self.visit(stmt)
176
+
177
+ def visit_ListComp(self, node: ast.ListComp):
178
+ self._visit_comprehension(node.elt, node.generators)
179
+
180
+ def visit_SetComp(self, node: ast.SetComp):
181
+ self._visit_comprehension(node.elt, node.generators)
182
+
183
+ def visit_GeneratorExp(self, node: ast.GeneratorExp):
184
+ self._visit_comprehension(node.elt, node.generators)
185
+
186
+ def visit_DictComp(self, node: ast.DictComp):
187
+ self._visit_comprehension(node.key, node.generators)
188
+ self._visit_comprehension(node.value, node.generators)
189
+
190
+ def _visit_comprehension(self, expr: ast.AST, generators: list[ast.comprehension]):
191
+ # Comprehensions run in their own scope; bindings should not leak out
192
+ self._scopes.append(set())
193
+ try:
194
+ for gen in generators:
195
+ self.visit(gen.iter)
196
+ self._define(_names_in_target(gen.target))
197
+ for cond in gen.ifs:
198
+ self.visit(cond)
199
+ self.visit(expr)
200
+ finally:
201
+ self._scopes.pop()
202
+
203
+ def visit_Name(self, node: ast.Name):
204
+ if isinstance(node.ctx, ast.Load) and not self._is_defined(node.id):
205
+ diagnostics.append(
206
+ _make_diagnostic(
207
+ node,
208
+ f"'{node.id}' may be undefined in this scope",
209
+ severity_level,
210
+ code=UNDEFINED_NAME_CODE,
211
+ data={"name": node.id},
212
+ )
213
+ )
214
+
215
+ def visit_Call(self, node: ast.Call):
216
+ if isinstance(node.func, ast.Name) and node.func.id == "using":
217
+ for kw in node.keywords:
218
+ if kw.arg:
219
+ self._define([str(kw.arg)])
220
+ self.generic_visit(node)
221
+
222
+ def visit_NamedExpr(self, node: ast.NamedExpr):
223
+ self.visit(node.value)
224
+ self._define([node.target.id] if isinstance(node.target, ast.Name) else _names_in_target(node.target))
225
+
226
+ def _with_new_scope(self, binder, args, body: list[ast.stmt]):
227
+ self._scopes.append(set())
228
+ try:
229
+ if binder and args is not None:
230
+ binder(args)
231
+ for stmt in body:
232
+ self.visit(stmt)
233
+ finally:
234
+ self._scopes.pop()
235
+
236
+ def _bind_args(self, args: ast.arguments):
237
+ for arg in getattr(args, "posonlyargs", []):
238
+ self._define([arg.arg])
239
+ for arg in args.args:
240
+ self._define([arg.arg])
241
+ if args.vararg:
242
+ self._define([args.vararg.arg])
243
+ for arg in args.kwonlyargs:
244
+ self._define([arg.arg])
245
+ if args.kwarg:
246
+ self._define([args.kwarg.arg])
247
+
248
+ walker = Walker(known)
249
+ for stmt in body:
250
+ walker.visit(stmt)
251
+ return diagnostics
252
+
253
+
254
+ def _syntax_diagnostic(exc: draconic.DraconicSyntaxError) -> types.Diagnostic:
255
+ rng = _range_from_positions(
256
+ exc.lineno,
257
+ exc.offset,
258
+ exc.end_lineno,
259
+ exc.end_offset,
260
+ )
261
+ return types.Diagnostic(
262
+ message=exc.msg,
263
+ range=rng,
264
+ severity=types.DiagnosticSeverity.Error,
265
+ source="avrae-ls",
266
+ )
267
+
268
+
269
+ def _syntax_from_std(exc: SyntaxError) -> types.Diagnostic:
270
+ lineno, offset = exc.lineno, exc.offset
271
+ rng = _range_from_positions(lineno, offset, getattr(exc, "end_lineno", None), getattr(exc, "end_offset", None))
272
+ return types.Diagnostic(
273
+ message=exc.msg,
274
+ range=rng,
275
+ severity=types.DiagnosticSeverity.Error,
276
+ source="avrae-ls",
277
+ )
278
+
279
+
280
+ def _names_in_target(target: ast.AST) -> Set[str]:
281
+ names: set[str] = set()
282
+ if isinstance(target, ast.Name):
283
+ names.add(target.id)
284
+ elif isinstance(target, ast.Tuple):
285
+ for elt in target.elts:
286
+ names.update(_names_in_target(elt))
287
+ elif isinstance(target, ast.List):
288
+ for elt in target.elts:
289
+ names.update(_names_in_target(elt))
290
+ return names
291
+
292
+
293
+ async def _check_gvars(
294
+ body: Sequence[ast.AST],
295
+ resolver: GVarResolver,
296
+ settings: DiagnosticSettings,
297
+ ) -> List[types.Diagnostic]:
298
+ diagnostics: list[types.Diagnostic] = []
299
+ seen: set[str] = set()
300
+
301
+ def _literal_value(node: ast.AST) -> str | None:
302
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
303
+ return node.value
304
+ if isinstance(node, ast.Str):
305
+ return node.s
306
+ return None
307
+
308
+ for node in _iter_calls(body):
309
+ if not isinstance(node.func, ast.Name):
310
+ continue
311
+
312
+ async def _validate_gvar(arg_node: ast.AST):
313
+ gvar_id = _literal_value(arg_node)
314
+ if gvar_id is None or gvar_id in seen:
315
+ return
316
+ seen.add(gvar_id)
317
+ found_local = resolver.get_local(gvar_id)
318
+ ensured = found_local is not None or await resolver.ensure(gvar_id)
319
+ if not ensured:
320
+ diagnostics.append(
321
+ _make_diagnostic(
322
+ arg_node,
323
+ f"Unknown gvar '{gvar_id}'",
324
+ settings.semantic_level,
325
+ code=MISSING_GVAR_CODE,
326
+ data={"gvar": gvar_id},
327
+ )
328
+ )
329
+
330
+ if node.func.id == "get_gvar":
331
+ if node.args:
332
+ await _validate_gvar(node.args[0])
333
+ elif node.func.id == "using":
334
+ for kw in node.keywords:
335
+ await _validate_gvar(kw.value)
336
+ return diagnostics
337
+
338
+
339
+ def _iter_calls(body: Sequence[ast.AST]) -> Iterable[ast.Call]:
340
+ class Finder(ast.NodeVisitor):
341
+ def __init__(self):
342
+ self.calls: list[ast.Call] = []
343
+
344
+ def visit_Call(self, node: ast.Call):
345
+ self.calls.append(node)
346
+ self.generic_visit(node)
347
+
348
+ finder = Finder()
349
+ for stmt in body:
350
+ finder.visit(stmt)
351
+ return finder.calls
352
+
353
+
354
+ def _check_private_method_calls(body: Sequence[ast.AST]) -> List[types.Diagnostic]:
355
+ diagnostics: list[types.Diagnostic] = []
356
+
357
+ class Finder(ast.NodeVisitor):
358
+ def visit_Call(self, node: ast.Call):
359
+ func = node.func
360
+ if isinstance(func, ast.Attribute) and func.attr.startswith("_"):
361
+ diagnostics.append(
362
+ _make_diagnostic(
363
+ func,
364
+ "Calling private methods (starting with '_') is not allowed",
365
+ "error",
366
+ )
367
+ )
368
+ self.generic_visit(node)
369
+
370
+ finder = Finder()
371
+ for stmt in body:
372
+ finder.visit(stmt)
373
+ return diagnostics
374
+
375
+
376
+ def _check_api_misuse(
377
+ body: Sequence[ast.AST],
378
+ code: str,
379
+ ctx_data: ContextData,
380
+ severity_level: str,
381
+ ) -> List[types.Diagnostic]:
382
+ """Heuristics for common API mistakes (list vs scalar, missing context, property calls)."""
383
+ diagnostics: list[types.Diagnostic] = []
384
+ module = ast.Module(body=list(body), type_ignores=[])
385
+ parent_map = _build_parent_map(module)
386
+ assigned_names = _collect_assigned_names(module)
387
+ type_map = _diagnostic_type_map(code)
388
+ context_seen: set[str] = set()
389
+
390
+ for node in ast.walk(module):
391
+ if isinstance(node, ast.Call):
392
+ diagnostics.extend(_context_call_diagnostics(node, ctx_data, severity_level, context_seen))
393
+ diagnostics.extend(_property_call_diagnostics(node, type_map, code, severity_level))
394
+ if isinstance(node, ast.Attribute):
395
+ diagnostics.extend(_uncalled_context_attr_diagnostics(node, assigned_names, severity_level))
396
+ diagnostics.extend(_iterable_attr_diagnostics(node, parent_map, type_map, code, severity_level))
397
+ return diagnostics
398
+
399
+
400
+ def _context_call_diagnostics(
401
+ node: ast.Call,
402
+ ctx_data: ContextData,
403
+ severity_level: str,
404
+ seen: set[str],
405
+ ) -> List[types.Diagnostic]:
406
+ diagnostics: list[types.Diagnostic] = []
407
+ if isinstance(node.func, ast.Name):
408
+ if node.func.id == "character" and not ctx_data.character and "character" not in seen:
409
+ seen.add("character")
410
+ diagnostics.append(
411
+ _make_diagnostic(
412
+ node.func,
413
+ "No character context configured; character() will raise at runtime.",
414
+ severity_level,
415
+ )
416
+ )
417
+ elif node.func.id == "combat" and not ctx_data.combat and "combat" not in seen:
418
+ seen.add("combat")
419
+ diagnostics.append(
420
+ _make_diagnostic(
421
+ node.func,
422
+ "No combat context configured; combat() will return None.",
423
+ severity_level,
424
+ )
425
+ )
426
+ return diagnostics
427
+
428
+
429
+ def _property_call_diagnostics(
430
+ node: ast.Call,
431
+ type_map: Dict[str, str],
432
+ code: str,
433
+ severity_level: str,
434
+ ) -> List[types.Diagnostic]:
435
+ if not isinstance(node.func, ast.Attribute):
436
+ return []
437
+ base_type = _resolve_expr_type(node.func.value, type_map, code)
438
+ if not base_type:
439
+ return []
440
+ meta = _type_meta(base_type)
441
+ attr = node.func.attr
442
+ if attr in meta.methods or attr not in meta.attrs:
443
+ return []
444
+ receiver = _expr_to_str(node.func.value) or base_type
445
+ return [
446
+ _make_diagnostic(
447
+ node.func,
448
+ f"'{attr}' on {receiver} is a property; drop the parentheses.",
449
+ severity_level,
450
+ )
451
+ ]
452
+
453
+
454
+ def _uncalled_context_attr_diagnostics(
455
+ node: ast.Attribute,
456
+ assigned_names: Set[str],
457
+ severity_level: str,
458
+ ) -> List[types.Diagnostic]:
459
+ if isinstance(node.value, ast.Name) and node.value.id in {"character", "combat"} and node.value.id not in assigned_names:
460
+ call_hint = f"{node.value.id}()"
461
+ return [
462
+ _make_diagnostic(
463
+ node.value,
464
+ f"Call {call_hint} before accessing '{node.attr}'.",
465
+ severity_level,
466
+ )
467
+ ]
468
+ return []
469
+
470
+
471
+ def _iterable_attr_diagnostics(
472
+ node: ast.Attribute,
473
+ parent_map: Dict[ast.AST, ast.AST],
474
+ type_map: Dict[str, str],
475
+ code: str,
476
+ severity_level: str,
477
+ ) -> List[types.Diagnostic]:
478
+ parent = parent_map.get(node)
479
+ if parent is None:
480
+ return []
481
+ if isinstance(parent, ast.Subscript) and parent.value is node:
482
+ return []
483
+
484
+ base_type = _resolve_expr_type(node.value, type_map, code)
485
+ if not base_type:
486
+ return []
487
+ meta = _type_meta(base_type)
488
+ attr_meta = meta.attrs.get(node.attr)
489
+ if not attr_meta:
490
+ return []
491
+
492
+ is_collection = bool(attr_meta.element_type) or attr_meta.type_name in {"list", "dict"}
493
+ if not is_collection:
494
+ return []
495
+
496
+ expr_label = _expr_to_str(node) or node.attr
497
+ element_label = attr_meta.element_type or "items"
498
+ container_label = attr_meta.type_name or "collection"
499
+
500
+ if isinstance(parent, ast.Attribute) and parent.value is node:
501
+ next_attr = parent.attr
502
+ message = f"'{expr_label}' is a {container_label} of {element_label}; index or iterate before accessing '{next_attr}'."
503
+ return [_make_diagnostic(node, message, severity_level)]
504
+
505
+ if isinstance(parent, ast.Call) and parent.func is node:
506
+ message = f"'{expr_label}' is a {container_label} of {element_label}; index or iterate before calling it."
507
+ return [_make_diagnostic(node, message, severity_level)]
508
+
509
+ return []
510
+
511
+
512
+ def _diagnostic_type_map(code: str) -> Dict[str, str]:
513
+ mapping = _infer_type_map(code)
514
+ if mapping:
515
+ return mapping
516
+ wrapped, _ = _wrap_draconic(code)
517
+ return _infer_type_map(wrapped)
518
+
519
+
520
+ def _resolve_expr_type(expr: ast.AST, type_map: Dict[str, str], code: str) -> str:
521
+ expr_text = _expr_to_str(expr)
522
+ if not expr_text:
523
+ return ""
524
+ return _resolve_type_name(expr_text, code, type_map)
525
+
526
+
527
+ def _expr_to_str(expr: ast.AST) -> str:
528
+ try:
529
+ return ast.unparse(expr)
530
+ except Exception:
531
+ return ""
532
+
533
+
534
+ def _collect_assigned_names(module: ast.Module) -> Set[str]:
535
+ assigned: set[str] = set()
536
+
537
+ class Collector(ast.NodeVisitor):
538
+ def visit_Assign(self, node: ast.Assign):
539
+ for target in node.targets:
540
+ assigned.update(_names_in_target(target))
541
+ self.generic_visit(node)
542
+
543
+ def visit_AnnAssign(self, node: ast.AnnAssign):
544
+ assigned.update(_names_in_target(node.target))
545
+ self.generic_visit(node)
546
+
547
+ def visit_For(self, node: ast.For):
548
+ assigned.update(_names_in_target(node.target))
549
+ self.generic_visit(node)
550
+
551
+ def visit_AsyncFor(self, node: ast.AsyncFor):
552
+ assigned.update(_names_in_target(node.target))
553
+ self.generic_visit(node)
554
+
555
+ def visit_FunctionDef(self, node: ast.FunctionDef):
556
+ assigned.add(node.name)
557
+ for arg in node.args.args:
558
+ assigned.add(arg.arg)
559
+ self.generic_visit(node)
560
+
561
+ def visit_ClassDef(self, node: ast.ClassDef):
562
+ assigned.add(node.name)
563
+ self.generic_visit(node)
564
+
565
+ Collector().visit(module)
566
+ return assigned
567
+
568
+
569
+ def _build_parent_map(root: ast.AST) -> Dict[ast.AST, ast.AST]:
570
+ parents: dict[ast.AST, ast.AST] = {}
571
+ for parent in ast.walk(root):
572
+ for child in ast.iter_child_nodes(parent):
573
+ parents[child] = parent
574
+ return parents
575
+
576
+
577
+ def _make_diagnostic(
578
+ node: ast.AST,
579
+ message: str,
580
+ level: str,
581
+ *,
582
+ code: str | None = None,
583
+ data: Dict[str, Any] | None = None,
584
+ ) -> types.Diagnostic:
585
+ severity = SEVERITY.get(level, types.DiagnosticSeverity.Warning)
586
+ if hasattr(node, "lineno"):
587
+ rng = _range_from_positions(
588
+ getattr(node, "lineno", 1),
589
+ getattr(node, "col_offset", 0) + 1,
590
+ getattr(node, "end_lineno", None),
591
+ getattr(node, "end_col_offset", None),
592
+ )
593
+ else:
594
+ rng = types.Range(
595
+ start=types.Position(line=0, character=0),
596
+ end=types.Position(line=0, character=1),
597
+ )
598
+ return types.Diagnostic(
599
+ message=message,
600
+ range=rng,
601
+ severity=severity,
602
+ source="avrae-ls",
603
+ code=code,
604
+ data=data,
605
+ )
606
+
607
+
608
+ def _shift_diagnostics(diags: List[types.Diagnostic], line_offset: int, char_offset: int) -> List[types.Diagnostic]:
609
+ shifted: list[types.Diagnostic] = []
610
+ for diag in diags:
611
+ shifted.append(
612
+ types.Diagnostic(
613
+ message=diag.message,
614
+ range=_shift_range(diag.range, line_offset, char_offset),
615
+ severity=diag.severity,
616
+ source=diag.source,
617
+ code=diag.code,
618
+ code_description=diag.code_description,
619
+ tags=diag.tags,
620
+ related_information=diag.related_information,
621
+ data=diag.data,
622
+ )
623
+ )
624
+ return shifted
625
+
626
+
627
+ def _shift_range(rng: types.Range, line_offset: int, char_offset: int) -> types.Range:
628
+ def _shift_pos(pos: types.Position) -> types.Position:
629
+ return types.Position(
630
+ line=max(pos.line + line_offset, 0),
631
+ character=max(pos.character + (char_offset if pos.line == 0 else 0), 0),
632
+ )
633
+
634
+ return types.Range(start=_shift_pos(rng.start), end=_shift_pos(rng.end))
635
+
636
+
637
+ def _wrap_draconic(code: str) -> tuple[str, int]:
638
+ indented = "\n".join(f" {line}" for line in code.splitlines())
639
+ wrapped = f"def __alias_main__():\n{indented}\n__alias_main__()"
640
+ return wrapped, 1
641
+
642
+
643
+ def _build_builtin_signatures() -> dict[str, inspect.Signature]:
644
+ sigs: dict[str, inspect.Signature] = {}
645
+ builtins = _default_builtins()
646
+
647
+ def try_add(name: str, obj):
648
+ try:
649
+ sigs[name] = inspect.signature(obj)
650
+ except (TypeError, ValueError):
651
+ pass
652
+
653
+ for name, obj in builtins.items():
654
+ try_add(name, obj)
655
+
656
+ # runtime helpers we expose
657
+ def get_gvar(key): ...
658
+ def get_svar(name, default=None): ...
659
+ def get_uvar(name, default=None): ...
660
+ def get_uvars(): ...
661
+ def set_uvar(name, value): ...
662
+ def set_uvar_nx(name, value): ...
663
+ def delete_uvar(name): ...
664
+ def uvar_exists(name): ...
665
+ def exists(name): ...
666
+ def get(name, default=None): ...
667
+ def using(**imports): ...
668
+ def signature(data=0): ...
669
+ def verify_signature(data): ...
670
+ def print_fn(*args, sep=" ", end="\n"): ...
671
+
672
+ helpers = {
673
+ "get_gvar": get_gvar,
674
+ "get_svar": get_svar,
675
+ "get_uvar": get_uvar,
676
+ "get_uvars": get_uvars,
677
+ "set_uvar": set_uvar,
678
+ "set_uvar_nx": set_uvar_nx,
679
+ "delete_uvar": delete_uvar,
680
+ "uvar_exists": uvar_exists,
681
+ "exists": exists,
682
+ "get": get,
683
+ "using": using,
684
+ "signature": signature,
685
+ "verify_signature": verify_signature,
686
+ "print": print_fn,
687
+ }
688
+ for name, obj in helpers.items():
689
+ try_add(name, obj)
690
+ return sigs
691
+
692
+
693
+ def _check_call_args(
694
+ body: Sequence[ast.AST],
695
+ signatures: dict[str, inspect.Signature],
696
+ severity_level: str,
697
+ ) -> List[types.Diagnostic]:
698
+ diagnostics: list[types.Diagnostic] = []
699
+
700
+ class Visitor(ast.NodeVisitor):
701
+ def visit_Call(self, node: ast.Call):
702
+ if isinstance(node.func, ast.Name):
703
+ fn = node.func.id
704
+ if fn in signatures:
705
+ sig = signatures[fn]
706
+ if not _call_args_match(sig, node):
707
+ diagnostics.append(
708
+ _make_diagnostic(
709
+ node.func,
710
+ f"Call to '{fn}' may have invalid arguments",
711
+ severity_level,
712
+ )
713
+ )
714
+ self.generic_visit(node)
715
+
716
+ visitor = Visitor()
717
+ for stmt in body:
718
+ visitor.visit(stmt)
719
+ return diagnostics
720
+
721
+
722
+ def _call_args_match(sig: inspect.Signature, call: ast.Call) -> bool:
723
+ params = list(sig.parameters.values())
724
+ required = [
725
+ p
726
+ for p in params
727
+ if p.default is inspect._empty
728
+ and p.kind
729
+ in (
730
+ inspect.Parameter.POSITIONAL_ONLY,
731
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
732
+ )
733
+ ]
734
+ max_args = None
735
+ if any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params):
736
+ max_args = None
737
+ else:
738
+ max_args = len(
739
+ [
740
+ p
741
+ for p in params
742
+ if p.kind
743
+ in (
744
+ inspect.Parameter.POSITIONAL_ONLY,
745
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
746
+ )
747
+ ]
748
+ )
749
+
750
+ arg_count = len(call.args)
751
+ if arg_count < len(required):
752
+ return False
753
+ if max_args is not None and arg_count > max_args:
754
+ return False
755
+ return True
756
+
757
+
758
+ def _check_imports(body: Sequence[ast.AST], severity_level: str) -> List[types.Diagnostic]:
759
+ diagnostics: list[types.Diagnostic] = []
760
+
761
+ class Visitor(ast.NodeVisitor):
762
+ def visit_Import(self, node: ast.Import):
763
+ module = node.names[0].name if node.names else None
764
+ diagnostics.append(
765
+ _make_diagnostic(
766
+ node,
767
+ "Imports are not supported in draconic aliases",
768
+ severity_level,
769
+ code=UNSUPPORTED_IMPORT_CODE,
770
+ data={"module": module} if module else None,
771
+ )
772
+ )
773
+
774
+ def visit_ImportFrom(self, node: ast.ImportFrom):
775
+ diagnostics.append(
776
+ _make_diagnostic(
777
+ node,
778
+ "Imports are not supported in draconic aliases",
779
+ severity_level,
780
+ code=UNSUPPORTED_IMPORT_CODE,
781
+ data={"module": node.module},
782
+ )
783
+ )
784
+
785
+ visitor = Visitor()
786
+ for stmt in body:
787
+ visitor.visit(stmt)
788
+ return diagnostics
789
+
790
+
791
+ def _range_from_positions(
792
+ lineno: int | None,
793
+ col_offset: int | None,
794
+ end_lineno: int | None,
795
+ end_col_offset: int | None,
796
+ ) -> types.Range:
797
+ start = types.Position(
798
+ line=max((lineno or 1) - 1, 0),
799
+ character=max((col_offset or 1) - 1, 0),
800
+ )
801
+ end = types.Position(
802
+ line=max(((end_lineno or lineno or 1) - 1), 0),
803
+ character=max(((end_col_offset or col_offset or 1) - 1), 0),
804
+ )
805
+ return types.Range(start=start, end=end)
806
+
807
+
808
+ def _plain_command_diagnostics(source: str) -> list[types.Diagnostic] | None:
809
+ """Handle simple commands (embed/echo) without draconic blocks."""
810
+ simulated = simulate_command(source)
811
+ if not simulated.command_name:
812
+ return None
813
+ if simulated.command_name == "embed":
814
+ if simulated.validation_error:
815
+ return [
816
+ types.Diagnostic(
817
+ message=simulated.validation_error,
818
+ range=_range_from_positions(1, 1, 1, 1),
819
+ severity=SEVERITY["warning"],
820
+ source="avrae-ls",
821
+ )
822
+ ]
823
+ return []
824
+ if simulated.command_name == "echo":
825
+ return []
826
+ return None