Skip to content

Commit 2b0f421

Browse files
authored
feat(shell): open and complete project_open projects anywhere under ~/Code (#204)
* feat(shell): add `project_open` autocomplete * fix(shell): hash-key cache, sort scan, return 0 from completion * refactor(shell): drop project_open theme target and redundant targets guard * refactor(shell): scan projects in a single find pass instead of recursive find * refactor(shell): scan projects at fixed category/project depth via .git * fix(shell): drop dead project_open_scan argument flagged by shellcheck * fix(shell): sort project_open targets and return success from completion guard * chore(test): convert project_open suite to bats and run it in ci
1 parent 51cbaf5 commit 2b0f421

11 files changed

Lines changed: 396 additions & 16 deletions

File tree

.github/workflows/lint.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ jobs:
1212
uses: actions/checkout@v3
1313
- name: Run Shellcheck
1414
uses: azohra/shell-linter@latest
15+
with:
16+
# zsh completion functions (shell/completions) use zsh-only syntax
17+
# ShellCheck cannot parse; tests/ holds .bats files (non-bash @test
18+
# syntax) and third-party BATS submodules. ShellCheck skips both.
19+
exclude-paths: "shell/completions,tests"
20+
21+
test:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v3
25+
with:
26+
submodules: recursive
27+
- name: Run BATS tests
28+
run: ./tests/bats/bin/bats tests/*.bats
1529

1630
lua:
1731
runs-on: ubuntu-latest

.gitmodules

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[submodule "tests/bats"]
2+
path = tests/bats
3+
url = https://github.com/bats-core/bats-core.git
4+
[submodule "tests/test_helper/bats-support"]
5+
path = tests/test_helper/bats-support
6+
url = https://github.com/bats-core/bats-support.git
7+
[submodule "tests/test_helper/bats-assert"]
8+
path = tests/test_helper/bats-assert
9+
url = https://github.com/bats-core/bats-assert.git
10+
[submodule "tests/test_helper/bats-file"]
11+
path = tests/test_helper/bats-file
12+
url = https://github.com/bats-core/bats-file.git

shell/aliases

Lines changed: 145 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -268,19 +268,101 @@ delete_terms() {
268268
set_posts_statuses() {
269269
wp post update "$(wp post list --post_type="${1}" --post_status="${2}" --format=ids)" --post_status="${3}"
270270
}
271+
_project_open_root() {
272+
printf '%s\n' "${PROJECT_OPEN_ROOT:-${HOME}/Code}"
273+
}
274+
275+
_project_open_scan() {
276+
local root
277+
root="$(_project_open_root)"
278+
# Projects live at <root>/<category>/<project> and always hold a .git, so one
279+
# find at that fixed depth lists them; submodules sit deeper and fall out.
280+
find "${root}" -mindepth 3 -maxdepth 3 -name '.git' 2>/dev/null |
281+
sed 's#/\.git$##' |
282+
LC_ALL=C sort
283+
}
284+
285+
_project_open_cache_file() {
286+
# Key the cache by a hash of the root so a different PROJECT_OPEN_ROOT never
287+
# reads a stale cache. Hashing (not char substitution) avoids path collisions;
288+
# trailing slashes are stripped so "/x" and "/x/" share one cache.
289+
local root key
290+
root="$(_project_open_root)"
291+
root="${root%/}"
292+
key="$(printf '%s' "${root}" | cksum | cut -d' ' -f1)"
293+
printf '%s\n' "${XDG_CACHE_HOME:-${HOME}/.cache}/project_open/projects-${key}"
294+
}
295+
296+
_project_open_build_cache() {
297+
local cache dir tmp root
298+
root="$(_project_open_root)"
299+
[[ -d "${root}" ]] || return 1
300+
cache="$(_project_open_cache_file)"
301+
[[ -f "${cache}" ]] && return 0
302+
303+
dir="${cache%/*}"
304+
mkdir -p "${dir}" 2>/dev/null || return 1
305+
tmp="$(mktemp "${dir}/projects.XXXXXX")" || return 1
306+
if _project_open_scan >"${tmp}" && mv -f "${tmp}" "${cache}"; then
307+
return 0
308+
fi
309+
rm -f "${tmp}"
310+
return 1
311+
}
312+
313+
_project_open_read_cache() {
314+
local cache line
315+
cache="$(_project_open_cache_file)"
316+
[[ -f "${cache}" ]] || return 0
317+
while IFS= read -r line || [[ -n "${line}" ]]; do
318+
printf '%s\n' "${line}"
319+
done < "${cache}"
320+
}
321+
322+
po_refresh() {
323+
local cache
324+
cache="$(_project_open_cache_file)"
325+
rm -f "${cache}"
326+
_project_open_build_cache
327+
}
328+
329+
_project_open_resolve() {
330+
local name="${1}" cache_only="${2}" cache line tmp
331+
[[ -n "${name}" ]] || return 1
332+
cache="$(_project_open_cache_file)"
333+
if [[ -f "${cache}" ]]; then
334+
while IFS= read -r line || [[ -n "${line}" ]]; do
335+
[[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; return 0; }
336+
done < "${cache}"
337+
fi
338+
[[ -n "${cache_only}" ]] && return 1
339+
tmp="$(mktemp "${TMPDIR:-/tmp}/project_open_scan.XXXXXX")" || return 1
340+
_project_open_scan >"${tmp}"
341+
# shellcheck disable=SC2094
342+
while IFS= read -r line || [[ -n "${line}" ]]; do
343+
[[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; rm -f "${tmp}"; return 0; }
344+
done < "${tmp}"
345+
rm -f "${tmp}"
346+
return 1
347+
}
348+
271349
project_open() {
272-
WP_PATH="${HOME}/Code/wordpress"
350+
CODE_PATH="$(_project_open_root)"
273351

274352
if [ -z "${1}" ]; then
275-
NEW_PATH="${WP_PATH}"
353+
NEW_PATH="${CODE_PATH}"
276354

277355
tmux setw automatic-rename
278356
else
279-
NEW_PATH="${WP_PATH}/${1}"
357+
NEW_PATH="$(_project_open_resolve "${1}")"
358+
if [ -z "${NEW_PATH}" ]; then
359+
printf 'No project named %s under %s\n' "${1}" "${CODE_PATH}" >&2
360+
return 1
361+
fi
280362
tmux rename-window "${1}"
281363
fi
282364

283-
if [ -n "${2}" ] && [[ "${2}" != "theme" ]]; then
365+
if [ -n "${2}" ]; then
284366
# shellcheck disable=SC2164
285367
cd "${NEW_PATH}/${2}"
286368
return
@@ -292,23 +374,70 @@ project_open() {
292374
NEW_PATH="${NEW_PATH}/site"
293375
fi
294376

295-
if [ -z "${2}" ]; then
296-
# shellcheck disable=SC2164
297-
cd "${NEW_PATH}"
298-
return
377+
# shellcheck disable=SC2164
378+
cd "${NEW_PATH}"
379+
}
380+
alias po='project_open'
381+
382+
_project_open_projects() {
383+
local proj
384+
_project_open_read_cache | while IFS= read -r proj; do
385+
printf '%s\n' "${proj##*/}"
386+
done
387+
}
388+
389+
_project_open_targets() {
390+
local name="${1}" project_path target_dir
391+
[[ -n "${name}" ]] || return 0
392+
project_path="$(_project_open_resolve "${name}" cache_only)"
393+
[[ -n "${project_path}" ]] || return 0
394+
find "${project_path}" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null |
395+
while IFS= read -r target_dir; do
396+
printf '%s\n' "${target_dir##*/}"
397+
done |
398+
LC_ALL=C sort
399+
}
400+
401+
_project_open_complete_bash() {
402+
local current previous candidate
403+
current="${COMP_WORDS[COMP_CWORD]}"
404+
COMPREPLY=()
405+
406+
if (( COMP_CWORD == 1 )); then
407+
while IFS= read -r candidate; do
408+
[[ "${candidate}" == "${current}"* ]] && COMPREPLY+=("${candidate}")
409+
done < <(_project_open_projects)
410+
return 0
299411
fi
300412

301-
# Find only themes using Composer.
302-
THEMES=("${NEW_PATH}/web/app/themes/"*/composer.json)
303-
THEME_PATH="${THEMES[1]//composer.json/}"
304-
if [ -z "${THEME_PATH}" ]; then
305-
echo "No theme available" >&2
306-
return 1
413+
if (( COMP_CWORD == 2 )); then
414+
previous="${COMP_WORDS[COMP_CWORD-1]}"
415+
while IFS= read -r candidate; do
416+
[[ "${candidate}" == "${current}"* ]] && COMPREPLY+=("${candidate}")
417+
done < <(_project_open_targets "${previous}")
418+
return 0
307419
fi
308420

309-
cd "${THEME_PATH}" || return
421+
return 0
310422
}
311-
alias po='project_open'
423+
424+
if [[ -n "${BASH_VERSION:-}" ]]; then
425+
complete -F _project_open_complete_bash project_open
426+
complete -F _project_open_complete_bash po
427+
if [[ $- == *i* ]]; then
428+
( _project_open_build_cache & ) >/dev/null 2>&1
429+
fi
430+
fi
431+
432+
if [[ -n "${ZSH_VERSION:-}" ]]; then
433+
# Completion is registered the canonical way via an autoloaded #compdef
434+
# function on $fpath (shell/completions/_project_open). Here we only kick
435+
# off the async cache build; eval defers the zsh-only `&!` past bash parsing.
436+
if [[ -o interactive ]]; then
437+
eval '_project_open_build_cache >/dev/null 2>&1 &!'
438+
fi
439+
fi
440+
312441
project_edit() {
313442
WP_PATH="${HOME}/Code/wordpress"
314443

shell/completions/_project_open

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#compdef project_open po
2+
3+
# Autoloaded via $fpath so compinit (re)registers it on every run, surviving
4+
# the repeated/deferred compinit calls in zshrc. Discovery helpers live in
5+
# shell/aliases and are available in the interactive shell.
6+
7+
# Succeed with no candidates if the discovery helpers (from shell/aliases) aren't
8+
# loaded yet, so zsh doesn't treat this as a failed completer.
9+
(( $+functions[_project_open_projects] )) || return 0
10+
11+
local -a candidates
12+
13+
if (( CURRENT == 2 )); then
14+
candidates=("${(@f)$(_project_open_projects)}")
15+
compadd -- "${candidates[@]}"
16+
return 0
17+
fi
18+
19+
if (( CURRENT == 3 )); then
20+
candidates=("${(@f)$(_project_open_targets "${words[CURRENT-1]}")}")
21+
compadd -- "${candidates[@]}"
22+
return 0
23+
fi
24+
25+
return 0

shell/zshrc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ HISTSIZE=100000
9797
SAVEHIST=10000
9898
setopt SHARE_HISTORY
9999

100+
if (( ! ${fpath[(Ie)${HOME}/.dotfiles/shell/completions]} )); then
101+
fpath=("${HOME}/.dotfiles/shell/completions" ${fpath})
102+
fi
100103
autoload -U compinit && compinit
101104
zmodload -i zsh/complist
102105
compdef __git_branch_names gpDo

tests/bats

Submodule bats added at 5a7db7a

0 commit comments

Comments
 (0)