#!/usr/bin/python3 """ Fuzzy-find a file and edit it. Dependencies ============ * fd * fzf """ import argparse import os import shutil import subprocess # ========== Constants ========== # Paths BOOT_DIR = '/boot' ETC_DIR = '/etc' # Exit Codes E_NOEDITORFOUND = 2 E_NOFILESELECTED = 3 # Commands FIND_CMD = '/usr/bin/fd' FIND_OPTS = ['--hidden', '--print0', '--type', 'f', '--no-ignore-vcs'] FZF_CMD = '/usr/bin/fzf' FZF_OPTS = ['--read0', '--select-1', '--exit-0', '--print0'] LOCATE_CMD = '/usr/bin/locate' LOCATE_OPTS = ['--all', '--ignore-case', '--null'] LOCALE = 'utf-8' # ========== Functions ========== def select_editor(editor_override=None): """Return a possible canonical path to an editor. Select an editor from one of: * -e, --editor * $EDITOR * Default of vim In this order If an editor cannot be resolved, then an Error is raised instead. :param editor_override: argument to override an editor :returns: path to one of these editors :rtype: str :raises: FileNotFoundError if an editor could not be resolved """ editor = None if editor_override is not None: editor = shutil.which(editor_override) elif 'EDITOR' in os.environ: editor = shutil.which(os.environ.get('EDITOR')) elif shutil.which('vim') is not None: editor = shutil.which('vim') if editor is None: raise FileNotFoundError('An editor could not be resolved') return editor def gen_editor_cmd(filename): """Generate a command line to run for editing a file based on permissions. :param filename: name of file to edit :type filename: str or path-like object :returns: command to execute to edit file :rtype: list """ # possible for a race condition to occur here if os.access(filename, os.W_OK): return [editor, filename] else: return ['sudo', '--edit', filename] def run_fzf(files): """Run fzf on a stream of searched files for the user to select. :param files: stream of null-terminated files to read :type files: bytes stream (stdout of a completed process) :returns: selected file :rtype: str """ selected_file = subprocess.run([FZF_CMD] + FZF_OPTS, input=files, stdout=subprocess.PIPE).stdout return selected_file.decode(LOCALE).strip('\x00') def find_files(directory=None): """Use a find-based program to locate files, then pass to fzf. :param directory: directory to search for files :type directory: str :returns: path of user-selected file :rtype: bytes """ cmd = [FIND_CMD] + FIND_OPTS if directory is not None: cmd.extend(['--', '.', directory]) return subprocess.run(cmd, capture_output=True).stdout def locate_files(patterns): """Use a locate-based program to locate files, then pass to fzf. :param patterns: patterns to pass to locate :type patterns: list :returns: path of user-selected file :rtype: bytes """ cmd = [LOCATE_CMD] + LOCATE_OPTS cmd.extend(patterns) return subprocess.run(cmd, capture_output=True).stdout # ========== Main Script ========== parser = argparse.ArgumentParser() parser.add_argument('-b', '--boot', action='store_const', const=BOOT_DIR, dest='dir', help='edit a file in /boot') parser.add_argument('-d', '--dir', dest='dir', type=str, help='edit a file in a given directory') parser.add_argument('-E', '--etc', action='store_const', const=ETC_DIR, dest='dir', help='edit a file in /etc') parser.add_argument('-e', '--editor', help='use a given editor') parser.add_argument('patterns', type=str, nargs='*', help='patterns to pass to locate') args = parser.parse_args() final_find_cmd = [FIND_CMD] + FIND_OPTS editor = '' try: editor = select_editor(args.editor) except FileNotFoundError as e: print(e) exit(E_NOEDITORFOUND) # If patterns were passed, use locate # Otherwise check for -d and use fd if not args.patterns == []: files = locate_files(args.patterns) else: files = find_files(args.dir) selected_file = run_fzf(files) if not selected_file == '': cmd = gen_editor_cmd(selected_file) subprocess.run(cmd) else: exit(E_NOFILESELECTED)