1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
|
diff --git a/common/.local/bin/dotfiles-validate b/common/.local/bin/dotfiles-validate
index 57a7505..d5e6695 100755
--- a/common/.local/bin/dotfiles-validate
+++ b/common/.local/bin/dotfiles-validate
@@ -3,6 +3,7 @@
#
# Walks the tree and extracts the commands that configs promise to launch:
# - hypr conf files: exec-once = CMD / exec = CMD / bind* = ..., exec, CMD
+# - hypr lua configs: at_start/at_shutdown/at_reload("CMD"), exec_cmd("CMD")
# - waybar config: "exec(-if)", "on-click*", "on-scroll-*",
# "on-double-click" values
# - systemd user units: Exec*= lines (leading -/@ modifiers stripped)
@@ -45,6 +46,30 @@ find "$root" -path '*/.config/hypr/*.conf' -type f 2>/dev/null | while read -r f
' "$f"
done >> "$refs_file"
+# --- hypr lua configs: autostart collectors and exec_cmd dispatchers ---
+# The Lua config manager (Hyprland 0.55+) spells the same two things as function
+# calls rather than assignments, so the .conf walk above sees none of them. Only
+# the first word is taken, as everywhere else here. The two matches are written
+# out rather than folded into a helper: awk cannot take a regex literal as a
+# function parameter -- it collapses to a boolean match against $0, which
+# silently turns every line into a bogus reference.
+find "$root" -path '*/.config/hypr/*.lua' -type f 2>/dev/null | while read -r f; do
+ awk -v file="$f" '
+ match($0, /at_(start|shutdown|reload)\("/) {
+ rest = substr($0, RSTART + RLENGTH)
+ sub(/".*$/, "", rest)
+ n = split(rest, w, /[ \t]+/)
+ if (n > 0 && w[1] != "") print file ":" FNR ":" w[1]
+ }
+ match($0, /hl\.dsp\.exec_cmd\("/) {
+ rest = substr($0, RSTART + RLENGTH)
+ sub(/".*$/, "", rest)
+ n = split(rest, w, /[ \t]+/)
+ if (n > 0 && w[1] != "") print file ":" FNR ":" w[1]
+ }
+ ' "$f"
+done >> "$refs_file"
+
# --- waybar configs: command-bearing JSON values ---
find "$root" -path '*/.config/waybar/*' -type f \( -name config -o -name '*.json' -o -name '*.jsonc' \) 2>/dev/null | while read -r f; do
awk -v file="$f" '
diff --git a/tests/layout-cycle/test_layout_cycle.py b/tests/layout-cycle/test_layout_cycle.py
index 1454817..3740024 100644
--- a/tests/layout-cycle/test_layout_cycle.py
+++ b/tests/layout-cycle/test_layout_cycle.py
@@ -18,6 +18,7 @@ Run from repo root:
import json
import os
+import re
import subprocess
import tempfile
import unittest
@@ -25,7 +26,7 @@ import unittest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
SCRIPT = os.path.join(REPO_ROOT, "hyprland/.local/bin/layout-cycle")
FAKE_HYPRCTL = os.path.join(os.path.dirname(__file__), "fake-hyprctl")
-HYPRLAND_CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf")
+HYPRLAND_CFG = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua")
FLASH = "rgba(ffd24aff)"
ACTIVE = "rgba(daa520ff)"
@@ -326,7 +327,7 @@ class TestFlashAllBorders(LayoutCycleHarness):
class TestScrollLayoutDisabledInConfig(unittest.TestCase):
- """Pin the hyprland.conf half of the scrolling disable.
+ """Pin the hyprland.lua half of the scrolling disable.
The script tests above prove the ring skips scrolling; these prove no
keybinding reaches it either, and that the freed Super+Shift+S chord
@@ -335,10 +336,20 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase):
@classmethod
def setUpClass(cls):
- with open(HYPRLAND_CONF) as f:
+ with open(HYPRLAND_CFG) as f:
cls.conf = f.read()
+ # Every bind in the Lua config is a top-level hl.bind() call; the
+ # locked/mouse/repeat variants that hyprlang spelled bindl/bindm/binde
+ # are the same call with an options table, so one prefix covers them.
cls.binds = [l for l in cls.conf.splitlines()
- if l.strip().startswith(("bind", "bindl", "bindm"))]
+ if l.strip().startswith("hl.bind")]
+
+ def test_the_binds_were_actually_found(self):
+ """Guard the guard: a renamed call would empty the list and make both
+ assertions below pass against nothing."""
+ self.assertGreater(len(self.binds), 50,
+ "found almost no hl.bind lines -- the two assertions "
+ "below would pass vacuously")
def test_no_bind_selects_scrolling_layout(self):
offenders = [l for l in self.binds if "general:layout scrolling" in l]
@@ -346,7 +357,7 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase):
def test_super_shift_s_is_fullscreen_screenshot(self):
shift_s = [l for l in self.binds
- if "$mod SHIFT, S," in l]
+ if re.search(r'\bmod\s*\.\.\s*"\s*\+\s*SHIFT\s*\+\s*S"', l)]
self.assertEqual(len(shift_s), 1, msg=f"binds found: {shift_s}")
self.assertIn("screenshot fullscreen", shift_s[0])
diff --git a/tests/settings/test_session_restore.py b/tests/settings/test_session_restore.py
index a989d52..b2a2721 100644
--- a/tests/settings/test_session_restore.py
+++ b/tests/settings/test_session_restore.py
@@ -585,12 +585,32 @@ class TestCompositorWiring(unittest.TestCase):
script and passing tests while never being placed in the bar.
"""
- CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf")
+ CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua")
def _lines(self):
+ """The autostart commands, in launch order.
+
+ The Lua config collects startup commands with at_start("CMD") and one
+ hl.on("hyprland.start") handler at the bottom replays the list in order,
+ so position in this file is still position at launch -- which is what
+ the ordering assertion below reads. Returning the command strings rather
+ than raw lines means every entry here is by construction an autostart
+ command, so the old startswith("exec-once") filter has nothing left to do.
+ """
+ out = []
with open(self.CONF) as f:
- return [ln.strip() for ln in f
- if ln.strip() and not ln.strip().startswith("#")]
+ for ln in f:
+ m = re.search(r'at_start\("(.*)"\)', ln.strip())
+ if m:
+ out.append(m.group(1))
+ return out
+
+ def test_the_autostart_commands_were_actually_found(self):
+ """Guard the guard: a renamed collector would empty the list and make
+ every assertion below pass against nothing."""
+ self.assertGreater(len(self._lines()), 10,
+ "found almost no at_start commands -- the assertions "
+ "below would pass vacuously")
@staticmethod
def _is_toggle_restore(line):
@@ -607,22 +627,19 @@ class TestCompositorWiring(unittest.TestCase):
def test_restore_runs_at_session_start(self):
self.assertTrue(
- any(ln.startswith("exec-once") and self._is_toggle_restore(ln)
- for ln in self._lines()),
- "hyprland.conf has no exec-once running `settings restore` — "
+ any(self._is_toggle_restore(ln) for ln in self._lines()),
+ "hyprland.lua has no at_start running `settings restore` — "
"remembered toggles would never be replayed")
def test_wallpaper_restore_runs_at_session_start(self):
self.assertTrue(
- any(ln.startswith("exec-once") and "settings restore-wallpaper" in ln
- for ln in self._lines()),
- "hyprland.conf has no exec-once running `settings restore-wallpaper` "
+ any("settings restore-wallpaper" in ln for ln in self._lines()),
+ "hyprland.lua has no at_start running `settings restore-wallpaper` "
"— the stored wallpaper would never be put back")
def test_the_toggle_restore_runs_only_once(self):
"""Twice means the whole re-assert budget is spent twice per login."""
- lines = [ln for ln in self._lines()
- if ln.startswith("exec-once") and self._is_toggle_restore(ln)]
+ lines = [ln for ln in self._lines() if self._is_toggle_restore(ln)]
self.assertEqual(len(lines), 1, lines)
def test_restore_is_ordered_after_the_backings_it_corrects(self):
@@ -631,17 +648,15 @@ class TestCompositorWiring(unittest.TestCase):
# burn attempts on backings that aren't up yet.
lines = self._lines()
restore = next(i for i, ln in enumerate(lines)
- if ln.startswith("exec-once")
- and self._is_toggle_restore(ln))
+ if self._is_toggle_restore(ln))
for backing in ("hypridle", "dunst"):
- launch = next(i for i, ln in enumerate(lines)
- if ln.startswith("exec-once") and backing in ln)
+ launch = next(i for i, ln in enumerate(lines) if backing in ln)
self.assertLess(launch, restore,
f"`settings restore` is ordered before {backing}")
class DimEnv(TempEnv):
- """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.conf's is."""
+ """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.lua's is."""
def setUp(self):
super().setUp()
diff --git a/tests/waybar-reserve/test_reserve_pairing.py b/tests/waybar-reserve/test_reserve_pairing.py
index 7421692..fbeb95d 100644
--- a/tests/waybar-reserve/test_reserve_pairing.py
+++ b/tests/waybar-reserve/test_reserve_pairing.py
@@ -26,7 +26,7 @@ import unittest
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
WAYBAR = os.path.join(REPO_ROOT, "hyprland/.config/waybar/config")
-HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf")
+HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua")
def waybar_config():
@@ -35,13 +35,19 @@ def waybar_config():
def reserve_exec_wired():
- """True when hyprland.conf runs waybar-reserve via exec (not exec-once).
+ """True when the config re-runs waybar-reserve on every config reload.
- Matches waybar-reserve invoked anywhere in an ``exec =`` line, so the
- reload-race-safe retry-loop form (``exec = for i in 1 2 3; do sleep 0.2;
- waybar-reserve; done``) counts the same as a bare ``exec = waybar-reserve``."""
+ Matches waybar-reserve invoked anywhere in an ``at_reload(...)`` line, so the
+ reload-race-safe retry-loop form (``at_reload("for i in 1 2 3; do sleep 0.2;
+ waybar-reserve; done")``) counts the same as a bare ``at_reload("waybar-reserve")``.
+
+ at_reload and not at_start, because hyprlang's ``exec`` ran at startup AND on
+ every reload, and the Lua port splits those two jobs. at_reload's handler is
+ ``hl.on("config.reloaded")``, which fires on the initial load as well, so it
+ alone covers both. Matching at_start instead would pass while reservations
+ died on the next reload -- which is exactly the defect this guards."""
with open(HYPR) as f:
- return any(re.match(r"\s*exec\s*=.*\bwaybar-reserve\b", ln) for ln in f)
+ return any(re.match(r'\s*at_reload\(".*\bwaybar-reserve\b', ln) for ln in f)
def reserve_target():
@@ -61,7 +67,7 @@ class ReservePairingHarness(unittest.TestCase):
def test_hyprland_wires_the_reserve_script(self):
self.assertTrue(reserve_exec_wired(),
- "hyprland.conf lacks `exec = waybar-reserve`: reservations "
+ "hyprland.lua lacks `at_reload(... waybar-reserve ...)`: reservations "
"die on the next config reload")
def test_reserve_target_covers_the_bar(self):
|