#!/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 7. 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 all scopes deeper than this indent # (the scope at this indent belongs to the parent object and must persist) 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", ) ) 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())