Repository: beetbox/beets

Issue:
new plugin fish does autocompletion script for fish shell
the `fish` plugin adds a `beet fish` command that will create a fish
autocompletion file `beet.fish` in `~/.config/fish/completions`
This makes `fish`_- a different shell - autocomplete commands for beet.
http://fishshell.com/
It already does most of what I want it to do. Have a look at it.



Discussion:
nice :)

This is looking very cool! I'm a zsh person myself, but I would totally use this if I made the jump to fish.

There are a few style conventions that could use cleaning up before we merge. It could also be really helpful to have a few more docstrings at the tops of the functions—just some brief information about the purpose of each function can make code easier to read.

ok, will do.

whoo FISH!  I use fish, so will be giving this a try.

This completion seems to have broken completions outside of beets.  I ran beet import  and then hit B to abort.  After that , rm and ls get turned into remove and list, even though I'm typing just regular rm,ls with no beet prefix

seems to be due to the abbreviations

SET fish_user_abbreviations:\x3f\x20help\x1eimp\x20import\x1eim\x20import\x1els\x20list\x1emiss\x20missing\x1emod\x20modify\x1emv\x20move\x1erm\x20remove\x1eupd\x20update\x1eup\x20update


Resolving patch:
diff --git a/beetsplug/fish.py b/beetsplug/fish.py
new file mode 100644
--- /dev/null
+++ b/beetsplug/fish.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+# This file is part of beets.
+# Copyright 2015, winters jean-marie.
+# Copyright 2020, Justin Mayer <https://justinmayer.com>
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+
+"""This plugin generates tab completions for Beets commands for the Fish shell
+<https://fishshell.com/>, including completions for Beets commands, plugin
+commands, and option flags. Also generated are completions for all the album
+and track fields, suggesting for example `genre:` or `album:` when querying the
+Beets database. Completions for the *values* of those fields are not generated
+by default but can be added via the `-e` / `--extravalues` flag. For example:
+`beet fish -e genre -e albumartist`
+"""
+
+from __future__ import division, absolute_import, print_function
+
+from beets.plugins import BeetsPlugin
+from beets import library, ui
+from beets.ui import commands
+from operator import attrgetter
+import os
+BL_NEED2 = """complete -c beet -n '__fish_beet_needs_command' {} {}\n"""
+BL_USE3 = """complete -c beet -n '__fish_beet_using_command {}' {} {}\n"""
+BL_SUBS = """complete -c beet -n '__fish_at_level {} ""' {}  {}\n"""
+BL_EXTRA3 = """complete -c beet -n '__fish_beet_use_extra {}' {} {}\n"""
+
+HEAD = '''
+function __fish_beet_needs_command
+    set cmd (commandline -opc)
+    if test (count $cmd) -eq 1
+        return 0
+    end
+    return 1
+end
+
+function __fish_beet_using_command
+    set cmd (commandline -opc)
+    set needle (count $cmd)
+    if test $needle -gt 1
+        if begin test $argv[1] = $cmd[2];
+            and not contains -- $cmd[$needle] $FIELDS; end
+                return 0
+        end
+    end
+    return 1
+end
+
+function __fish_beet_use_extra
+    set cmd (commandline -opc)
+    set needle (count $cmd)
+    if test $argv[2]  = $cmd[$needle]
+        return 0
+    end
+    return 1
+end
+'''
+
+
+class FishPlugin(BeetsPlugin):
+
+    def commands(self):
+        cmd = ui.Subcommand('fish', help='generate Fish shell tab completions')
+        cmd.func = self.run
+        cmd.parser.add_option('-f', '--noFields', action='store_true',
+                              default=False,
+                              help='omit album/track field completions')
+        cmd.parser.add_option(
+            '-e',
+            '--extravalues',
+            action='append',
+            type='choice',
+            choices=library.Item.all_keys() +
+            library.Album.all_keys(),
+            help='include specified field *values* in completions')
+        return [cmd]
+
+    def run(self, lib, opts, args):
+        # Gather the commands from Beets core and its plugins.
+        # Collect the album and track fields.
+        # If specified, also collect the values for these fields.
+        # Make a giant string of all the above, formatted in a way that
+        # allows Fish to do tab completion for the `beet` command.
+        home_dir = os.path.expanduser("~")
+        completion_dir = os.path.join(home_dir, '.config/fish/completions')
+        try:
+            os.makedirs(completion_dir)
+        except OSError:
+            if not os.path.isdir(completion_dir):
+                raise
+        completion_file_path = os.path.join(completion_dir, 'beet.fish')
+        nobasicfields = opts.noFields  # Do not complete for album/track fields
+        extravalues = opts.extravalues  # e.g., Also complete artists names
+        beetcmds = sorted(
+            (commands.default_commands +
+             commands.plugins.commands()),
+            key=attrgetter('name'))
+        fields = sorted(set(
+            library.Album.all_keys() + library.Item.all_keys()))
+        # Collect commands, their aliases, and their help text
+        cmd_names_help = []
+        for cmd in beetcmds:
+            names = [alias for alias in cmd.aliases]
+            names.append(cmd.name)
+            for name in names:
+                cmd_names_help.append((name, cmd.help))
+        # Concatenate the string
+        totstring = HEAD + "\n"
+        totstring += get_cmds_list([name[0] for name in cmd_names_help])
+        totstring += '' if nobasicfields else get_standard_fields(fields)
+        totstring += get_extravalues(lib, extravalues) if extravalues else ''
+        totstring += "\n" + "# ====== {} =====".format(
+            "setup basic beet completion") + "\n" * 2
+        totstring += get_basic_beet_options()
+        totstring += "\n" + "# ====== {} =====".format(
+            "setup field completion for subcommands") + "\n"
+        totstring += get_subcommands(
+            cmd_names_help, nobasicfields, extravalues)
+        # Set up completion for all the command options
+        totstring += get_all_commands(beetcmds)
+
+        with open(completion_file_path, 'w') as fish_file:
+            fish_file.write(totstring)
+
+
+def get_cmds_list(cmds_names):
+    # Make a list of all Beets core & plugin commands
+    substr = ''
+    substr += (
+        "set CMDS " + " ".join(cmds_names) + ("\n" * 2)
+    )
+    return substr
+
+
+def get_standard_fields(fields):
+    # Make a list of album/track fields and append with ':'
+    fields = (field + ":" for field in fields)
+    substr = ''
+    substr += (
+        "set FIELDS " + " ".join(fields) + ("\n" * 2)
+    )
+    return substr
+
+
+def get_extravalues(lib, extravalues):
+    # Make a list of all values from an album/track field.
+    # 'beet ls albumartist: <TAB>' yields completions for ABBA, Beatles, etc.
+    word = ''
+    values_set = get_set_of_values_for_field(lib, extravalues)
+    for fld in extravalues:
+        extraname = fld.upper() + 'S'
+        word += (
+            "set  " + extraname + " " + " ".join(sorted(values_set[fld]))
+            + ("\n" * 2)
+        )
+    return word
+
+
+def get_set_of_values_for_field(lib, fields):
+    # Get unique values from a specified album/track field
+    fields_dict = {}
+    for each in fields:
+        fields_dict[each] = set()
+    for item in lib.items():
+        for field in fields:
+            fields_dict[field].add(wrap(item[field]))
+    return fields_dict
+
+
+def get_basic_beet_options():
+    word = (
+        BL_NEED2.format("-l format-item",
+                        "-f -d 'print with custom format'") +
+        BL_NEED2.format("-l format-album",
+                        "-f -d 'print with custom format'") +
+        BL_NEED2.format("-s  l  -l library",
+                        "-f -r -d 'library database file to use'") +
+        BL_NEED2.format("-s  d  -l directory",
+                        "-f -r -d 'destination music directory'") +
+        BL_NEED2.format("-s  v  -l verbose",
+                        "-f -d 'print debugging information'") +
+
+        BL_NEED2.format("-s  c  -l config",
+                        "-f -r -d 'path to configuration file'") +
+        BL_NEED2.format("-s  h  -l help",
+                        "-f -d 'print this help message and exit'"))
+    return word
+
+
+def get_subcommands(cmd_name_and_help, nobasicfields, extravalues):
+    # Formatting for Fish to complete our fields/values
+    word = ""
+    for cmdname, cmdhelp in cmd_name_and_help:
+        word += "\n" + "# ------ {} -------".format(
+            "fieldsetups for  " + cmdname) + "\n"
+        word += (
+            BL_NEED2.format(
+                ("-a " + cmdname),
+                ("-f " + "-d " + wrap(clean_whitespace(cmdhelp)))))
+
+        if nobasicfields is False:
+            word += (
+                BL_USE3.format(
+                    cmdname,
+                    ("-a " + wrap("$FIELDS")),
+                    ("-f " + "-d " + wrap("fieldname"))))
+
+        if extravalues:
+            for f in extravalues:
+                setvar = wrap("$" + f.upper() + "S")
+                word += " ".join(BL_EXTRA3.format(
+                    (cmdname + " " + f + ":"),
+                    ('-f ' + '-A ' + '-a ' + setvar),
+                    ('-d ' + wrap(f))).split()) + "\n"
+    return word
+
+
+def get_all_commands(beetcmds):
+    # Formatting for Fish to complete command options
+    word = ""
+    for cmd in beetcmds:
+        names = [alias for alias in cmd.aliases]
+        names.append(cmd.name)
+        for name in names:
+            word += "\n"
+            word += ("\n" * 2) + "# ====== {} =====".format(
+                "completions for  " + name) + "\n"
+
+            for option in cmd.parser._get_all_options()[1:]:
+                cmd_l = (" -l " + option._long_opts[0].replace('--', '')
+                         )if option._long_opts else ''
+                cmd_s = (" -s " + option._short_opts[0].replace('-', '')
+                         ) if option._short_opts else ''
+                cmd_need_arg = ' -r ' if option.nargs in [1] else ''
+                cmd_helpstr = (" -d " + wrap(' '.join(option.help.split()))
+                               ) if option.help else ''
+                cmd_arglist = (' -a ' + wrap(" ".join(option.choices))
+                               ) if option.choices else ''
+
+                word += " ".join(BL_USE3.format(
+                    name,
+                    (cmd_need_arg + cmd_s + cmd_l + " -f " + cmd_arglist),
+                    cmd_helpstr).split()) + "\n"
+
+            word = (word + " ".join(BL_USE3.format(
+                name,
+                ("-s " + "h " + "-l " + "help" + " -f "),
+                ('-d ' + wrap("print help") + "\n")
+            ).split()))
+    return word
+
+
+def clean_whitespace(word):
+    # Remove excess whitespace and tabs in a string
+    return " ".join(word.split())
+
+
+def wrap(word):
+    # Need " or ' around strings but watch out if they're in the string
+    sptoken = '\"'
+    if ('"') in word and ("'") in word:
+        word.replace('"', sptoken)
+        return '"' + word + '"'
+
+    tok = '"' if "'" in word else "'"
+    return tok + word + tok


Test patch: