detached 6282944

fix(config): keep a rule when an invalid duplicate key follows a valid one

commit
628294425cb3db6caf1722f4cc1a79db29844d18
author
brice · · 2026-10-08 20:15 UTC
parent
12a6410
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +8 -3
M docs/config.md +1 -1
@@ -100,7 +100,7 @@ rule.calc.floating = on
100100 - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual.
101101 - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them.
102102 - `width` is ignored under `fixed` and `cascade` overflow, where columns share the width.
103-- An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored.
103+- An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. An invalid value that repeats a key already set keeps the earlier value, like any other key.
104104
105105 To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched.
106106
@@ -100,7 +100,7 @@ rule.calc.floating = on
100 - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual.100 - A named workspace stays on its own monitor: `monitor` is ignored next to a named `workspace`. A name nobody declared warns and the window opens as usual.
101 - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them.101 - A window started for a slot (`workspace.<name>.column.N`) goes to its slot, and a dialog stays over its parent: rules do not apply to them.
102 - `width` is ignored under `fixed` and `cascade` overflow, where columns share the width.102 - `width` is ignored under `fixed` and `cascade` overflow, where columns share the width.
103-- An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored.103+- An invalid value, a bad regular expression or a missing `app-id` logs a warning with the line, and the rule is ignored. An invalid value that repeats a key already set keeps the earlier value, like any other key.
104 104
105 To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched.105 To find an app ID, look at `app_id` in `neferwl state | jq '.windows[] | {id, app_id}'`, or at the `window mapped` line in the log. X11 apps run through xwayland-satellite get their app ID from `WM_CLASS`; it may be empty, and an app that sets it after opening is not matched.
106 106
M internal/adapters/config/config.go +4 -2
@@ -531,7 +531,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
531531 }
532532 if err := setRule(&e.rule, field, value); err != nil {
533533 warn("%s: %v", key, err)
534- if !errors.Is(err, errUnknownRuleField) {
534+ // Like any key, an invalid duplicate keeps the earlier value.
535+ if _, dup := seen[key]; !dup && !errors.Is(err, errUnknownRuleField) {
535536 e.dropped = true
536537 }
537538 continue
@@ -1016,7 +1017,8 @@ var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
10161017 type ruleEntry struct {
10171018 rule ports.WindowRule
10181019 line int // of its first key
1019- // dropped is set by an invalid value: the whole rule is ignored.
1020+ // dropped is set by an invalid value with no valid one before it: the
1021+ // whole rule is ignored.
10201022 dropped bool
10211023 }
10221024
@@ -531,7 +531,8 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) {
531 }531 }
532 if err := setRule(&e.rule, field, value); err != nil {532 if err := setRule(&e.rule, field, value); err != nil {
533 warn("%s: %v", key, err)533 warn("%s: %v", key, err)
534- if !errors.Is(err, errUnknownRuleField) {534+ // Like any key, an invalid duplicate keeps the earlier value.
535+ if _, dup := seen[key]; !dup && !errors.Is(err, errUnknownRuleField) {
535 e.dropped = true536 e.dropped = true
536 }537 }
537 continue538 continue
@@ -1016,7 +1017,8 @@ var workspaceName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
1016 type ruleEntry struct {1017 type ruleEntry struct {
1017 rule ports.WindowRule1018 rule ports.WindowRule
1018 line int // of its first key1019 line int // of its first key
1019- // dropped is set by an invalid value: the whole rule is ignored.1020+ // dropped is set by an invalid value with no valid one before it: the
1021+ // whole rule is ignored.
1020 dropped bool1022 dropped bool
1021 }1023 }
1022 1024
M internal/adapters/config/rules_test.go +3 -0
@@ -66,6 +66,9 @@ func TestRuleWarnings(t *testing.T) {
6666 {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0},
6767 {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1},
6868 {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0},
69+ // An invalid duplicate keeps the earlier value and the rule.
70+ {"bad duplicate floating", "rule.a.app-id = foo\nrule.a.floating = on\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 3, 1},
71+ {"bad duplicate app-id", "rule.a.app-id = foo\nrule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 2, 1},
6972 {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},
7073 {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
7174 {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},
@@ -66,6 +66,9 @@ func TestRuleWarnings(t *testing.T) {
66 {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0},66 {"missing app-id", "rule.a.floating = on\n", "rule.a: no app-id, rule ignored", 1, 0},
67 {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1},67 {"unknown field", "rule.a.app-id = foo\nrule.a.title = x\n", "rule.a.title: unknown key", 2, 1},
68 {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0},68 {"bad floating", "rule.a.app-id = foo\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 2, 0},
69+ // An invalid duplicate keeps the earlier value and the rule.
70+ {"bad duplicate floating", "rule.a.app-id = foo\nrule.a.floating = on\nrule.a.floating = maybe\n", "rule.a.floating: must be on or off", 3, 1},
71+ {"bad duplicate app-id", "rule.a.app-id = foo\nrule.a.app-id = (\n", "rule.a.app-id: invalid regular expression", 2, 1},
69 {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},72 {"bad width", "rule.a.app-id = foo\nrule.a.width = wide\n", "rule.a.width: must be a width", 2, 0},
70 {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},73 {"bad workspace number", "rule.a.app-id = foo\nrule.a.workspace = 0\n", "rule.a.workspace: workspace number", 2, 0},
71 {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},74 {"bad workspace name", "rule.a.app-id = foo\nrule.a.workspace = a b\n", "rule.a.workspace: must be", 2, 0},

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close