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.
- avrae_ls/__init__.py +3 -0
- avrae_ls/__main__.py +272 -0
- avrae_ls/alias_preview.py +371 -0
- avrae_ls/alias_tests.py +351 -0
- avrae_ls/api.py +2015 -0
- avrae_ls/argparser.py +430 -0
- avrae_ls/argument_parsing.py +67 -0
- avrae_ls/code_actions.py +282 -0
- avrae_ls/codes.py +3 -0
- avrae_ls/completions.py +1695 -0
- avrae_ls/config.py +480 -0
- avrae_ls/context.py +337 -0
- avrae_ls/cvars.py +115 -0
- avrae_ls/diagnostics.py +826 -0
- avrae_ls/dice.py +33 -0
- avrae_ls/parser.py +68 -0
- avrae_ls/runtime.py +750 -0
- avrae_ls/server.py +447 -0
- avrae_ls/signature_help.py +248 -0
- avrae_ls/symbols.py +274 -0
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/METADATA +1 -1
- avrae_ls-0.6.2.dist-info/RECORD +34 -0
- draconic/__init__.py +4 -0
- draconic/exceptions.py +157 -0
- draconic/helpers.py +236 -0
- draconic/interpreter.py +1091 -0
- draconic/string.py +100 -0
- draconic/types.py +364 -0
- draconic/utils.py +78 -0
- draconic/versions.py +4 -0
- avrae_ls-0.6.0.dist-info/RECORD +0 -6
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/WHEEL +0 -0
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/entry_points.txt +0 -0
- {avrae_ls-0.6.0.dist-info → avrae_ls-0.6.2.dist-info}/licenses/LICENSE +0 -0
avrae_ls/diagnostics.py
ADDED
|
@@ -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
|