diff --git a/devscripts/bash-completion.in b/devscripts/bash-completion.in index 994bb4e721..c58b9382b7 100644 --- a/devscripts/bash-completion.in +++ b/devscripts/bash-completion.in @@ -1,33 +1,84 @@ -__yt_dlp() -{ - local cur prev opts fileopts diropts keywords - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - opts="{{flags}}" - keywords=":ytfavorites :ytrecommended :ytsubscriptions :ytwatchlater :ythistory" - fileopts="-a|--batch-file|--download-archive|--cookies|--load-info-json" - diropts="--cache-dir" +_comp_cmd_yt-dlp() { + # Not using "$2" and "$3" to partially workaround YouTube keyword completions. + local have_bashcomp=0 cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD - 1]}" - if [[ ${prev} =~ ${fileopts} ]]; then - local IFS=$'\n' - type compopt &>/dev/null && compopt -o filenames - COMPREPLY=( $(compgen -f -- ${cur}) ) - return 0 - elif [[ ${prev} =~ ${diropts} ]]; then - local IFS=$'\n' - type compopt &>/dev/null && compopt -o dirnames - COMPREPLY=( $(compgen -d -- ${cur}) ) - return 0 + # Long options with equals and YouTube keyword completions + # do not work without bash-completion. + if declare -F _comp_initialize &> /dev/null; then + have_bashcomp=1 + # Unused variables assigned by _comp_initialize. + # shellcheck disable=SC2034 + local words cword comp_args was_split + # ':' must be excluded from COMP_WORDBREAKS to handle :yt* keywords + _comp_initialize -n : -s -- "$@" || return fi - if [[ ${cur} =~ : ]]; then - COMPREPLY=( $(compgen -W "${keywords}" -- ${cur}) ) - return 0 - elif [[ ${cur} == * ]] ; then - COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) - return 0 + local filedir_action + case "$prev" in + YT_DLP_FILE_OPTS_CASE ) + filedir_action='file' + ;; + + YT_DLP_DIR_OPTS_CASE ) + filedir_action='directory' + ;; + + * ) + ;; + esac + + local compreply_lines + if [[ "$filedir_action" ]]; then + if (( "$have_bashcomp" )); then + if [[ "$filedir_action" = directory ]]; then + _comp_compgen_filedir -d + else + _comp_compgen_filedir + fi + else + # macOS Bash does not have compopt. + if type compopt &>/dev/null; then + compopt -o filenames + fi + compreply_lines="$(compgen -A "$filedir_action" -- "$cur")" + fi + elif [[ "$cur" = :* ]]; then + # Adding keywords one by one since macOS Bash + # can't initialise a local array from brace expansions. + local k keywords + for k in :ytnotif{,ication}{,s} \ + :ytfav{,o{,u}rite}{,s} \ + :ytwatchlater \ + :ytrec{,ommended} \ + :ytsub{,scription}{,s} \ + :ythis{,tory}; do + keywords+=("$k") + done + if (( "$have_bashcomp" )); then + _comp_compgen -- -W "${keywords[*]}" + _comp_ltrim_colon_completions "$cur" + else + compreply_lines="$(compgen -W "${keywords[*]}" -- "$cur")" + compreply_lines="${compreply_lines//:/}" + fi + else + local -r opts=( YT_DLP_OPTS_ARRAY ) + if type compopt &> /dev/null; then + compopt -o nosort + fi + if (( "$have_bashcomp" )); then + _comp_compgen -- -W "${opts[*]}" + else + compreply_lines="$(compgen -W "${opts[*]}" -- "$cur")" + fi fi -} -complete -F __yt_dlp yt-dlp + if [[ "$have_bashcomp" != 1 && "$compreply_lines" ]]; then + COMPREPLY=() + local line + while IFS='' read -r line; do + COMPREPLY+=("$line") + done <<< "$compreply_lines" + fi +} \ + && complete -F _comp_cmd_yt-dlp yt-dlp diff --git a/devscripts/bash-completion.py b/devscripts/bash-completion.py index 3918ebde86..01dc94ff09 100755 --- a/devscripts/bash-completion.py +++ b/devscripts/bash-completion.py @@ -8,23 +8,24 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import yt_dlp +from devscripts.utils import gather_completion_opts BASH_COMPLETION_FILE = 'completions/bash/yt-dlp' BASH_COMPLETION_TEMPLATE = 'devscripts/bash-completion.in' def build_completion(opt_parser): - opts_flag = [] - for group in opt_parser.option_groups: - for option in group.option_list: - # for every long flag - opts_flag.append(option.get_opt_string()) + opts = gather_completion_opts(opt_parser) + with open(BASH_COMPLETION_TEMPLATE) as f: template = f.read() + + template = template.replace('YT_DLP_FILE_OPTS_CASE', ' | '.join(opts.file_opts)) + template = template.replace('YT_DLP_DIR_OPTS_CASE', ' | '.join(opts.dir_opts)) + template = template.replace('YT_DLP_OPTS_ARRAY', ' '.join(opts.opts)) + with open(BASH_COMPLETION_FILE, 'w') as f: - # just using the special char - filled_template = template.replace('{{flags}}', ' '.join(opts_flag)) - f.write(filled_template) + f.write(template) parser = yt_dlp.parseOpts(ignore_config_files=True)[0] diff --git a/devscripts/utils.py b/devscripts/utils.py index b89d01e415..75a2423fdf 100644 --- a/devscripts/utils.py +++ b/devscripts/utils.py @@ -2,7 +2,9 @@ import argparse import datetime as dt import functools import re +import shlex import subprocess +from dataclasses import dataclass def read_file(fname): @@ -64,3 +66,36 @@ def run_process(*args, **kwargs): kwargs.setdefault('encoding', 'utf-8') kwargs.setdefault('errors', 'replace') return subprocess.run(args, **kwargs) + + +@dataclass +class YtDlpCompletionOpts: + opts: tuple[str, ...] + file_opts: tuple[str, ...] + dir_opts: tuple[str, ...] + + +def gather_completion_opts(yt_dlp_opt_parser): + opts, long_opts = [], [] + file_opts, dir_opts = [], [] + + for opt in yt_dlp_opt_parser._get_all_options(): + opts.extend(opt._short_opts) + long_opts.extend(opt._long_opts) + if opt.metavar in {'FILE', 'PATH', 'CERTFILE', 'KEYFILE'}: + file_opts.extend(opt._short_opts) + file_opts.extend(opt._long_opts) + elif opt.metavar == 'DIR': + dir_opts.extend(opt._short_opts) + dir_opts.extend(opt._long_opts) + + opts.sort() + long_opts.sort() + opts.extend(long_opts) + + # Escape after sorting. + return YtDlpCompletionOpts( + tuple(shlex.quote(o) for o in opts), + tuple(shlex.quote(o) for o in file_opts), + tuple(shlex.quote(o) for o in dir_opts), + ) diff --git a/devscripts/zsh-completion.py b/devscripts/zsh-completion.py index 046e9231f1..13318ef29b 100755 --- a/devscripts/zsh-completion.py +++ b/devscripts/zsh-completion.py @@ -8,46 +8,21 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import yt_dlp +from devscripts.utils import gather_completion_opts ZSH_COMPLETION_FILE = 'completions/zsh/_yt-dlp' ZSH_COMPLETION_TEMPLATE = 'devscripts/zsh-completion.in' def build_completion(opt_parser): - opts = [opt for group in opt_parser.option_groups - for opt in group.option_list] - opts_file = [opt for opt in opts if opt.metavar == 'FILE'] - opts_dir = [opt for opt in opts if opt.metavar == 'DIR'] - opts_path = [opt for opt in opts if opt.metavar == 'PATH'] - - fileopts = [] - for opt in opts_file: - if opt._short_opts: - fileopts.extend(opt._short_opts) - if opt._long_opts: - fileopts.extend(opt._long_opts) - - for opt in opts_path: - if opt._short_opts: - fileopts.extend(opt._short_opts) - if opt._long_opts: - fileopts.extend(opt._long_opts) - - diropts = [] - for opt in opts_dir: - if opt._short_opts: - diropts.extend(opt._short_opts) - if opt._long_opts: - diropts.extend(opt._long_opts) - - flags = [opt.get_opt_string() for opt in opts] + opts = gather_completion_opts(opt_parser) with open(ZSH_COMPLETION_TEMPLATE) as f: template = f.read() - template = template.replace('{{fileopts}}', '|'.join(fileopts)) - template = template.replace('{{diropts}}', '|'.join(diropts)) - template = template.replace('{{flags}}', ' '.join(flags)) + template = template.replace('{{fileopts}}', '|'.join(opts.file_opts)) + template = template.replace('{{diropts}}', '|'.join(opts.dir_opts)) + template = template.replace('{{flags}}', ' '.join(opts.opts)) with open(ZSH_COMPLETION_FILE, 'w') as f: f.write(template) diff --git a/yt_dlp/options.py b/yt_dlp/options.py index 14b582fc42..7f1c31daaf 100644 --- a/yt_dlp/options.py +++ b/yt_dlp/options.py @@ -1535,7 +1535,7 @@ def create_parser(): help='Netscape formatted file to read cookies from and dump cookie jar in') filesystem.add_option( '--no-cookies', - action='store_const', const=None, dest='cookiefile', metavar='FILE', + action='store_const', const=None, dest='cookiefile', help='Do not read/dump cookies from/to file (default)') filesystem.add_option( '--cookies-from-browser',