Files
Nomarchy/tools/check-windowrule-syntax.py
Bernardo Magri 1e1d568947
Some checks failed
Check / eval (push) Has been cancelled
fix(hyprland): stay_focused not stayfocused (0.55)
Hyprland 0.55 renamed the effect; stayfocused 1 → invalid field type
on line 158 (polkit rules). Keep float/center/workspace current.
2026-07-10 13:38:54 +01:00

78 lines
3.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Hyprland windowrule dead-syntax guard (flake check `windowrule-syntax`).
Usage: check-windowrule-syntax.py <hyprland.conf>
Hyprland 0.53 rewrote window rules (hyprlang `handleWindowrule`): the
`windowrulev2` keyword is a hard error, and the old rule-first order
(`windowrule = float, class:^…$`) no longer parses. Both surface as a red
config-error banner on every session start and silently drop the rules
(fixed in ed7fd93). The current, correct grammar is
`windowrule = <effect> <value>, match:<prop> ^…$` — e.g.
`windowrule = float 1, match:class ^…$`.
This guards the GENERATED hyprland.conf against a regression to the dead
syntax, catching three markers:
1. the `windowrulev2` keyword anywhere;
2. rule-first order — an effect keyword directly followed by a comma
(`windowrule = float, …` / `center, …` / `size, …`), i.e. no value;
3. a bare `<prop>:` matcher (`class:`, `title:`, …) instead of the
`match:<prop>` form — this also catches an effect that DOES carry a
value but kept the old matcher (`size 60% 65%, class:^…$`).
The current correct config must PASS; any of the above must FAIL.
"""
import re
import sys
conf = sys.argv[1]
lines = open(conf).read().splitlines()
# Effect keywords that in the dead grammar sat directly before the comma.
# Effect keywords that in the dead grammar sat directly before the comma.
# Hyprland 0.55 renames: stayfocused → stay_focused (underscore). Keep the
# old name in the dead-grammar list so a regression to bare `stayfocused,`
# still fails the check.
EFFECTS = (
"float", "center", "tile", "pin", "fullscreen", "maximize",
"size", "move", "opacity", "workspace", "monitor", "fullscreenstate",
"group", "stayfocused", "stay_focused", "pseudo", "immediate",
)
rule_first = re.compile(
r"windowrule\s*=\s*(?:" + "|".join(EFFECTS) + r")\s*,"
)
# A window-property matcher written the old (colon-suffixed) way. The new
# grammar spells these `match:class …` — "class" is then followed by a
# space, never a colon — so a bare `class:`/`title:` only ever appears in
# the dead form.
bare_matcher = re.compile(
r"\b(?:initialclass|initialtitle|class|title|tag|xwayland|floating)\s*:",
re.IGNORECASE,
)
failures = []
for n, line in enumerate(lines, 1):
stripped = line.strip()
if stripped.startswith("#"):
continue
if "windowrulev2" in line:
failures.append((n, "dead `windowrulev2` keyword", stripped))
continue
if not re.match(r"\s*windowrule\s*=", line):
continue
if rule_first.search(line):
failures.append((n, "rule-first order (effect directly before comma)", stripped))
if bare_matcher.search(line):
failures.append((n, "bare `<prop>:` matcher — expected `match:<prop>`", stripped))
if failures:
print(f"windowrule-syntax: {len(failures)} dead-syntax rule(s) in {conf}")
for n, why, text in failures:
print(f" line {n}: {why}\n {text}")
sys.exit(1)
rules = sum(1 for l in lines if re.match(r"\s*windowrule\s*=", l))
print(f"windowrule-syntax: {rules} windowrule line(s), all post-0.53 grammar")