nosignal-shell/scripts/qml-lint-conventions.py
2026-03-22 02:21:53 +11:00

381 lines
13 KiB
Python
Executable file

#!/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 = {
"import-order": GREEN,
"section-order": YELLOW,
"missing-section-separator": CYAN,
"blank-after-open-brace": MAGENTA,
"blank-before-close-brace": MAGENTA,
}
IMPORT_RE = re.compile(r"^import\s+(\S+)")
def import_group(module: str) -> tuple[int, int] | None:
"""Return (group, depth) for a module import, or None to skip."""
if module.startswith('"'):
return None
depth = module.count(".") + 1
if module == "QtQuick" or module.startswith("QtQuick."):
return (1, depth)
if module.startswith("Qt"):
return (2, depth)
if module == "Quickshell" or module.startswith("Quickshell."):
return (3, depth)
if module == "M3Shapes":
return (4, depth)
if module == "Caelestia" or module.startswith("Caelestia."):
return (5, depth)
if module == "qs.components" or module.startswith("qs.components."):
return (6, depth)
if module == "qs.services":
return (7, depth)
if module == "qs.config":
return (8, depth)
if module == "qs.utils":
return (9, depth)
if module == "qs.modules" or module.startswith("qs.modules."):
return (10, depth)
return None
def check_imports(filepath: Path, lines: list[str], rel: str) -> list[Violation]:
"""Check that module imports are in the required order."""
violations = []
imports: list[tuple[int, str, int, int]] = [] # (lineno, module, group, depth)
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped or stripped.startswith("//") or stripped.startswith("pragma "):
continue
m = IMPORT_RE.match(stripped)
if m:
module = m.group(1)
result = import_group(module)
if result is not None:
group, depth = result
imports.append((i + 1, module, group, depth))
continue
break # end of import block
for j in range(1, len(imports)):
prev_lineno, prev_mod, prev_group, prev_depth = imports[j - 1]
curr_lineno, curr_mod, curr_group, curr_depth = imports[j]
if curr_group < prev_group:
violations.append(
Violation(
rel,
curr_lineno,
"import-order",
f"'{curr_mod}' should appear before '{prev_mod}'",
)
)
elif curr_group == prev_group and curr_depth < prev_depth:
violations.append(
Violation(
rel,
curr_lineno,
"import-order",
f"'{curr_mod}' should appear before '{prev_mod}' (less nested first)",
)
)
return violations
# 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 <property> {, NumberAnimation on <property> {, 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
violations.extend(check_imports(filepath, lines, rel))
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())