#!/usr/bin/env python3 """Checks QML files for Qt coding convention violations. https://doc.qt.io/qt-6/qml-codingconventions.html Required ordering within each QML object (with blank line between sections): 1. id 2. property declarations 3. signal declarations 4. JavaScript functions 5. object properties (bindings) 6. child objects / component definitions """ import re import sys from enum import IntEnum from pathlib import Path RED = "\033[0;31m" YELLOW = "\033[0;33m" CYAN = "\033[0;36m" GREEN = "\033[0;32m" MAGENTA = "\033[0;35m" BOLD = "\033[1m" RESET = "\033[0m" REPO_ROOT = Path(__file__).resolve().parent.parent class Section(IntEnum): ID = 0 PROPERTY = 1 SIGNAL = 2 FUNCTION = 3 BINDING = 4 CHILD = 5 COMPONENT_DEF = 6 SECTION_NAMES = { Section.ID: "id", Section.PROPERTY: "property declarations", Section.SIGNAL: "signal declarations", Section.FUNCTION: "functions", Section.BINDING: "bindings", Section.CHILD: "child objects", Section.COMPONENT_DEF: "component definitions", } RULE_COLOURS = { "section-order": YELLOW, "missing-section-separator": CYAN, "blank-after-open-brace": MAGENTA, "blank-before-close-brace": MAGENTA, } # Regexes PROPERTY_DECL_RE = re.compile(r"^(?:required\s+|readonly\s+|default\s+)*property\s") SIGNAL_RE = re.compile(r"^signal\s") FUNCTION_RE = re.compile(r"^function\s") ID_RE = re.compile(r"^id\s*:\s*[a-zA-Z_]\w*\s*$") ENUM_RE = re.compile(r"^enum\s") COMPONENT_DEF_RE = re.compile(r"^component\s+\w+\s*:") COMMENT_LINE_RE = re.compile(r"^//") BLOCK_COMMENT_START = re.compile(r"/\*") BLOCK_COMMENT_END = re.compile(r"\*/") BINDING_RE = re.compile(r"^[a-z][a-zA-Z0-9_.]*\s*:") SIGNAL_HANDLER_RE = re.compile(r"^on[A-Z][a-zA-Z]*\s*:") # Child object: starts with uppercase or is a known child-like pattern CHILD_OBJECT_RE = re.compile(r"^[A-Z][a-zA-Z0-9_.]*\s*\{") # Inline component: Component { ... } INLINE_COMPONENT_RE = re.compile(r"^Component\s*\{") # Behavior on {, NumberAnimation on {, etc. BEHAVIOR_ON_RE = re.compile(r"^[A-Z]\w+\s+on\s+\w[\w.]*\s*\{") # Attached signal handler: Component.onCompleted:, Drag.onDragStarted:, etc. ATTACHED_HANDLER_RE = re.compile(r"^[A-Z]\w+\.on[A-Z]\w*\s*:") class Violation: def __init__(self, file: str, line: int, rule: str, msg: str): self.file = file self.line = line self.rule = rule self.msg = msg def __str__(self): c = RULE_COLOURS.get(self.rule, "") return f"{c}[{self.rule}]{RESET} {self.file}:{self.line}: {self.msg}" class ScopeTracker: """Tracks the current section and last-seen state for one indent level.""" def __init__(self): self.last_section: Section | None = None self.last_section_line: int = 0 self.had_blank_before_current: bool = True # no separator needed at start def get_indent(line: str) -> str: return line[: len(line) - len(line.lstrip())] def classify_line(stripped: str) -> Section | None: """Classify a stripped QML line into a section category.""" if ID_RE.match(stripped): return Section.ID if PROPERTY_DECL_RE.match(stripped): return Section.PROPERTY if SIGNAL_RE.match(stripped): return Section.SIGNAL if FUNCTION_RE.match(stripped): return Section.FUNCTION if ENUM_RE.match(stripped): return Section.PROPERTY # enums go with declarations if COMPONENT_DEF_RE.match(stripped): return Section.COMPONENT_DEF if BEHAVIOR_ON_RE.match(stripped): return Section.CHILD if CHILD_OBJECT_RE.match(stripped): return Section.CHILD if INLINE_COMPONENT_RE.match(stripped): return Section.CHILD if BINDING_RE.match(stripped) or SIGNAL_HANDLER_RE.match(stripped): return Section.BINDING if ATTACHED_HANDLER_RE.match(stripped): return Section.BINDING return None def check_file(filepath: Path) -> list[Violation]: violations = [] rel = str(filepath.relative_to(REPO_ROOT)) try: lines = filepath.read_text().splitlines() except (OSError, UnicodeDecodeError): return violations scopes: dict[str, ScopeTracker] = {} # indent -> tracker in_block_comment = False func_skip_depth = 0 # brace depth for skipping function bodies only prev_blank: dict[str, bool] = {} # indent -> was previous relevant line a blank? for i, line in enumerate(lines): lineno = i + 1 stripped = line.strip() indent = get_indent(line) # Handle block comments if in_block_comment: if BLOCK_COMMENT_END.search(stripped): in_block_comment = False continue if BLOCK_COMMENT_START.search(stripped) and not BLOCK_COMMENT_END.search(stripped): in_block_comment = True continue # Track blank lines per indent if not stripped: # Check: blank line right after opening brace of a QML object if i > 0 and func_skip_depth == 0 and not in_block_comment and lines[i - 1].strip().endswith("{"): violations.append( Violation( rel, lineno, "blank-after-open-brace", "no blank line expected after opening brace", ) ) for key in prev_blank: prev_blank[key] = True continue # Skip line comments if COMMENT_LINE_RE.match(stripped): continue # Skip inside function bodies (JS code, not QML structure) if func_skip_depth > 0: func_skip_depth += stripped.count("{") - stripped.count("}") if func_skip_depth <= 0: func_skip_depth = 0 continue # Closing brace: pop scope for this indent and all deeper scopes if stripped == "}": # Check: blank line right before closing brace if i > 0 and not lines[i - 1].strip(): violations.append( Violation( rel, lineno, "blank-before-close-brace", "no blank line expected before closing brace", ) ) scopes.pop(indent, None) prev_blank.pop(indent, None) to_remove = [k for k in scopes if len(k) > len(indent)] for k in to_remove: del scopes[k] prev_blank.pop(k, None) continue section = classify_line(stripped) if section is None: continue # Get or create scope tracker for this indent if indent not in scopes: scopes[indent] = ScopeTracker() prev_blank[indent] = True # treat start of object as having separator tracker = scopes[indent] had_blank = prev_blank.get(indent, True) # --- Check 1: Section ordering --- if tracker.last_section is not None and section < tracker.last_section: violations.append( Violation( rel, lineno, "section-order", f"{SECTION_NAMES[section]} should appear before " f"{SECTION_NAMES[tracker.last_section]} " f"(seen at line {tracker.last_section_line})", ) ) # --- Check 2: Missing blank line between different sections --- if tracker.last_section is not None and section != tracker.last_section and not had_blank: violations.append( Violation( rel, lineno, "missing-section-separator", f"blank line expected between {SECTION_NAMES[tracker.last_section]} and {SECTION_NAMES[section]}", ) ) # Update tracker if tracker.last_section is None or section >= tracker.last_section: tracker.last_section = section tracker.last_section_line = lineno prev_blank[indent] = False # Skip function bodies (they contain JS, not QML structure) brace_count = stripped.count("{") - stripped.count("}") if brace_count > 0 and section == Section.FUNCTION: func_skip_depth = brace_count # Skip JS blocks in bindings (signal handlers, attached handlers, # and expression blocks like `color: { ... }`) if brace_count > 0 and section == Section.BINDING: colon_idx = stripped.index(":") after_colon = stripped[colon_idx + 1 :].strip() # If content after : doesn't start with an uppercase type name, # it's a JS block (not an inline QML object like `contentItem: Rect {`) if not re.match(r"^[A-Z]", after_colon): func_skip_depth = brace_count # Child object/component opening resets deeper scopes if brace_count > 0 and section in (Section.CHILD, Section.COMPONENT_DEF): to_remove = [k for k in scopes if len(k) > len(indent)] for k in to_remove: del scopes[k] prev_blank.pop(k, None) return violations def main(): qml_files = sorted(p for p in REPO_ROOT.rglob("*.qml") if "build" not in p.parts) print(f"{BOLD}Checking {len(qml_files)} QML files for convention violations...{RESET}\n") all_violations: list[Violation] = [] for f in qml_files: all_violations.extend(check_file(f)) for v in all_violations: print(v) print() if all_violations: by_rule: dict[str, int] = {} for v in all_violations: by_rule[v.rule] = by_rule.get(v.rule, 0) + 1 for rule, count in sorted(by_rule.items()): print(f" {RULE_COLOURS.get(rule, '')}{rule}{RESET}: {count}") print(f"\n{BOLD}Found {len(all_violations)} violation(s).{RESET}") return 1 else: print(f"{BOLD}No violations found.{RESET}") return 0 if __name__ == "__main__": sys.exit(main())