diff --git a/core/internal/keybinds/providers/hyprland.go b/core/internal/keybinds/providers/hyprland.go index be6319e24..addce5632 100644 --- a/core/internal/keybinds/providers/hyprland.go +++ b/core/internal/keybinds/providers/hyprland.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -224,6 +225,44 @@ func (h *HyprlandProvider) validateAction(action string) error { return nil } +var luaExprActionPattern = regexp.MustCompile(`^(function\s*\(|hl\.)`) + +// isRawLuaActionText reports that action is a Lua expression to re-emit +// verbatim rather than freeform dispatcher text to wrap for hyprctl. The +// balance check keeps malformed input from corrupting the generated file. +func isRawLuaActionText(action string) bool { + if !luaExprActionPattern.MatchString(action) { + return false + } + depth := 0 + var quote byte + escaped := false + for i := 0; i < len(action); i++ { + c := action[i] + switch { + case escaped: + escaped = false + case quote != 0: + switch c { + case '\\': + escaped = true + case quote: + quote = 0 + } + case c == '"' || c == '\'': + quote = c + case c == '(': + depth++ + case c == ')': + depth-- + if depth < 0 { + return false + } + } + } + return depth == 0 && quote == 0 && !escaped +} + func (h *HyprlandProvider) SetBind(key, action, description string, options map[string]any) error { if err := h.ensureWritableConfig(); err != nil { return err @@ -254,11 +293,12 @@ func (h *HyprlandProvider) SetBind(key, action, description string, options map[ canonicalKey := canonicalHyprlandOverrideKey(key) normalizedKey := hyprlandOverrideMapKey(canonicalKey) existingBinds[normalizedKey] = &hyprlandOverrideBind{ - Key: canonicalKey, - Action: action, - Description: description, - Flags: flags, - Options: options, + Key: canonicalKey, + Action: action, + Description: description, + Flags: flags, + Options: options, + RawLuaAction: isRawLuaActionText(action), } return h.writeOverrideBinds(existingBinds) diff --git a/core/internal/keybinds/providers/hyprland_parser_test.go b/core/internal/keybinds/providers/hyprland_parser_test.go index 27c0c0edf..51be6968f 100644 --- a/core/internal/keybinds/providers/hyprland_parser_test.go +++ b/core/internal/keybinds/providers/hyprland_parser_test.go @@ -463,6 +463,56 @@ func TestHyprlandSetBindLeavesConfOnlyInstallReadOnly(t *testing.T) { } } +func TestIsRawLuaActionText(t *testing.T) { + cases := []struct { + action string + want bool + }{ + {`function() hl.plugin.scrolloverview.overview("toggle") end`, true}, + {`hl.dsp.exec_cmd("foo")`, true}, + {`hl.dsp.no_op()`, true}, + {"workspace 3", false}, + {"exec zeditor", false}, + {`function() hl.foo( end`, false}, + {`function() hl.foo("a) end`, false}, + {`hl.foo()) hl.bar((`, false}, + } + for _, tc := range cases { + if got := isRawLuaActionText(tc.action); got != tc.want { + t.Errorf("isRawLuaActionText(%q) = %v, want %v", tc.action, got, tc.want) + } + } +} + +func TestHyprlandSetBindPreservesRawLuaAction(t *testing.T) { + tmpDir := t.TempDir() + dmsDir := filepath.Join(tmpDir, "dms") + if err := os.MkdirAll(dmsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dmsDir, "binds-user.lua"), []byte("-- DMS user keybind overrides\n"), 0o644); err != nil { + t.Fatal(err) + } + + provider := NewHyprlandProvider(tmpDir) + rawAction := `function() hl.plugin.scrolloverview.overview("toggle") end` + if err := provider.SetBind("SUPER + G", rawAction, "Toggle overview", nil); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(filepath.Join(dmsDir, "binds-user.lua")) + if err != nil { + t.Fatal(err) + } + got := string(data) + if !strings.Contains(got, `hl.bind("SUPER + G", `+rawAction) { + t.Fatalf("expected raw Lua action to be written verbatim, got:\n%s", got) + } + if strings.Contains(got, "hyprctl dispatch function") { + t.Fatalf("expected raw Lua action to not be wrapped in hyprctl dispatch, got:\n%s", got) + } +} + func TestHyprlandSetBindUpdatesSpacedLuaOverrideWithoutDuplicates(t *testing.T) { tmpDir := t.TempDir() dmsDir := filepath.Join(tmpDir, "dms")