diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a90ee00..2b1335a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,6 +12,20 @@ jobs: uses: actions/checkout@v3 - name: Run Shellcheck uses: azohra/shell-linter@latest + with: + # zsh completion functions (shell/completions) use zsh-only syntax + # ShellCheck cannot parse; tests/ holds .bats files (non-bash @test + # syntax) and third-party BATS submodules. ShellCheck skips both. + exclude-paths: "shell/completions,tests" + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + - name: Run BATS tests + run: ./tests/bats/bin/bats tests/*.bats lua: runs-on: ubuntu-latest diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a431ec9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "tests/bats"] + path = tests/bats + url = https://github.com/bats-core/bats-core.git +[submodule "tests/test_helper/bats-support"] + path = tests/test_helper/bats-support + url = https://github.com/bats-core/bats-support.git +[submodule "tests/test_helper/bats-assert"] + path = tests/test_helper/bats-assert + url = https://github.com/bats-core/bats-assert.git +[submodule "tests/test_helper/bats-file"] + path = tests/test_helper/bats-file + url = https://github.com/bats-core/bats-file.git diff --git a/shell/aliases b/shell/aliases index ffee1e2..cba620f 100644 --- a/shell/aliases +++ b/shell/aliases @@ -268,19 +268,101 @@ delete_terms() { set_posts_statuses() { wp post update "$(wp post list --post_type="${1}" --post_status="${2}" --format=ids)" --post_status="${3}" } +_project_open_root() { + printf '%s\n' "${PROJECT_OPEN_ROOT:-${HOME}/Code}" +} + +_project_open_scan() { + local root + root="$(_project_open_root)" + # Projects live at // and always hold a .git, so one + # find at that fixed depth lists them; submodules sit deeper and fall out. + find "${root}" -mindepth 3 -maxdepth 3 -name '.git' 2>/dev/null | + sed 's#/\.git$##' | + LC_ALL=C sort +} + +_project_open_cache_file() { + # Key the cache by a hash of the root so a different PROJECT_OPEN_ROOT never + # reads a stale cache. Hashing (not char substitution) avoids path collisions; + # trailing slashes are stripped so "/x" and "/x/" share one cache. + local root key + root="$(_project_open_root)" + root="${root%/}" + key="$(printf '%s' "${root}" | cksum | cut -d' ' -f1)" + printf '%s\n' "${XDG_CACHE_HOME:-${HOME}/.cache}/project_open/projects-${key}" +} + +_project_open_build_cache() { + local cache dir tmp root + root="$(_project_open_root)" + [[ -d "${root}" ]] || return 1 + cache="$(_project_open_cache_file)" + [[ -f "${cache}" ]] && return 0 + + dir="${cache%/*}" + mkdir -p "${dir}" 2>/dev/null || return 1 + tmp="$(mktemp "${dir}/projects.XXXXXX")" || return 1 + if _project_open_scan >"${tmp}" && mv -f "${tmp}" "${cache}"; then + return 0 + fi + rm -f "${tmp}" + return 1 +} + +_project_open_read_cache() { + local cache line + cache="$(_project_open_cache_file)" + [[ -f "${cache}" ]] || return 0 + while IFS= read -r line || [[ -n "${line}" ]]; do + printf '%s\n' "${line}" + done < "${cache}" +} + +po_refresh() { + local cache + cache="$(_project_open_cache_file)" + rm -f "${cache}" + _project_open_build_cache +} + +_project_open_resolve() { + local name="${1}" cache_only="${2}" cache line tmp + [[ -n "${name}" ]] || return 1 + cache="$(_project_open_cache_file)" + if [[ -f "${cache}" ]]; then + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; return 0; } + done < "${cache}" + fi + [[ -n "${cache_only}" ]] && return 1 + tmp="$(mktemp "${TMPDIR:-/tmp}/project_open_scan.XXXXXX")" || return 1 + _project_open_scan >"${tmp}" + # shellcheck disable=SC2094 + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; rm -f "${tmp}"; return 0; } + done < "${tmp}" + rm -f "${tmp}" + return 1 +} + project_open() { - WP_PATH="${HOME}/Code/wordpress" + CODE_PATH="$(_project_open_root)" if [ -z "${1}" ]; then - NEW_PATH="${WP_PATH}" + NEW_PATH="${CODE_PATH}" tmux setw automatic-rename else - NEW_PATH="${WP_PATH}/${1}" + NEW_PATH="$(_project_open_resolve "${1}")" + if [ -z "${NEW_PATH}" ]; then + printf 'No project named %s under %s\n' "${1}" "${CODE_PATH}" >&2 + return 1 + fi tmux rename-window "${1}" fi - if [ -n "${2}" ] && [[ "${2}" != "theme" ]]; then + if [ -n "${2}" ]; then # shellcheck disable=SC2164 cd "${NEW_PATH}/${2}" return @@ -292,23 +374,70 @@ project_open() { NEW_PATH="${NEW_PATH}/site" fi - if [ -z "${2}" ]; then - # shellcheck disable=SC2164 - cd "${NEW_PATH}" - return + # shellcheck disable=SC2164 + cd "${NEW_PATH}" +} +alias po='project_open' + +_project_open_projects() { + local proj + _project_open_read_cache | while IFS= read -r proj; do + printf '%s\n' "${proj##*/}" + done +} + +_project_open_targets() { + local name="${1}" project_path target_dir + [[ -n "${name}" ]] || return 0 + project_path="$(_project_open_resolve "${name}" cache_only)" + [[ -n "${project_path}" ]] || return 0 + find "${project_path}" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | + while IFS= read -r target_dir; do + printf '%s\n' "${target_dir##*/}" + done | + LC_ALL=C sort +} + +_project_open_complete_bash() { + local current previous candidate + current="${COMP_WORDS[COMP_CWORD]}" + COMPREPLY=() + + if (( COMP_CWORD == 1 )); then + while IFS= read -r candidate; do + [[ "${candidate}" == "${current}"* ]] && COMPREPLY+=("${candidate}") + done < <(_project_open_projects) + return 0 fi - # Find only themes using Composer. - THEMES=("${NEW_PATH}/web/app/themes/"*/composer.json) - THEME_PATH="${THEMES[1]//composer.json/}" - if [ -z "${THEME_PATH}" ]; then - echo "No theme available" >&2 - return 1 + if (( COMP_CWORD == 2 )); then + previous="${COMP_WORDS[COMP_CWORD-1]}" + while IFS= read -r candidate; do + [[ "${candidate}" == "${current}"* ]] && COMPREPLY+=("${candidate}") + done < <(_project_open_targets "${previous}") + return 0 fi - cd "${THEME_PATH}" || return + return 0 } -alias po='project_open' + +if [[ -n "${BASH_VERSION:-}" ]]; then + complete -F _project_open_complete_bash project_open + complete -F _project_open_complete_bash po + if [[ $- == *i* ]]; then + ( _project_open_build_cache & ) >/dev/null 2>&1 + fi +fi + +if [[ -n "${ZSH_VERSION:-}" ]]; then + # Completion is registered the canonical way via an autoloaded #compdef + # function on $fpath (shell/completions/_project_open). Here we only kick + # off the async cache build; eval defers the zsh-only `&!` past bash parsing. + if [[ -o interactive ]]; then + eval '_project_open_build_cache >/dev/null 2>&1 &!' + fi +fi + project_edit() { WP_PATH="${HOME}/Code/wordpress" diff --git a/shell/completions/_project_open b/shell/completions/_project_open new file mode 100644 index 0000000..c43dabd --- /dev/null +++ b/shell/completions/_project_open @@ -0,0 +1,25 @@ +#compdef project_open po + +# Autoloaded via $fpath so compinit (re)registers it on every run, surviving +# the repeated/deferred compinit calls in zshrc. Discovery helpers live in +# shell/aliases and are available in the interactive shell. + +# Succeed with no candidates if the discovery helpers (from shell/aliases) aren't +# loaded yet, so zsh doesn't treat this as a failed completer. +(( $+functions[_project_open_projects] )) || return 0 + +local -a candidates + +if (( CURRENT == 2 )); then + candidates=("${(@f)$(_project_open_projects)}") + compadd -- "${candidates[@]}" + return 0 +fi + +if (( CURRENT == 3 )); then + candidates=("${(@f)$(_project_open_targets "${words[CURRENT-1]}")}") + compadd -- "${candidates[@]}" + return 0 +fi + +return 0 diff --git a/shell/zshrc b/shell/zshrc index d8ac4ca..8ea413c 100644 --- a/shell/zshrc +++ b/shell/zshrc @@ -97,6 +97,9 @@ HISTSIZE=100000 SAVEHIST=10000 setopt SHARE_HISTORY +if (( ! ${fpath[(Ie)${HOME}/.dotfiles/shell/completions]} )); then + fpath=("${HOME}/.dotfiles/shell/completions" ${fpath}) +fi autoload -U compinit && compinit zmodload -i zsh/complist compdef __git_branch_names gpDo diff --git a/tests/bats b/tests/bats new file mode 160000 index 0000000..5a7db7a --- /dev/null +++ b/tests/bats @@ -0,0 +1 @@ +Subproject commit 5a7db7a98951d9d89b3b5e7800037e655a93345f diff --git a/tests/project_open.bats b/tests/project_open.bats new file mode 100644 index 0000000..03fb2c4 --- /dev/null +++ b/tests/project_open.bats @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Tests for project_open discovery/cache/resolution (shell/aliases). +# shellcheck disable=SC2030,SC2031 # BATS runs each test in a subshell by design + +load test_helper + +# tmux is irrelevant here; stub it to a no-op so sourcing/aliases stay quiet. +tmux() { return 0; } + +setup() { + ROOT="$(temp_make)" + export PROJECT_OPEN_ROOT="${ROOT}" + XDG_CACHE_HOME="$(temp_make)" + export XDG_CACHE_HOME + + # Projects sit at // and always hold a .git. + # wordpress projects carry a .git alongside their bedrock/site app dir; + # vcpkg is a nested submodule (deeper) that must be excluded from scans. + mkdir -p \ + "${ROOT}/mods/HoldFast/.git" \ + "${ROOT}/mods/HoldFast/lib" \ + "${ROOT}/mods/HoldFast/lib/vcpkg/.git" \ + "${ROOT}/misc/khuey/.git" \ + "${ROOT}/wordpress/acme/.git" \ + "${ROOT}/wordpress/acme/site" \ + "${ROOT}/wordpress/beta/.git" \ + "${ROOT}/wordpress/beta/bedrock" + + # shell/aliases sources ${HOME}/.dotfiles/shell/os.sh, so point HOME at a + # temp dir whose .dotfiles symlinks to this repo. That lets the suite run + # from any checkout path, not only ~/.dotfiles. + TEST_HOME="$(temp_make)" + ln -s "$(cd "${DOTFILES_DIR}" && pwd)" "${TEST_HOME}/.dotfiles" + export HOME="${TEST_HOME}" + + # shellcheck source=/dev/null + source "${HOME}/.dotfiles/shell/aliases" +} + +teardown() { + temp_del "${ROOT}" + temp_del "${XDG_CACHE_HOME}" + temp_del "${TEST_HOME}" +} + +@test "scan finds category/project dirs and excludes nested submodules" { + local out + out="$(_project_open_scan | sed "s#^${ROOT}/##" | LC_ALL=C sort | tr '\n' ',')" + assert_equal "${out}" "misc/khuey,mods/HoldFast,wordpress/acme,wordpress/beta," +} + +@test "po_refresh creates the cache file" { + po_refresh + assert_file_exist "$(_project_open_cache_file)" +} + +@test "build is a no-op when the cache already exists" { + po_refresh + mkdir -p "${ROOT}/mods/NewMod/.git" + _project_open_build_cache + run grep -q '/NewMod$' "$(_project_open_cache_file)" + assert_failure +} + +@test "po_refresh rebuilds and picks up new projects" { + po_refresh + mkdir -p "${ROOT}/mods/NewMod/.git" + po_refresh + run grep -q '/NewMod$' "$(_project_open_cache_file)" + assert_success +} + +@test "read_cache is empty when the cache is missing" { + rm -f "$(_project_open_cache_file)" + run _project_open_read_cache + assert_output '' +} + +@test "resolve falls back to a scan when the cache is missing" { + rm -f "$(_project_open_cache_file)" + run _project_open_resolve HoldFast + assert_output "${ROOT}/mods/HoldFast" +} + +@test "resolve cache_only returns nothing when the cache is missing" { + rm -f "$(_project_open_cache_file)" + run _project_open_resolve HoldFast cache_only + assert_output '' +} + +@test "no cache is built when the root is missing" { + export PROJECT_OPEN_ROOT="${ROOT}/missing" + local missing_cache + missing_cache="$(_project_open_cache_file)" + run _project_open_build_cache + assert_file_not_exist "${missing_cache}" +} + +@test "resolve maps a project name to its path" { + run _project_open_resolve HoldFast + assert_output "${ROOT}/mods/HoldFast" +} + +@test "resolve maps a wordpress project to its path" { + run _project_open_resolve acme + assert_output "${ROOT}/wordpress/acme" +} + +@test "resolve of an unknown name is empty" { + run _project_open_resolve nope + assert_output '' +} + +@test "projects lists project basenames" { + po_refresh + local out + out="$(_project_open_projects | LC_ALL=C sort | tr '\n' ',')" + assert_equal "${out}" "HoldFast,acme,beta,khuey," +} + +@test "targets lists a project's subdirs" { + po_refresh + run _project_open_targets HoldFast + assert_output "lib" +} + +@test "targets of a childless project is empty" { + po_refresh + run _project_open_targets khuey + assert_output '' +} + +@test "targets of an unknown project is empty" { + po_refresh + run _project_open_targets nope + assert_output '' +} + +@test "no-arg project_open cds to the root" { + po_refresh + local dir + dir="$(cd / && project_open >/dev/null 2>&1 && pwd)" + assert_equal "${dir}" "${ROOT}" +} + +@test "project_open cds into a plain project" { + po_refresh + local dir + dir="$(cd / && project_open HoldFast >/dev/null 2>&1 && pwd)" + assert_equal "${dir}" "${ROOT}/mods/HoldFast" +} + +@test "project_open descends into bedrock" { + po_refresh + local dir + dir="$(cd / && project_open beta >/dev/null 2>&1 && pwd)" + assert_equal "${dir}" "${ROOT}/wordpress/beta/bedrock" +} + +@test "project_open descends into site" { + po_refresh + local dir + dir="$(cd / && project_open acme >/dev/null 2>&1 && pwd)" + assert_equal "${dir}" "${ROOT}/wordpress/acme/site" +} + +@test "project_open cds into the subdir" { + po_refresh + local dir + dir="$(cd / && project_open HoldFast lib >/dev/null 2>&1 && pwd)" + assert_equal "${dir}" "${ROOT}/mods/HoldFast/lib" +} + +@test "project_open of an unknown project returns 1" { + po_refresh + local rc=0 + (cd / && project_open nope >/dev/null 2>&1) || rc=$? + assert_equal "${rc}" "1" +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..c861d12 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Common test helper for BATS tests. + +# GitHub Actions sets TERM=dumb, which makes tput error; force a sane value. +if [[ -z "${TERM:-}" || "${TERM}" == "dumb" ]]; then + export TERM="xterm" +fi + +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' +load 'test_helper/bats-file/load' + +# Repo root (tests/ lives directly under it). +DOTFILES_DIR="${BATS_TEST_DIRNAME}/.." diff --git a/tests/test_helper/bats-assert b/tests/test_helper/bats-assert new file mode 160000 index 0000000..697471b --- /dev/null +++ b/tests/test_helper/bats-assert @@ -0,0 +1 @@ +Subproject commit 697471b7a89d3ab38571f38c6c7c4b460d1f5e35 diff --git a/tests/test_helper/bats-file b/tests/test_helper/bats-file new file mode 160000 index 0000000..6bee58b --- /dev/null +++ b/tests/test_helper/bats-file @@ -0,0 +1 @@ +Subproject commit 6bee58bec7c2f4aed1a7425ccd4bdc42b4a84599 diff --git a/tests/test_helper/bats-support b/tests/test_helper/bats-support new file mode 160000 index 0000000..0954abb --- /dev/null +++ b/tests/test_helper/bats-support @@ -0,0 +1 @@ +Subproject commit 0954abb9925cad550424cebca2b99255d4eabe96