diff --git a/config/binds.go b/config/binds.go index 3b9510cd..9591ee49 100644 --- a/config/binds.go +++ b/config/binds.go @@ -7,12 +7,14 @@ import ( "io" "os" "path" + "path/filepath" "regexp" "strings" "unicode" "unicode/utf8" "git.sr.ht/~rjarry/aerc/lib/log" + "git.sr.ht/~rjarry/aerc/lib/xdg" "git.sr.ht/~rockorager/vaxis" "github.com/go-ini/ini" ) @@ -104,7 +106,24 @@ func defaultBindsConfig() *BindingConfig { var Binds = defaultBindsConfig() -func parseBindsFromFile(root string, filename string) error { +func parseBindsFromFile(_ string, filename string) error { + filename = xdg.ExpandHome(filename) + absolute, err := filepath.Abs(filename) + if err != nil { + return err + } + return parseBindsFile(absolute, make(map[string]bool)) +} + +func parseBindsFile(filename string, stack map[string]bool) error { + filename = filepath.Clean(filename) + + if stack[filename] { + return fmt.Errorf("cyclic key bindings include: %s", filename) + } + stack[filename] = true + defer delete(stack, filename) + log.Debugf("Parsing key bindings configuration from %s", filename) binds, err := ini.LoadSources(ini.LoadOptions{ KeyValueDelimiters: "=", @@ -117,6 +136,26 @@ func parseBindsFromFile(root string, filename string) error { return err } + defaultSection, err := binds.GetSection(ini.DefaultSection) + if err != nil { + return err + } + if include, err := defaultSection.GetKey("$include"); err == nil { + for _, name := range strings.Split(include.String(), ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + name = xdg.ExpandHome(name) + if !filepath.IsAbs(name) { + name = filepath.Join(filepath.Dir(filename), name) + } + if err := parseBindsFile(name, stack); err != nil { + return fmt.Errorf("include %s from %s: %w", name, filename, err) + } + } + } + baseGroups := map[string]**KeyBindings{ "default": &Binds.Global, "compose": &Binds.Compose, @@ -178,6 +217,8 @@ func LoadBindingSection(sec *ini.Section) (*KeyBindings, error) { value, annotation, _ := strings.Cut(k.String(), " # ") value = strings.TrimSpace(value) switch key { + case "$include": + continue case "$ex": strokes, err := ParseKeyStrokes(value) if err != nil { diff --git a/config/binds_test.go b/config/binds_test.go index a47da41c..e3182537 100644 --- a/config/binds_test.go +++ b/config/binds_test.go @@ -2,12 +2,57 @@ package config import ( "fmt" + "os" + "path/filepath" "testing" "git.sr.ht/~rockorager/vaxis" "github.com/stretchr/testify/assert" ) +func TestBindsInclude(t *testing.T) { + oldBinds := Binds + t.Cleanup(func() { Binds = oldBinds }) + Binds = defaultBindsConfig() + + dir := t.TempDir() + write := func(name, content string) { + err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600) + assert.NoError(t, err) + } + write("base.conf", "a = :base\n[messages]\nq = :base\n") + write("extra.conf", "a = :extra\n[messages]\nq = :extra\n") + write("binds.conf", "$include = base.conf, extra.conf\na = :local\n") + + assert.NoError(t, parseBindsFromFile(dir, filepath.Join(dir, "binds.conf"))) + assertBinding := func(bindings *KeyBindings, input, output string) { + in, err := ParseKeyStrokes(input) + assert.NoError(t, err) + want, err := ParseKeyStrokes(output) + assert.NoError(t, err) + result, got := bindings.GetBinding(in) + assert.Equal(t, BindingSearchResult(BINDING_FOUND), result) + assert.Equal(t, want, got) + } + assertBinding(Binds.Global, "a", ":local") + assertBinding(Binds.MessageList, "q", ":extra") +} + +func TestBindsIncludeCycle(t *testing.T) { + oldBinds := Binds + t.Cleanup(func() { Binds = oldBinds }) + Binds = defaultBindsConfig() + + dir := t.TempDir() + assert.NoError(t, os.WriteFile( + filepath.Join(dir, "one.conf"), []byte("$include = two.conf\n"), 0o600)) + assert.NoError(t, os.WriteFile( + filepath.Join(dir, "two.conf"), []byte("$include = one.conf\n"), 0o600)) + + err := parseBindsFromFile(dir, filepath.Join(dir, "one.conf")) + assert.ErrorContains(t, err, "cyclic key bindings include") +} + func TestGetBinding(t *testing.T) { assert := assert.New(t) diff --git a/doc/aerc-binds.5.scd b/doc/aerc-binds.5.scd index 1f508ef8..68155c7a 100644 --- a/doc/aerc-binds.5.scd +++ b/doc/aerc-binds.5.scd @@ -30,6 +30,23 @@ in a similar fashion. You may configure different keybindings for different contexts by writing them into different *[sections]* of the ini file. +# INCLUDES + +Additional binding files can be loaded with the *$include* special option in the +global section: + +``` +$include = /usr/share/aerc/binds.conf +``` + +Multiple files can be separated with commas. Relative paths are resolved from +the directory containing the file with the *$include* option. Paths beginning +with _~/_ are expanded to the user's home directory. Included files are loaded +in the order listed, before the including file. Therefore, bindings in the +including file override bindings from included files. + +Includes may be nested. Cyclic includes are rejected. + # CONTEXTS The available contexts are: