Coverage for src/renaissance/utils/ast_utils.py: 95%

64 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 10:34 +0000

1# Copyright (C) 2024-2026 TNO 

2# 

3# Licensed under the terms specified in the LICENSE file in the root directory 

4# of this repository, also available at: 

5# https://github.com/TNO/Renaissance.Py/blob/main/LICENSE 

6 

7"""AI: Low-level AST utility helpers: sibling lookup, placeholder handling, and node matching.""" 

8 

9from collections import deque 

10from contextlib import contextmanager 

11from contextvars import ContextVar 

12 

13from renaissance.integrations import MATCH_ALL, MATCH_ONE 

14 

15DISPLAY_PARSER_KIND: ContextVar[bool] = ContextVar("display_parser_kind", default=False) 

16 

17 

18@contextmanager 

19def display_context(display_parser_kind: bool): 

20 """AI: Temporarily set whether node display uses parser kind instead of semantic kind.""" 

21 token = DISPLAY_PARSER_KIND.set(display_parser_kind) 

22 try: 

23 yield 

24 finally: 

25 DISPLAY_PARSER_KIND.reset(token) 

26 

27 

28def replace_dollar(text: str) -> str: 

29 """Replace dollar-sign placeholders in the given text with their corresponding match symbols. 

30 

31 Two literal dollar-sign characters (`$$`) are replaced with MATCH_ALL; a single literal dollar-sign 

32 character (`$`) is replaced with MATCH_ONE. 

33 """ 

34 return text.replace("$$", MATCH_ALL).replace("$", MATCH_ONE) 

35 

36 

37def use_dollar(text: str) -> str: 

38 """Replace match symbols in the given text with their corresponding dollar-sign placeholders. 

39 

40 MATCH_ALL is replaced with two literal dollar-sign characters (`$$`); MATCH_ONE is replaced with a 

41 single literal dollar-sign character (`$`). 

42 """ 

43 return text.replace(MATCH_ALL, "$$").replace(MATCH_ONE, "$") 

44 

45 

46def detect_placeholder(signature: str, original_node_type: str) -> tuple[bool, str, str]: 

47 """Detect if the given signature represents a placeholder symbol. 

48 

49 Returns: 

50 (is_placeholder, coerced_node_type, placeholder_name_or_signature) 

51 

52 """ 

53 if not signature: 

54 return False, original_node_type, "" 

55 if signature.startswith((MATCH_ALL, "$$")) and " " not in signature and "(" not in signature: 

56 # legacy compatibility 

57 return True, MATCH_ALL, signature 

58 if signature.startswith((MATCH_ONE, "$")) and " " not in signature and "(" not in signature: 

59 return True, MATCH_ONE, signature 

60 return False, original_node_type, "-" 

61 

62 

63# duplicate of ast node process 

64def traverse(node): 

65 """AI: Yield node and all its descendants in breadth-first order.""" 

66 todo = deque([node]) 

67 while todo: 

68 node = todo.popleft() 

69 if hasattr(node, "children"): 

70 todo.extend(node.children) 

71 yield node 

72 

73 

74def process_node(node, action) -> None: 

75 """AI: Recursively apply action to node and all its descendants.""" 

76 action(node) 

77 if node.children: 

78 for child in node.children: 

79 process_node(child, action) 

80 

81 

82def preceding_sibling(node): 

83 """AI: Return the sibling immediately before node in its parent's children, or None.""" 

84 parent = node.parent 

85 if not parent: 

86 return None 

87 siblings = parent.children 

88 preceding_index = siblings.index(node) - 1 

89 return siblings[preceding_index] if preceding_index >= 0 else None 

90 

91 

92def next_sibling(self): 

93 """AI: Return the sibling immediately after self in its parent's children, or None.""" 

94 parent = self.parent 

95 if not parent: 

96 return None 

97 siblings = parent.children 

98 next_index = siblings.index(self) + 1 

99 return siblings[next_index] if next_index < len(siblings) else None 

100 

101 

102def match_props(mine, other, irrelevant_props) -> bool: 

103 """AI: Return True if mine and other agree on all properties except the irrelevant ones.""" 

104 all_keys = (mine.keys() | other.keys()) - irrelevant_props 

105 return all(mine.get(n) == other.get(n) for n in all_keys) 

106 

107 

108def match_children(mine, other, irrelevant_kinds) -> bool: 

109 """AI: Return True if every child in other matches the corresponding child in mine (or has an irrelevant kind).""" 

110 if mine is None or other is None: 

111 return mine == other 

112 return all((i < len(mine) and mine[i] == child) or child.parser_kind in irrelevant_kinds for i, child in enumerate(other)) 

113 

114 

115def format_node(node) -> str: 

116 """AI: Return a human-readable, indented display string for a single AST node.""" 

117 raw_lines = node.signature.splitlines() 

118 properties_text = "" if not node.show_props else node.properties 

119 prefix = " " if len(raw_lines) < 2 else f"\n {node.indent}" 

120 formatted_lines = [f"{prefix}|{line}|" for line in raw_lines] 

121 semantic_name = node.semantic_kind.value 

122 kind = node.parser_kind if DISPLAY_PARSER_KIND.get() or semantic_name == "node" else semantic_name 

123 return ( 

124 f"{node.indent}({kind}, {node.name}, " 

125 f"{node.filename}[{node.offset}:{node.offset + node.length}])" 

126 f"{properties_text}:{''.join(formatted_lines)}\n" 

127 )