feat(linter): Develop custom AST linter for ImGui scopes
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user