#!/usr/bin/env bash set -Eeuo pipefail readonly COC_REPOSITORY='https://github.com/neoclide/coc.nvim' readonly COC_ARCHIVE='https://github.com/neoclide/coc.nvim/archive/refs/heads/release.tar.gz' readonly REQUIRED_NODE='22.15.0' readonly REQUIRED_VIM='9.0.0438' readonly REQUIRED_NVIM='0.8.0' editor='' dry_run=0 assume_yes=0 modify_config=1 step() { printf '\033[36m==> %s\033[0m\n' "$1"; } success() { printf '\033[32m%s\033[0m\n' "$1"; } warn() { printf '\033[33m%s\033[0m\n' "$1" >&2; } die() { printf '\033[31mERROR: %s\033[0m\n' "$1" >&2; exit 1; } usage() { cat <<'EOF' Install or update coc.nvim from its pre-built release branch. Usage: install-coc.sh --editor=vim|nvim|all [options] Options: --editor=TARGET Required target: vim, nvim, or all. --dry-run Print every path and planned change without writing files. --yes Confirm the displayed plan non-interactively. --no-config Install coc.nvim without adding a packadd line. -h, --help Show this help. EOF } for arg in "$@"; do case "$arg" in --editor=vim|--editor=nvim|--editor=all) editor=${arg#*=} ;; --dry-run) dry_run=1 ;; --yes) assume_yes=1 ;; --no-config) modify_config=0 ;; -h|--help) usage; exit 0 ;; *) die "Unknown argument: $arg" ;; esac done [[ -n $editor ]] || { usage >&2; die 'Choose a target with --editor=vim, --editor=nvim, or --editor=all.'; } [[ -n ${HOME:-} ]] || die 'HOME is not set.' version_at_least() { local actual=${1#v} required=${2#v} local actual_major actual_minor actual_patch required_major required_minor required_patch IFS=. read -r actual_major actual_minor actual_patch <<<"$actual" IFS=. read -r required_major required_minor required_patch <<<"$required" actual_major=${actual_major:-0}; actual_minor=${actual_minor:-0}; actual_patch=${actual_patch%%[^0-9]*}; actual_patch=${actual_patch:-0} required_major=${required_major:-0}; required_minor=${required_minor:-0}; required_patch=${required_patch%%[^0-9]*}; required_patch=${required_patch:-0} (( actual_major > required_major )) && return 0 (( actual_major < required_major )) && return 1 (( actual_minor > required_minor )) && return 0 (( actual_minor < required_minor )) && return 1 (( actual_patch >= required_patch )) } require_node() { command -v node >/dev/null 2>&1 || die "Node.js $REQUIRED_NODE or newer is required." local value value=$(node --version 2>/dev/null || true) [[ -n $value ]] && version_at_least "$value" "$REQUIRED_NODE" || die "Node.js ${value:-unknown} is too old; $REQUIRED_NODE or newer is required." step "Node.js ${value#v} detected" } require_vim() { command -v vim >/dev/null 2>&1 || die 'vim was not found in PATH.' if ! vim -Nu NONE -n -es -c "if !has('patch-$REQUIRED_VIM') | cquit 1 | endif" -c 'qa!' >/dev/null 2>&1; then die "Vim $REQUIRED_VIM or newer is required." fi step "$(vim --version | sed -n '1p') detected" } require_nvim() { command -v nvim >/dev/null 2>&1 || die 'nvim was not found in PATH.' local first_line value first_line=$(nvim --version | sed -n '1p') value=$(printf '%s' "$first_line" | sed -nE 's/^NVIM v([0-9]+\.[0-9]+\.[0-9]+).*/\1/p') [[ -n $value ]] && version_at_least "$value" "$REQUIRED_NVIM" || die "Neovim ${value:-unknown} is too old; $REQUIRED_NVIM or newer is required." step "$first_line detected" } declare -a editor_names=() declare -a install_paths=() declare -a config_paths=() declare -a packadd_lines=() add_vim_target() { require_vim local config_path="$HOME/.vimrc" [[ ! -f $HOME/.vim/vimrc || -f $config_path ]] || config_path="$HOME/.vim/vimrc" editor_names+=('Vim') install_paths+=("$HOME/.vim/pack/coc/opt/coc.nvim") config_paths+=("$config_path") packadd_lines+=('packadd coc.nvim') } add_nvim_target() { require_nvim local config_root="${XDG_CONFIG_HOME:-$HOME/.config}/nvim" local data_root="${XDG_DATA_HOME:-$HOME/.local/share}/nvim" local config_path="$config_root/init.vim" packadd_line='packadd coc.nvim' if [[ -f $config_root/init.lua ]]; then config_path="$config_root/init.lua" packadd_line="vim.cmd.packadd('coc.nvim')" fi editor_names+=('Neovim') install_paths+=("$data_root/site/pack/coc/opt/coc.nvim") config_paths+=("$config_path") packadd_lines+=("$packadd_line") } require_node case "$editor" in vim) add_vim_target ;; nvim) add_nvim_target ;; all) add_vim_target; add_nvim_target ;; esac is_selected_install_target() { local candidate=$1 selected for selected in "${install_paths[@]}"; do [[ $candidate == "$selected" ]] && return 0 done return 1 } find_conflicting_install() { local candidate local nvim_data="${XDG_DATA_HOME:-$HOME/.local/share}/nvim" shopt -s nullglob local candidates=() # A native install for one editor does not load into the other editor. Only # inspect the selected editor's paths; --editor=all intentionally inspects # both sets below. if [[ $editor == vim || $editor == all ]]; then candidates+=( "$HOME/.vim/plugged/coc.nvim" "$HOME/.vim/pack/"*/start/coc.nvim "$HOME/.vim/pack/"*/opt/coc.nvim ) fi if [[ $editor == nvim || $editor == all ]]; then candidates+=( "$nvim_data/lazy/coc.nvim" "$nvim_data/site/pack/"*/start/coc.nvim "$nvim_data/site/pack/"*/opt/coc.nvim ) fi shopt -u nullglob for candidate in "${candidates[@]}"; do [[ ! -e $candidate ]] && continue # When --editor=all is used, both native-pack targets are intentionally # updated by this invocation. Do not mistake one selected target for a # conflicting installation while checking the other target. is_selected_install_target "$candidate" && continue printf '%s' "$candidate" return 0 done return 1 } for ((i = 0; i < ${#editor_names[@]}; i++)); do conflict=$(find_conflicting_install || true) [[ -z $conflict ]] || die "Another coc.nvim installation was found at $conflict. Update or remove it with its plugin manager before using this installer." done step 'Planned changes' for ((i = 0; i < ${#editor_names[@]}; i++)); do printf ' %s install: %s\n' "${editor_names[$i]}" "${install_paths[$i]}" if [[ -e ${install_paths[$i]} ]]; then printf ' Existing install: preserved as a timestamped .backup-* directory\n' fi if (( modify_config )); then printf ' Config file: %s\n' "${config_paths[$i]}" printf ' Config line: %s\n' "${packadd_lines[$i]}" else printf ' Config file: unchanged (--no-config)\n' fi done if (( dry_run )); then success 'Dry run complete. No files were downloaded or changed.' exit 0 fi if (( ! assume_yes )); then [[ -r /dev/tty ]] || die 'Confirmation requires a terminal. Re-run with --yes only after reviewing --dry-run output.' printf 'Continue? [y/N] ' >/dev/tty read -r reply /dev/null 2>&1 || die 'tar is required but was not found.' temp_root=$(mktemp -d "${TMPDIR:-/tmp}/coc-install.XXXXXXXX") cleanup() { rm -rf "$temp_root"; } trap cleanup EXIT archive_path="$temp_root/coc.nvim-release.tar.gz" extract_path="$temp_root/extract" mkdir -p "$extract_path" step 'Downloading coc.nvim release branch' if command -v curl >/dev/null 2>&1; then curl -fL --retry 3 --connect-timeout 15 "$COC_ARCHIVE" -o "$archive_path" elif command -v wget >/dev/null 2>&1; then wget -O "$archive_path" "$COC_ARCHIVE" else die 'curl or wget is required but neither was found.' fi tar -xzf "$archive_path" -C "$extract_path" source_path="$extract_path/coc.nvim-release" [[ -f $source_path/build/index.js ]] || die "Unexpected release archive from $COC_REPOSITORY" append_line_once() { local config_path=$1 line=$2 mkdir -p "$(dirname "$config_path")" touch "$config_path" # A plugin manager, an explicit runtimepath entry, or a Lua/Vimscript # packadd already loads coc.nvim. Do not add a second activation line. if grep -Eiq \ '(^|[^[:alnum:]_])(packadd!?[[:space:]]+coc\.nvim|vim\.cmd[^#]*packadd[^#]*coc\.nvim|(runtime!?|set[[:space:]]+(runtimepath|rtp))[^#]*coc\.nvim|(Plug|use)[^#]*neoclide/coc\.nvim)' \ "$config_path"; then return 2 fi grep -Fqx -- "$line" "$config_path" && return 1 if [[ -s $config_path ]] && [[ $(tail -c 1 "$config_path" | wc -l | tr -d ' ') == 0 ]]; then printf '\n' >>"$config_path"; fi printf '%s\n' "$line" >>"$config_path" } install_coc_copy() { local source=$1 target=$2 backup='' mkdir -p "$(dirname "$target")" if [[ -e $target ]]; then backup="${target}.backup-$(date +%Y%m%d%H%M%S)" [[ ! -e $backup ]] || die "Backup already exists: $backup" mv "$target" "$backup" fi mkdir -p "$target" if ! cp -R "$source/." "$target" || [[ ! -f $target/build/index.js ]]; then rm -rf "$target" [[ -z $backup ]] || mv "$backup" "$target" die "Failed to install coc.nvim at $target" fi [[ -z $backup ]] || printf ' Backup path: %s\n' "$backup" } for ((i = 0; i < ${#editor_names[@]}; i++)); do step "Installing coc.nvim for ${editor_names[$i]}" install_coc_copy "$source_path" "${install_paths[$i]}" if (( modify_config )); then if append_line_once "${config_paths[$i]}" "${packadd_lines[$i]}"; then printf ' Added to %s: %s\n' "${config_paths[$i]}" "${packadd_lines[$i]}" else case $? in 2) printf ' Config unchanged: existing coc.nvim runtimepath/plugin-manager entry\n' ;; *) printf ' Config unchanged: packadd line already exists\n' ;; esac fi fi success "${editor_names[$i]} installation complete" done success 'Done. Restart the selected editor and run :CocInfo to verify.'