feat(linter): Develop custom AST linter for ImGui scopes

This commit is contained in:
2026-05-12 19:02:30 -04:00
parent 5398b4eef0
commit c359961a0a
8 changed files with 281 additions and 250 deletions
+116
View File
@@ -0,0 +1,116 @@
import ast
import sys
class ImGuiScopeLinter:
def __init__(self):
self.pairs = {
'begin': 'end',
'begin_child': 'end_child',
'begin_group': 'end_group',
'begin_combo': 'end_combo',
'begin_main_menu_bar': 'end_main_menu_bar',
'begin_menu_bar': 'end_menu_bar',
'begin_menu': 'end_menu',
'begin_tooltip': 'end_tooltip',
'begin_popup': 'end_popup',
'begin_popup_modal': 'end_popup',
'begin_popup_context_item': 'end_popup',
'begin_popup_context_window': 'end_popup',
'begin_popup_context_void': 'end_popup',
'begin_table': 'end_table',
'begin_tab_bar': 'end_tab_bar',
'begin_tab_item': 'end_tab_item',
'push_style_var': 'pop_style_var',
'push_style_color': 'pop_style_color',
'push_font': 'pop_font',
'push_id': 'pop_id',
'push_item_width': 'pop_item_width',
'push_text_wrap_pos': 'pop_text_wrap_pos',
'push_clip_rect': 'pop_clip_rect',
'tree_node': 'tree_pop',
'tree_push': 'tree_pop',
'indent': 'unindent',
}
self.starts = set(self.pairs.keys())
self.ends = set(self.pairs.values())
def check_source(self, source_code: str) -> list[str]:
try:
tree = ast.parse(source_code)
except SyntaxError as e:
return [f"Syntax error: {e}"]
visitor = ImGuiVisitor(self.pairs)
visitor.visit(tree)
return visitor.errors
class ImGuiVisitor(ast.NodeVisitor):
def __init__(self, pairs):
self.pairs = pairs
self.stack = []
self.errors = []
self.starts = set(pairs.keys())
self.ends = set(pairs.values())
def visit_FunctionDef(self, node):
saved_stack = self.stack
self.stack = []
self.generic_visit(node)
while self.stack:
start_type, lineno = self.stack.pop()
self.errors.append(f"Function '{node.name}': Unclosed scope '{start_type}' started at line {lineno}")
self.stack = saved_stack
def visit_AsyncFunctionDef(self, node):
self.visit_FunctionDef(node)
def visit_Call(self, node):
func_name = self._get_func_name(node.func)
if func_name:
parts = func_name.split('.')
if len(parts) >= 2 and parts[0] in ('imgui', 'ed', 'imgui_node_editor'):
method = parts[-1]
if method in self.starts:
self.stack.append((method, node.lineno))
elif method in self.ends:
if not self.stack:
self.errors.append(f"Extra '{method}' at line {node.lineno} (no matching start)")
else:
start_type, start_lineno = self.stack[-1]
expected_end = self.pairs.get(start_type)
if expected_end == method:
self.stack.pop()
else:
self.stack.pop()
self.errors.append(f"Mismatched scope: '{method}' at line {node.lineno} does not match '{start_type}' at line {start_lineno}")
self.generic_visit(node)
def _get_func_name(self, node):
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Attribute):
value = self._get_func_name(node.value)
if value:
return f"{value}.{node.attr}"
return None
def main():
if len(sys.argv) < 2:
print("Usage: python check_imgui_scopes.py <file1> <file2> ...")
return
linter = ImGuiScopeLinter()
for path in sys.argv[1:]:
try:
with open(path, "r", encoding="utf-8") as f:
source = f.read()
errors = linter.check_source(source)
if errors:
print(f"Errors in {path}:")
for err in errors:
print(f" {err}")
else:
print(f"{path}: OK")
except Exception as e:
print(f"Error reading {path}: {e}")
if __name__ == "__main__":
main()