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/completions.py CHANGED
@@ -11,6 +11,7 @@ from typing import Any, ClassVar, Dict, Iterable, List, Optional
11
11
  from lsprotocol import types
12
12
 
13
13
  from .context import ContextData, GVarResolver
14
+ from .argparser import ParsedArguments
14
15
  from .runtime import _default_builtins
15
16
  from .api import (
16
17
  AliasAction,
@@ -123,7 +124,9 @@ TYPE_MAP: Dict[str, object] = {
123
124
  "attacks": AliasAttackList,
124
125
  "attack": AliasAttack,
125
126
  "skills": AliasSkills,
127
+ "AliasSkills": AliasSkills,
126
128
  "skill": AliasSkill,
129
+ "AliasSkill": AliasSkill,
127
130
  "saves": AliasSaves,
128
131
  "resistances": AliasResistances,
129
132
  "coinpurse": AliasCoinpurse,
@@ -147,6 +150,7 @@ TYPE_MAP: Dict[str, object] = {
147
150
  "list": _BuiltinList,
148
151
  "dict": _BuiltinDict,
149
152
  "str": _BuiltinStr,
153
+ "ParsedArguments": ParsedArguments,
150
154
  }
151
155
 
152
156
 
@@ -184,6 +188,226 @@ class TypeMeta:
184
188
  element_type: str = ""
185
189
 
186
190
 
191
+ _SKILL_DOCS: dict[str, str] = {
192
+ "acrobatics": "Acrobatics skill bonus.",
193
+ "animalHandling": "Animal Handling skill bonus.",
194
+ "arcana": "Arcana skill bonus.",
195
+ "athletics": "Athletics skill bonus.",
196
+ "deception": "Deception skill bonus.",
197
+ "history": "History skill bonus.",
198
+ "initiative": "Initiative modifier.",
199
+ "insight": "Insight skill bonus.",
200
+ "intimidation": "Intimidation skill bonus.",
201
+ "investigation": "Investigation skill bonus.",
202
+ "medicine": "Medicine skill bonus.",
203
+ "nature": "Nature skill bonus.",
204
+ "perception": "Perception skill bonus.",
205
+ "performance": "Performance skill bonus.",
206
+ "persuasion": "Persuasion skill bonus.",
207
+ "religion": "Religion skill bonus.",
208
+ "sleightOfHand": "Sleight of Hand skill bonus.",
209
+ "stealth": "Stealth skill bonus.",
210
+ "survival": "Survival skill bonus.",
211
+ "strength": "Strength ability score for this skill block.",
212
+ "dexterity": "Dexterity ability score for this skill block.",
213
+ "constitution": "Constitution ability score for this skill block.",
214
+ "intelligence": "Intelligence ability score for this skill block.",
215
+ "wisdom": "Wisdom ability score for this skill block.",
216
+ "charisma": "Charisma ability score for this skill block.",
217
+ }
218
+
219
+ _COUNTER_DOCS: dict[str, str] = {
220
+ "name": "Internal name of the counter.",
221
+ "title": "Display title for the counter.",
222
+ "desc": "Description text for the counter.",
223
+ "value": "Current counter value.",
224
+ "max": "Maximum value for the counter.",
225
+ "min": "Minimum value for the counter.",
226
+ "reset_on": "Reset cadence for the counter (e.g., long/short rest).",
227
+ "display_type": "Display style for the counter.",
228
+ "reset_to": "Value to reset the counter to.",
229
+ "reset_by": "Increment applied when the counter resets.",
230
+ }
231
+
232
+ _EFFECT_DOCS: dict[str, str] = {
233
+ "name": "Effect name.",
234
+ "duration": "Configured duration for the effect.",
235
+ "remaining": "Remaining duration for the effect.",
236
+ "effect": "Raw effect payload.",
237
+ "attacks": "Attack data attached to the effect, if any.",
238
+ "buttons": "Buttons provided by the effect.",
239
+ "conc": "Whether the effect requires concentration.",
240
+ "desc": "Effect description text.",
241
+ "ticks_on_end": "Whether the effect ticks when it ends.",
242
+ "combatant_name": "Name of the owning combatant.",
243
+ "parent": "Parent effect, if nested.",
244
+ "children": "Child effects nested under this effect.",
245
+ }
246
+
247
+ _ATTR_DOC_OVERRIDES: dict[str, dict[str, str]] = {
248
+ "SimpleRollResult": {
249
+ "dice": "Markdown representation of the dice that were rolled.",
250
+ "total": "Numeric total of the resolved roll.",
251
+ "full": "Rendered roll result string.",
252
+ "result": "Underlying d20 RollResult object.",
253
+ "raw": "Original d20 expression for the roll.",
254
+ },
255
+ "stats": {
256
+ "prof_bonus": "Proficiency bonus for the character.",
257
+ "strength": "Strength ability score.",
258
+ "dexterity": "Dexterity ability score.",
259
+ "constitution": "Constitution ability score.",
260
+ "intelligence": "Intelligence ability score.",
261
+ "wisdom": "Wisdom ability score.",
262
+ "charisma": "Charisma ability score.",
263
+ },
264
+ "AliasBaseStats": {
265
+ "prof_bonus": "Proficiency bonus for the character.",
266
+ "strength": "Strength ability score.",
267
+ "dexterity": "Dexterity ability score.",
268
+ "constitution": "Constitution ability score.",
269
+ "intelligence": "Intelligence ability score.",
270
+ "wisdom": "Wisdom ability score.",
271
+ "charisma": "Charisma ability score.",
272
+ },
273
+ "levels": {
274
+ "total_level": "Sum of all class levels.",
275
+ },
276
+ "AliasLevels": {
277
+ "total_level": "Sum of all class levels.",
278
+ },
279
+ "attack": {
280
+ "name": "Attack name.",
281
+ "verb": "Attack verb or action phrase.",
282
+ "proper": "Whether the attack name is treated as proper.",
283
+ "activation_type": "Activation type identifier for this attack.",
284
+ "raw": "Raw attack payload from the statblock.",
285
+ },
286
+ "AliasAttack": {
287
+ "name": "Attack name.",
288
+ "verb": "Attack verb or action phrase.",
289
+ "proper": "Whether the attack name is treated as proper.",
290
+ "activation_type": "Activation type identifier for this attack.",
291
+ "raw": "Raw attack payload from the statblock.",
292
+ },
293
+ "skills": _SKILL_DOCS,
294
+ "AliasSkills": _SKILL_DOCS,
295
+ "skill": {
296
+ "value": "Total modifier for the skill.",
297
+ "prof": "Proficiency value applied to the skill.",
298
+ "bonus": "Base bonus before rolling.",
299
+ "adv": "Advantage state for the skill roll (True/False/None).",
300
+ },
301
+ "AliasSkill": {
302
+ "value": "Total modifier for the skill.",
303
+ "prof": "Proficiency value applied to the skill.",
304
+ "bonus": "Base bonus before rolling.",
305
+ "adv": "Advantage state for the skill roll (True/False/None).",
306
+ },
307
+ "resistances": {
308
+ "resist": "Damage types resisted.",
309
+ "vuln": "Damage types this target is vulnerable to.",
310
+ "immune": "Damage types the target is immune to.",
311
+ "neutral": "Damage types with no modifiers.",
312
+ },
313
+ "AliasResistances": {
314
+ "resist": "Damage types resisted.",
315
+ "vuln": "Damage types this target is vulnerable to.",
316
+ "immune": "Damage types the target is immune to.",
317
+ "neutral": "Damage types with no modifiers.",
318
+ },
319
+ "coinpurse": {
320
+ "pp": "Platinum pieces carried.",
321
+ "gp": "Gold pieces carried.",
322
+ "ep": "Electrum pieces carried.",
323
+ "sp": "Silver pieces carried.",
324
+ "cp": "Copper pieces carried.",
325
+ "total": "Total value of all coins.",
326
+ },
327
+ "AliasCoinpurse": {
328
+ "pp": "Platinum pieces carried.",
329
+ "gp": "Gold pieces carried.",
330
+ "ep": "Electrum pieces carried.",
331
+ "sp": "Silver pieces carried.",
332
+ "cp": "Copper pieces carried.",
333
+ "total": "Total value of all coins.",
334
+ },
335
+ "custom_counter": _COUNTER_DOCS,
336
+ "consumable": _COUNTER_DOCS,
337
+ "AliasCustomCounter": _COUNTER_DOCS,
338
+ "death_saves": {
339
+ "successes": "Number of successful death saves.",
340
+ "fails": "Number of failed death saves.",
341
+ },
342
+ "AliasDeathSaves": {
343
+ "successes": "Number of successful death saves.",
344
+ "fails": "Number of failed death saves.",
345
+ },
346
+ "spellbook": {
347
+ "dc": "Save DC for spells in this spellbook.",
348
+ "sab": "Spell attack bonus for this spellbook.",
349
+ "caster_level": "Caster level used for the spellbook.",
350
+ "spell_mod": "Spellcasting ability modifier.",
351
+ "spells": "Spells grouped by level.",
352
+ "pact_slot_level": "Level of pact slots, if any.",
353
+ "num_pact_slots": "Number of pact slots available.",
354
+ "max_pact_slots": "Maximum pact slots available.",
355
+ },
356
+ "AliasSpellbook": {
357
+ "dc": "Save DC for spells in this spellbook.",
358
+ "sab": "Spell attack bonus for this spellbook.",
359
+ "caster_level": "Caster level used for the spellbook.",
360
+ "spell_mod": "Spellcasting ability modifier.",
361
+ "spells": "Spells grouped by level.",
362
+ "pact_slot_level": "Level of pact slots, if any.",
363
+ "num_pact_slots": "Number of pact slots available.",
364
+ "max_pact_slots": "Maximum pact slots available.",
365
+ },
366
+ "spell": {
367
+ "name": "Spell name.",
368
+ "dc": "Save DC for this spell.",
369
+ "sab": "Spell attack bonus for this spell.",
370
+ "mod": "Spellcasting modifier applied to the spell.",
371
+ "prepared": "Whether the spell is prepared/known.",
372
+ },
373
+ "AliasSpellbookSpell": {
374
+ "name": "Spell name.",
375
+ "dc": "Save DC for this spell.",
376
+ "sab": "Spell attack bonus for this spell.",
377
+ "mod": "Spellcasting modifier applied to the spell.",
378
+ "prepared": "Whether the spell is prepared/known.",
379
+ },
380
+ "guild": {
381
+ "name": "Guild (server) name.",
382
+ "id": "Guild (server) id.",
383
+ },
384
+ "channel": {
385
+ "name": "Channel name.",
386
+ "id": "Channel id.",
387
+ "topic": "Channel topic, if set.",
388
+ "category": "Parent category for the channel.",
389
+ "parent": "Parent channel, if present.",
390
+ },
391
+ "category": {
392
+ "name": "Category name.",
393
+ "id": "Category id.",
394
+ },
395
+ "author": {
396
+ "name": "User name for the invoking author.",
397
+ "id": "User id for the invoking author.",
398
+ "discriminator": "User discriminator/tag.",
399
+ "display_name": "Display name for the author.",
400
+ "roles": "Roles held by the author.",
401
+ },
402
+ "role": {
403
+ "name": "Role name.",
404
+ "id": "Role id.",
405
+ },
406
+ "effect": _EFFECT_DOCS,
407
+ "SimpleEffect": _EFFECT_DOCS,
408
+ }
409
+
410
+
187
411
  def gather_suggestions(
188
412
  ctx_data: ContextData,
189
413
  resolver: GVarResolver,
@@ -459,96 +683,257 @@ def _infer_type_map(code: str) -> Dict[str, str]:
459
683
  tree = ast.parse(code)
460
684
  except SyntaxError:
461
685
  return {}
462
- type_map: dict[str, str] = {}
463
-
464
- class Visitor(ast.NodeVisitor):
465
- def visit_Assign(self, node: ast.Assign):
466
- val_type, elem_type = self._value_type(node.value)
467
- for target in node.targets:
468
- if not isinstance(target, ast.Name):
469
- continue
470
- if val_type:
471
- type_map[target.id] = val_type
472
- if elem_type:
473
- type_map[f"{target.id}.__element__"] = elem_type
474
- self._record_dict_key_types(target.id, node.value)
475
- self.generic_visit(node)
686
+ visitor = _TypeInferencer(code)
687
+ visitor.visit(tree)
688
+ return visitor.type_map
689
+
690
+
691
+ class _TypeInferencer(ast.NodeVisitor):
692
+ def __init__(self, code: str) -> None:
693
+ self.code = code
694
+ self.type_map: dict[str, str] = {}
695
+
696
+ def visit_Assign(self, node: ast.Assign):
697
+ val_type, elem_type = self._value_type(node.value)
698
+ for target in node.targets:
699
+ self._bind_target(target, val_type, elem_type, node.value)
700
+ self.generic_visit(node)
701
+
702
+ def visit_AnnAssign(self, node: ast.AnnAssign):
703
+ val_type, elem_type = self._value_type(node.value) if node.value else (None, None)
704
+ self._bind_target(node.target, val_type, elem_type, node.value)
705
+ self.generic_visit(node)
706
+
707
+ def visit_AugAssign(self, node: ast.AugAssign):
708
+ val_type, elem_type = self._value_type(node.value)
709
+ self._bind_target(
710
+ node.target,
711
+ val_type or self._existing_type(node.target),
712
+ elem_type or self._existing_element(node.target),
713
+ None,
714
+ )
715
+ self.generic_visit(node)
716
+
717
+ def visit_For(self, node: ast.For):
718
+ _, elem_type = self._value_type(node.iter)
719
+ if not elem_type and isinstance(node.iter, ast.Name):
720
+ elem_type = self.type_map.get(f"{node.iter.id}.__element__")
721
+ self._bind_target(node.target, elem_type, None, None)
722
+ self.generic_visit(node)
723
+
724
+ def visit_AsyncFor(self, node: ast.AsyncFor):
725
+ _, elem_type = self._value_type(node.iter)
726
+ if not elem_type and isinstance(node.iter, ast.Name):
727
+ elem_type = self.type_map.get(f"{node.iter.id}.__element__")
728
+ self._bind_target(node.target, elem_type, None, None)
729
+ self.generic_visit(node)
730
+
731
+ def visit_If(self, node: ast.If):
732
+ self.visit(node.test)
733
+ base_map = self.type_map.copy()
734
+ body_map = self._visit_block(node.body, base_map.copy())
735
+ orelse_seed = base_map.copy()
736
+ orelse_map = self._visit_block(node.orelse, orelse_seed) if node.orelse else orelse_seed
737
+ self.type_map = self._merge_branch_types(base_map, body_map, orelse_map)
738
+
739
+ def _visit_block(self, nodes: Iterable[ast.stmt], seed: dict[str, str]) -> dict[str, str]:
740
+ walker = _TypeInferencer(self.code)
741
+ walker.type_map = seed
742
+ for stmt in nodes:
743
+ walker.visit(stmt)
744
+ return walker.type_map
745
+
746
+ def _merge_branch_types(self, base: dict[str, str], left: dict[str, str], right: dict[str, str]) -> dict[str, str]:
747
+ merged = base.copy()
748
+ for key in set(left) | set(right):
749
+ l_val = left.get(key)
750
+ r_val = right.get(key)
751
+ if l_val and r_val and l_val == r_val:
752
+ merged[key] = l_val
753
+ elif key in base:
754
+ merged[key] = base[key]
755
+ elif l_val and not r_val:
756
+ merged[key] = l_val
757
+ elif r_val and not l_val:
758
+ merged[key] = r_val
759
+ elif key in merged:
760
+ merged.pop(key, None)
761
+ return merged
762
+
763
+ def _bind_target(self, target: ast.AST, val_type: Optional[str], elem_type: Optional[str], source: ast.AST | None):
764
+ if isinstance(target, ast.Name):
765
+ if val_type:
766
+ self.type_map[target.id] = val_type
767
+ if elem_type:
768
+ self.type_map[f"{target.id}.__element__"] = elem_type
769
+ if source is not None:
770
+ self._record_dict_key_types(target.id, source)
771
+ elif isinstance(target, (ast.Tuple, ast.List)):
772
+ for elt in target.elts:
773
+ self._bind_target(elt, val_type, elem_type, source)
476
774
 
477
- def visit_For(self, node: ast.For):
478
- iter_type, elem_type = self._value_type(node.iter)
479
- if not elem_type and isinstance(node.iter, ast.Name):
480
- elem_type = type_map.get(f"{node.iter.id}.__element__")
481
- if elem_type and isinstance(node.target, ast.Name):
482
- type_map[node.target.id] = elem_type
483
- self.generic_visit(node)
775
+ def _existing_type(self, target: ast.AST) -> Optional[str]:
776
+ if isinstance(target, ast.Name):
777
+ return self.type_map.get(target.id)
778
+ return None
484
779
 
485
- def visit_AnnAssign(self, node: ast.AnnAssign):
486
- val_type, elem_type = self._value_type(node.value) if node.value else (None, None)
487
- if isinstance(node.target, ast.Name):
488
- if val_type:
489
- type_map[node.target.id] = val_type
490
- if elem_type:
491
- type_map[f"{node.target.id}.__element__"] = elem_type
492
- self._record_dict_key_types(node.target.id, node.value)
493
- self.generic_visit(node)
780
+ def _existing_element(self, target: ast.AST) -> Optional[str]:
781
+ if isinstance(target, ast.Name):
782
+ return self.type_map.get(f"{target.id}.__element__")
783
+ return None
494
784
 
495
- def _value_type(self, value: ast.AST | None) -> tuple[Optional[str], Optional[str]]:
496
- if isinstance(value, ast.Call) and isinstance(value.func, ast.Name):
785
+ def _value_type(self, value: ast.AST | None) -> tuple[Optional[str], Optional[str]]:
786
+ if isinstance(value, ast.Call):
787
+ if isinstance(value.func, ast.Name):
497
788
  if value.func.id in {"character", "combat"}:
498
789
  return value.func.id, None
499
790
  if value.func.id == "vroll":
500
791
  return "SimpleRollResult", None
792
+ if value.func.id == "argparse":
793
+ return "ParsedArguments", None
794
+ if value.func.id == "range":
795
+ return "range", "int"
501
796
  if value.func.id in {"list", "dict", "str"}:
502
797
  return value.func.id, None
503
- if isinstance(value, ast.List):
504
- return "list", None
505
- if isinstance(value, ast.Dict):
506
- return "dict", None
507
- if isinstance(value, ast.Constant):
508
- if isinstance(value.value, str):
509
- return "str", None
510
- if isinstance(value, ast.Name):
511
- if value.id in type_map:
512
- return type_map[value.id], type_map.get(f"{value.id}.__element__")
513
- if value.id in {"character", "combat", "ctx"}:
514
- return value.id, None
515
- if isinstance(value, ast.Attribute):
516
- attr_name = value.attr
517
- base_type = None
518
- base_elem = None
519
- if isinstance(value.value, ast.Name):
520
- base_type = type_map.get(value.value.id)
521
- base_elem = type_map.get(f"{value.value.id}.__element__")
522
- if base_type is None:
523
- base_type, base_elem = self._value_type(value.value)
524
- if base_type:
525
- meta = _type_meta(base_type)
526
- attr_meta = meta.attrs.get(attr_name)
527
- if attr_meta:
528
- if attr_meta.type_name:
529
- return attr_meta.type_name, attr_meta.element_type or None
530
- if attr_meta.element_type:
531
- return base_type, attr_meta.element_type
798
+ if isinstance(value.func, ast.Attribute):
799
+ base_type, base_elem = self._value_type(value.func.value)
800
+ if value.func.attr == "get" and value.args:
801
+ key_literal = self._literal_key(value.args[0])
802
+ val_type, elem_type = self._subscript_type(value.func.value, key_literal, base_type, base_elem)
803
+ if val_type:
804
+ return val_type, elem_type
532
805
  if base_elem:
533
806
  return base_elem, None
534
- if attr_name in TYPE_MAP:
535
- return attr_name, None
536
- return None, None
807
+ if isinstance(value, ast.List):
808
+ elem_type, _ = self._iterable_element_from_values(value.elts)
809
+ return "list", elem_type
810
+ if isinstance(value, ast.Tuple):
811
+ elem_type, _ = self._iterable_element_from_values(getattr(value, "elts", []))
812
+ return "tuple", elem_type
813
+ if isinstance(value, ast.Set):
814
+ elem_type, _ = self._iterable_element_from_values(getattr(value, "elts", []))
815
+ return "set", elem_type
816
+ if isinstance(value, ast.ListComp):
817
+ comp_type, comp_elem = self._value_type(value.elt)
818
+ return "list", comp_type or comp_elem
819
+ if isinstance(value, ast.Dict):
820
+ elem_type, _ = self._iterable_element_from_values(value.values or [])
821
+ return "dict", elem_type
822
+ if isinstance(value, ast.Subscript):
823
+ return self._subscript_value_type(value)
824
+ if isinstance(value, ast.Constant):
825
+ if isinstance(value.value, str):
826
+ return "str", None
827
+ if isinstance(value, ast.Name):
828
+ if value.id in self.type_map:
829
+ return self.type_map[value.id], self.type_map.get(f"{value.id}.__element__")
830
+ if value.id in {"character", "combat", "ctx"}:
831
+ return value.id, None
832
+ if isinstance(value, ast.Attribute):
833
+ attr_name = value.attr
834
+ base_type = None
835
+ base_elem = None
836
+ if isinstance(value.value, ast.Name):
837
+ base_type = self.type_map.get(value.value.id)
838
+ base_elem = self.type_map.get(f"{value.value.id}.__element__")
839
+ if base_type is None:
840
+ base_type, base_elem = self._value_type(value.value)
841
+ if base_type:
842
+ meta = _type_meta(base_type)
843
+ attr_meta = meta.attrs.get(attr_name)
844
+ if attr_meta:
845
+ if attr_meta.type_name:
846
+ return attr_meta.type_name, attr_meta.element_type or None
847
+ if attr_meta.element_type:
848
+ return base_type, attr_meta.element_type
849
+ if base_elem:
850
+ return base_elem, None
851
+ if attr_name in TYPE_MAP:
852
+ return attr_name, None
537
853
  return None, None
854
+ if isinstance(value, ast.IfExp):
855
+ t_type, t_elem = self._value_type(value.body)
856
+ e_type, e_elem = self._value_type(value.orelse)
857
+ if t_type and e_type and t_type == e_type:
858
+ merged_elem = t_elem or e_elem
859
+ if t_elem and e_elem and t_elem != e_elem:
860
+ merged_elem = None
861
+ return t_type, merged_elem
862
+ return t_type or e_type, t_elem or e_elem
863
+ return None, None
864
+
865
+ def _iterable_element_from_values(self, values: Iterable[ast.AST]) -> tuple[Optional[str], Optional[str]]:
866
+ elem_type: Optional[str] = None
867
+ nested_elem: Optional[str] = None
868
+ for node in values:
869
+ val_type, inner_elem = self._value_type(node)
870
+ if not val_type:
871
+ return None, None
872
+ if elem_type is None:
873
+ elem_type = val_type
874
+ nested_elem = inner_elem
875
+ elif elem_type != val_type:
876
+ return None, None
877
+ if inner_elem:
878
+ if nested_elem is None:
879
+ nested_elem = inner_elem
880
+ elif nested_elem != inner_elem:
881
+ nested_elem = None
882
+ return elem_type, nested_elem
883
+
884
+ def _literal_key(self, node: ast.AST | None) -> str | int | None:
885
+ if isinstance(node, ast.Constant):
886
+ if isinstance(node.value, (str, int)):
887
+ return node.value
888
+ if hasattr(ast, "Index") and isinstance(node, getattr(ast, "Index")):
889
+ return self._literal_key(getattr(node, "value", None))
890
+ return None
538
891
 
539
- def _record_dict_key_types(self, var_name: str, value: ast.AST | None) -> None:
540
- if not isinstance(value, ast.Dict):
541
- return
542
- for key_node, val_node in zip(value.keys or [], value.values or []):
543
- if isinstance(key_node, ast.Constant) and isinstance(key_node.value, str):
544
- val_type, elem_type = self._value_type(val_node)
545
- if val_type:
546
- type_map[f"{var_name}.{key_node.value}"] = val_type
547
- if elem_type:
548
- type_map[f"{var_name}.{key_node.value}.__element__"] = elem_type
549
-
550
- Visitor().visit(tree)
551
- return type_map
892
+ def _subscript_type(
893
+ self,
894
+ base_expr: ast.AST,
895
+ key_literal: str | int | None,
896
+ base_type: Optional[str],
897
+ base_elem: Optional[str],
898
+ ) -> tuple[Optional[str], Optional[str]]:
899
+ base_name = base_expr.id if isinstance(base_expr, ast.Name) else None
900
+ if base_name and key_literal is not None:
901
+ dict_key = f"{base_name}.{key_literal}"
902
+ if dict_key in self.type_map:
903
+ return self.type_map[dict_key], self.type_map.get(f"{dict_key}.__element__")
904
+ elem_hint = base_elem
905
+ if base_name and not elem_hint:
906
+ elem_hint = self.type_map.get(f"{base_name}.__element__")
907
+ if base_type:
908
+ meta = _type_meta(base_type)
909
+ if key_literal is not None and key_literal in meta.attrs:
910
+ attr_meta = meta.attrs[key_literal]
911
+ if attr_meta.type_name:
912
+ return attr_meta.type_name, attr_meta.element_type or None
913
+ if attr_meta.element_type:
914
+ return base_type, attr_meta.element_type
915
+ elem_hint = elem_hint or meta.element_type
916
+ if elem_hint:
917
+ return elem_hint, None
918
+ return base_type, None
919
+
920
+ def _subscript_value_type(self, node: ast.Subscript) -> tuple[Optional[str], Optional[str]]:
921
+ base_type, base_elem = self._value_type(node.value)
922
+ key_literal = self._literal_key(getattr(node, "slice", None))
923
+ return self._subscript_type(node.value, key_literal, base_type, base_elem)
924
+
925
+ def _record_dict_key_types(self, var_name: str, value: ast.AST | None) -> None:
926
+ if not isinstance(value, ast.Dict):
927
+ return
928
+ for key_node, val_node in zip(value.keys or [], value.values or []):
929
+ key_literal = self._literal_key(key_node)
930
+ if key_literal is None:
931
+ continue
932
+ val_type, elem_type = self._value_type(val_node)
933
+ if val_type:
934
+ self.type_map[f"{var_name}.{key_literal}"] = val_type
935
+ if elem_type:
936
+ self.type_map[f"{var_name}.{key_literal}.__element__"] = elem_type
552
937
 
553
938
 
554
939
  def _resolve_type_name(receiver: str, code: str, type_map: Dict[str, str] | None = None) -> str:
@@ -615,10 +1000,22 @@ def _type_meta_map() -> Dict[str, TypeMeta]:
615
1000
  return ""
616
1001
  return _element_type_from_iterable(cls, reverse_type_map)
617
1002
 
1003
+ def _getitem_element_for_type_name(type_name: str) -> str:
1004
+ cls = TYPE_MAP.get(type_name)
1005
+ if not cls:
1006
+ return ""
1007
+ return _element_type_from_getitem(cls, reverse_type_map)
1008
+
618
1009
  for type_name, cls in TYPE_MAP.items():
619
1010
  attrs: dict[str, AttrMeta] = {}
620
1011
  methods: dict[str, MethodMeta] = {}
621
1012
  iterable_element = _iter_element_for_type_name(type_name)
1013
+ getitem_element = _getitem_element_for_type_name(type_name)
1014
+ element_hint = iterable_element or getitem_element
1015
+ override_docs = {
1016
+ **_ATTR_DOC_OVERRIDES.get(type_name, {}),
1017
+ **_ATTR_DOC_OVERRIDES.get(cls.__name__, {}),
1018
+ }
622
1019
 
623
1020
  for attr in getattr(cls, "ATTRS", []):
624
1021
  doc = ""
@@ -637,8 +1034,12 @@ def _type_meta_map() -> Dict[str, TypeMeta]:
637
1034
  if not type_name_hint and not element_type_hint:
638
1035
  ann = _class_annotation(cls, attr)
639
1036
  type_name_hint, element_type_hint = _type_names_from_annotation(ann, reverse_type_map)
1037
+ if not type_name_hint and element_hint:
1038
+ type_name_hint = element_hint
640
1039
  if type_name_hint and not element_type_hint:
641
1040
  element_type_hint = _iter_element_for_type_name(type_name_hint)
1041
+ if not doc:
1042
+ doc = override_docs.get(attr, doc)
642
1043
  attrs[attr] = AttrMeta(doc=doc, type_name=type_name_hint, element_type=element_type_hint)
643
1044
 
644
1045
  for meth in getattr(cls, "METHODS", []):
@@ -653,7 +1054,7 @@ def _type_meta_map() -> Dict[str, TypeMeta]:
653
1054
  doc = (meth_obj.__doc__ or "").strip()
654
1055
  methods[meth] = MethodMeta(signature=sig_label, doc=doc)
655
1056
 
656
- meta[type_name] = TypeMeta(attrs=attrs, methods=methods, element_type=iterable_element)
1057
+ meta[type_name] = TypeMeta(attrs=attrs, methods=methods, element_type=element_hint)
657
1058
  return meta
658
1059
 
659
1060
 
@@ -735,6 +1136,16 @@ def _element_type_from_iterable(cls: type, reverse_type_map: Dict[type, str]) ->
735
1136
  return ""
736
1137
 
737
1138
 
1139
+ def _element_type_from_getitem(cls: type, reverse_type_map: Dict[type, str]) -> str:
1140
+ try:
1141
+ hints = typing.get_type_hints(cls.__getitem__, globalns=inspect.getmodule(cls).__dict__, include_extras=False)
1142
+ ret_ann = hints.get("return")
1143
+ name, elem = _type_names_from_annotation(ret_ann, reverse_type_map)
1144
+ return name or elem
1145
+ except Exception:
1146
+ return ""
1147
+
1148
+
738
1149
  def _attribute_at_position(line_text: str, cursor: int) -> tuple[Optional[str], Optional[str]]:
739
1150
  cursor = max(0, min(cursor, len(line_text)))
740
1151
  for match in ATTR_AT_CURSOR_RE.finditer(line_text):