From c8425228003afc27cff214856d92b35c12f93f71 Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Tue, 19 May 2026 10:17:50 +0200 Subject: [PATCH] Overhaul zsh completion: caching, global flags, --flag=value, test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caching ------- Replace the previous flat-file cache (target/.dbt_completion_cache.txt) with zsh's native _retrieve_cache / _store_cache, enabled via: zstyle ':completion:*:complete:dbt:*' use-cache yes Cache keys are composed of two parts to prevent collisions: path_hash — cksum of the manifest / binary path, so two projects whose manifests have the same mtime (compiled in the same second) don't share entries. mtime — ensures the cache is invalidated whenever the file changes. Three cache namespaces are used: dbt_models__ — model/tag/source selectors dbt_selectors__ — YAML named selectors dbt_click__ — Click completion responses, keyed by a hash of the normalised context string so many real command lines collapse onto a small number of cache entries Click context normalisation --------------------------- Prior flags are stripped from COMP_WORDS before calling Click because Click returns the same flag list regardless of which flags have already been given. This means "dbt run --project-dir /foo --" and "dbt run --" share one cache entry ("dbt run -"), with the real partial filtered locally. Exception: when global flags precede the subcommand (e.g. "dbt --profiles-dir /p run --") we cannot safely strip them because we don't know which take a value argument. In that case the full word sequence is passed to Click verbatim. The current word is still normalised to "-" for flag-name completions so the same global-flag prefix shares one cache entry. Other fixes ----------- - --flag=value style (used by Fusion / clap): compset -P '*=' strips the prefix so model/selector completions work for --select= etc. - Click multi-line description parser: fixed stride-3 loop that broke alignment when a description spanned multiple lines (--warn-error-options). - Manifest path helper (_dbt_manifest_path) extracted to reduce duplication. Test suite ---------- Add test.zsh: 72 unit tests covering all pure-logic functions without requiring a live dbt binary or a real completion context. Mocks stub out _describe, compadd, _files, compset, _retrieve_cache, and _store_cache. --- _dbt | 479 +++++++++++++++++++++++++++++++++++++------------------ test.zsh | 467 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 794 insertions(+), 152 deletions(-) create mode 100644 test.zsh diff --git a/_dbt b/_dbt index a77f18a..b7eafc2 100644 --- a/_dbt +++ b/_dbt @@ -1,5 +1,8 @@ #compdef dbt dbtf +# Enable zsh's native completion cache for all dbt completions. +zstyle ':completion:*:complete:dbt:*' use-cache yes + # OVERVIEW # Adds autocompletion to dbt CLI by: # 1. Auto-detecting whether dbt is dbt-core (Click) or dbt-fusion (clap) @@ -21,19 +24,17 @@ # Brand new models/sources/tags/packages will not be displayed in the tab # complete menu until they are compiled and appear in the manifest. # -# # CREDITS # Leveraging a lot of logic from dbt-completion.bash: # https://github.com/fishtown-analytics/dbt-completion.bash/blob/master/dbt-completion.bash # -# Inspired by zsh-completions -# https://github.com/zsh-users/zsh-completions -# and particularly https://github.com/zsh-users/zsh-completions/blob/40c6c768eabfa49d54a149149f338e23ee6dd83b/src/_ufw +# Inspired by zsh-completions +# https://github.com/zsh-users/zsh-completions +# ─── manifest parsing ──────────────────────────────────────────────────────── -# Inline a python script so we can deploy this as a single file -# the idea of doing this in bash natively is... daunting +# Inline a python script so we can deploy this as a single file. _parse_manifest() { manifest_path=$1 prefix=$2 @@ -163,140 +164,139 @@ cat "$manifest_path" | python3 -c "$prog" } -# Check if DBT_PROJECT_DIR is set and not empty -# Otherwise, walk up the filesystem until we find a dbt_project.yml file, -# then return the path which contains it (if found) +# Walk up the filesystem to find the dbt project root (directory containing +# dbt_project.yml). Respects $DBT_PROJECT_DIR if set. _get_project_root() { - if [ -n "$DBT_PROJECT_DIR" ]; then - echo "$DBT_PROJECT_DIR" && return - fi - - slashes=${PWD//[^\/]/} - directory="$PWD" - for (( n=${#slashes}; n>0; --n )) - do - test -e "$directory/dbt_project.yml" && echo "$directory" && return - directory="$directory/.." - done -} + if [[ -n "${DBT_PROJECT_DIR-}" ]]; then + echo "$DBT_PROJECT_DIR" && return + fi + local slashes="${PWD//[^\/]/}" + local directory="$PWD" + for (( n=${#slashes}; n>0; --n )); do + test -e "$directory/dbt_project.yml" && echo "$directory" && return + directory="$directory/.." + done +} -# Lists the difference models, extracted from manifest.json -_dbt_list_models() { +# Resolves the manifest path from $DBT_MANIFEST_PATH or the project root. +# Prints the path to stdout; returns 1 if the file does not exist. +_dbt_manifest_path() { + local project_dir project_dir="$(_get_project_root)" + local path="${DBT_MANIFEST_PATH:-${project_dir}/target/manifest.json}" + [[ -f "$path" ]] || return 1 + printf '%s' "$path" +} - # Attempt to fetch the manifest path from the environment variable - if [ -z "$DBT_MANIFEST_PATH" ] ; then - manifest_path="${project_dir}/target/manifest.json" - else - manifest_path="$DBT_MANIFEST_PATH" - fi - if [ ! -f "$manifest_path" ] ; then - return - fi +# Lists model selectors extracted from manifest.json. +# Cache key = path_hash + mtime: mtime alone would collide across projects that +# happened to compile their manifest at the same second. +_dbt_list_models() { + local manifest_path + manifest_path=$(_dbt_manifest_path) || return - # Cache the base model list in target/.dbt_completion_cache.txt (keyed by - # manifest mtime) so Python is only invoked when the manifest changes. - local cache_file="${manifest_path:h}/.dbt_completion_cache.txt" - local key_file="${manifest_path:h}/.dbt_completion_cache.key" - local manifest_key - manifest_key=$(stat -f %m "$manifest_path" 2>/dev/null || stat -c %Y "$manifest_path" 2>/dev/null) + local manifest_mtime path_hash + manifest_mtime=$(stat -f %m "$manifest_path" 2>/dev/null || stat -c %Y "$manifest_path" 2>/dev/null) + path_hash=$(printf '%s' "$manifest_path" | cksum); path_hash="${path_hash%% *}" - if [[ ! -f "$cache_file" ]] || [[ "$(cat "$key_file" 2>/dev/null)" != "$manifest_key" ]]; then - _parse_manifest "$manifest_path" "" > "$cache_file" - printf '%s' "$manifest_key" > "$key_file" + typeset -ga _dbt_models_cache + if ! _retrieve_cache "dbt_models_${path_hash}_${manifest_mtime}"; then + _dbt_models_cache=( ${=${(f)"$(_parse_manifest "$manifest_path" "")"}} ) + _store_cache "dbt_models_${path_hash}_${manifest_mtime}" _dbt_models_cache fi - local first_letter - first_letter=${words[-1]:0:1} - - local models_list - if [ "$first_letter" = "+" ] || [ "$first_letter" = "@" ]; then - models_list=( $(sed "s/^/${first_letter}/" "$cache_file") ) + # $PREFIX is set by compset after stripping a --flag= prefix; fall back to + # the raw current word so + / @ pass-through prefixes work in both styles. + local _pfx="${PREFIX-${words[-1]}}" + local first_letter="${_pfx:0:1}" + local -a models_list + if [[ "$first_letter" == "+" || "$first_letter" == "@" ]]; then + models_list=( "${_dbt_models_cache[@]/#/${first_letter}}" ) else - models_list=( $(cat "$cache_file") ) + models_list=( "${_dbt_models_cache[@]}" ) fi _values -s , 'models' $models_list } -# Lists the different selectors, extracted from manifest.json +# Lists YAML selectors extracted from manifest.json. +# Same path_hash + mtime cache key as _dbt_list_models to avoid cross-project +# collisions. _dbt_list_selectors() { + local manifest_path + manifest_path=$(_dbt_manifest_path) || return - project_dir="$(_get_project_root)" - - # Attempt to fetch the manifest path from the environment variable - if [ -z "$DBT_MANIFEST_PATH" ] ; then - manifest_path="${project_dir}/target/manifest.json" - else - manifest_path="$DBT_MANIFEST_PATH" - fi - - if [ ! -f "$manifest_path" ] ; then - return - fi - - local cache_file="${manifest_path:h}/.dbt_completion_cache_selectors.txt" - local key_file="${manifest_path:h}/.dbt_completion_cache_selectors.key" - local manifest_key - manifest_key=$(stat -f %m "$manifest_path" 2>/dev/null || stat -c %Y "$manifest_path" 2>/dev/null) + local manifest_mtime path_hash + manifest_mtime=$(stat -f %m "$manifest_path" 2>/dev/null || stat -c %Y "$manifest_path" 2>/dev/null) + path_hash=$(printf '%s' "$manifest_path" | cksum); path_hash="${path_hash%% *}" - if [[ ! -f "$cache_file" ]] || [[ "$(cat "$key_file" 2>/dev/null)" != "$manifest_key" ]]; then - _parse_selectors "$manifest_path" > "$cache_file" - printf '%s' "$manifest_key" > "$key_file" + typeset -ga _dbt_selectors_cache + if ! _retrieve_cache "dbt_selectors_${path_hash}_${manifest_mtime}"; then + _dbt_selectors_cache=( ${=${(f)"$(_parse_selectors "$manifest_path")"}} ) + _store_cache "dbt_selectors_${path_hash}_${manifest_mtime}" _dbt_selectors_cache fi - local selectors_list=( $(cat "$cache_file") ) - if [ "$selectors_list" != "" ]; then - _values -s , 'selectors' $selectors_list - fi + (( ${#_dbt_selectors_cache[@]} > 0 )) && _values -s , 'selectors' "${_dbt_selectors_cache[@]}" } -# Detect whether the dbt binary is dbt-fusion or dbt-core. -# Result is cached (keyed by binary path + mtime) to avoid running --help on every tab press. -_dbt_detect_type() { - local bin_path - bin_path=$(command -v dbt) || return +# ─── binary info ───────────────────────────────────────────────────────────── - local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}" - local type_cache="$cache_dir/dbt_binary_type" - local key_cache="$cache_dir/dbt_binary_key" - local current_key="${bin_path}:$(stat -f %m "$bin_path" 2>/dev/null)" +# Resolves the dbt binary path and mtime. +# reply=(bin_path bin_mtime). Returns 1 if dbt is not in PATH. +_dbt_bin_info() { + local bin_path + bin_path=$(command -v dbt) || return 1 + local bin_mtime + bin_mtime=$(stat -f %m "$bin_path" 2>/dev/null || stat -c %Y "$bin_path" 2>/dev/null) + reply=("$bin_path" "$bin_mtime") +} - if [[ -f "$type_cache" ]] && [[ "$(cat "$key_cache" 2>/dev/null)" == "$current_key" ]]; then - cat "$type_cache" - return - fi - local first_line - first_line=$(dbt --help 2>/dev/null | head -1) +# ─── type detection ────────────────────────────────────────────────────────── - local type="core" - if [[ "$first_line" == *"dbt-fusion"* ]]; then - type="fusion" +# Detects whether dbt is dbt-core (click) or dbt-fusion (clap). +# Prints "core" or "fusion". Cached by binary path + mtime. +_dbt_detect_type() { + _dbt_bin_info || return 1 + local bin_path="${reply[1]}" bin_mtime="${reply[2]}" + + local path_hash + path_hash=$(printf '%s' "$bin_path" | cksum) + path_hash="${path_hash%% *}" + + typeset -g _dbt_binary_type_cache + if ! _retrieve_cache "dbt_binary_type_${path_hash}_${bin_mtime}"; then + local first_line + first_line=$(dbt --help 2>/dev/null | head -1) + _dbt_binary_type_cache="core" + [[ "$first_line" == *"dbt-fusion"* ]] && _dbt_binary_type_cache="fusion" + _store_cache "dbt_binary_type_${path_hash}_${bin_mtime}" _dbt_binary_type_cache fi - mkdir -p "$cache_dir" - printf '%s' "$type" > "$type_cache" - printf '%s' "$current_key" > "$key_cache" - printf '%s' "$type" + printf '%s' "$_dbt_binary_type_cache" } -# For dbt-fusion: source the clap_complete-generated zsh script (cached by binary mtime), -# then delegate to the _dbt function it defines. +# ─── dbt-fusion ────────────────────────────────────────────────────────────── + +# Sources the clap_complete-generated zsh script (cached by binary mtime) and +# delegates to the _dbt_clap function it defines. # Accepts an optional explicit binary path (used when completing the dbtf alias). _dbt_fusion_complete() { local bin_path="${1:-$(command -v dbt)}" [[ -z "$bin_path" ]] && return 1 + local bin_mtime + bin_mtime=$(stat -f %m "$bin_path" 2>/dev/null || stat -c %Y "$bin_path" 2>/dev/null) + local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}" local script_cache="$cache_dir/dbt_fusion_completions.zsh" local key_cache="$cache_dir/dbt_fusion_completions.key" - local current_key="${bin_path}:$(stat -f %m "$bin_path" 2>/dev/null)" + local current_key="${bin_path}:${bin_mtime}" if [[ ! -f "$script_cache" ]] || [[ "$(cat "$key_cache" 2>/dev/null)" != "$current_key" ]]; then mkdir -p "$cache_dir" @@ -304,8 +304,8 @@ _dbt_fusion_complete() { printf '%s' "$current_key" > "$key_cache" fi - # Source the clap-generated script; it redefines _dbt with clap's completion logic. - # Rename it to _dbt_clap so it doesn't clobber this function, then call it. + # Source the clap-generated script; rename its _dbt() to _dbt_clap() so it + # doesn't clobber this completion function, then call it. local script_content script_content=$(sed 's/^_dbt()/_dbt_clap()/' "$script_cache") eval "$script_content" @@ -313,64 +313,244 @@ _dbt_fusion_complete() { } -# For dbt-core: Click runtime completion. -# Note: spawns a Python process per tab press, so inherently slower than dbt-fusion. -_dbt_core_complete() { +# ─── dbt-core (click) ──────────────────────────────────────────────────────── +# +# Completion for dbt-core is cached at the level of the normalised completion +# context, not the full command line. Prior flags are stripped from COMP_WORDS +# before calling click because click returns the same flag list regardless of +# which flags have already been given. This collapses many real command lines +# onto a small set of cache entries: +# +# Flag-name completion (current word starts with "-"): +# Normalised COMP_WORDS = " -" +# e.g. "dbt run --" and "dbt run --project-dir /foo --" both +# normalise to "dbt run -", sharing one cache entry. The real partial is +# filtered locally after the cache is read. +# +# Flag-value completion (prev word is a flag, e.g. "--log-format te"): +# Normalised COMP_WORDS = " " +# The flag name is included so click returns the right choices. +# +# Subcommand / positional completion: +# Normalised COMP_WORDS = " " + + +# Extracts the subcommand path (non-flag words) from $words[1..CURRENT-1]. +# reply=(sub_context sub_cword has_global_flags) +# +# has_global_flags is 1 when words[2] (the word immediately after "dbt") starts +# with "-", meaning the user typed at least one global flag before the subcommand, +# e.g. "dbt --profiles-dir /p run --". The caller uses this to decide whether +# to normalise the context (safe when no global flags) or pass the full word +# sequence to Click (necessary when global flags are present, because we cannot +# determine which flags take a value without running dbt). +_dbt_core_subcommand_path() { + local -a sub_words + local w + for w in "${words[1,CURRENT-1][@]}"; do + [[ "$w" == -* ]] && break + sub_words+=("$w") + done + # words[2] is a reliable sentinel: if CURRENT > 2 and it starts with -, + # a global flag precedes the subcommand. We check CURRENT > 2 to avoid + # treating the current (still-being-typed) word as a committed global flag. + local has_global_flags=0 + (( CURRENT > 2 )) && [[ "${words[2]-}" == -* ]] && has_global_flags=1 + reply=("${(j: :)sub_words}" "${#sub_words}" "$has_global_flags") +} + + +# Builds the normalised COMP_WORDS / COMP_CWORD to pass to click, plus an +# optional local filter word (used when the partial was normalised away). +# Args: current_word prev_word sub_context sub_cword +# reply=(comp_words_str comp_cword filter_word) +_dbt_core_build_comp_context() { + local current_word="$1" prev_word="$2" sub_context="$3" sub_cword="$4" + local comp_words_str comp_cword filter_word="" + + if [[ "$prev_word" == -* ]]; then + # Flag-value: include the flag so click returns the right choices. + comp_words_str="${sub_context} ${prev_word} ${current_word}" + comp_cword=$(( sub_cword + 1 )) + elif [[ "$current_word" == -* ]]; then + # Flag-name: normalise to "-" so all flags share one cache entry. + comp_words_str="${sub_context} -" + comp_cword=${sub_cword} + filter_word="$current_word" + else + # Subcommand or positional argument. + comp_words_str="${sub_context} ${current_word}" + comp_cword=${sub_cword} + fi + + reply=("$comp_words_str" "$comp_cword" "$filter_word") +} + + +# Invokes click to fetch completions for the given normalised context. +# Uses zsh's native _retrieve_cache / _store_cache keyed by binary mtime + +# context hash. Sets the global variable $_dbt_response. +# Args: bin_mtime comp_words_str comp_cword +_dbt_core_fetch_completions() { + local bin_mtime="$1" comp_words_str="$2" comp_cword="$3" + + local ctx_hash + ctx_hash=$(printf '%s' "$comp_words_str" | cksum) + ctx_hash="${ctx_hash%% *}" + + typeset -g _dbt_response + if ! _retrieve_cache "dbt_click_${bin_mtime}_${ctx_hash}"; then + _dbt_response=$(env COMP_WORDS="$comp_words_str" COMP_CWORD="$comp_cword" \ + _DBT_COMPLETE=zsh_complete dbt 2>/dev/null) + _store_cache "dbt_click_${bin_mtime}_${ctx_hash}" _dbt_response + fi +} + + +# Parses click's line-triplet response (type / value / description) and +# presents completions to zsh. Applies filter_word as a prefix filter when set +# (used for flag-name completions that were normalised to "-"). +# +# Click occasionally emits multi-line descriptions (e.g. --warn-error-options), +# which breaks a fixed stride-3 loop. Instead, we scan forward to the next type +# marker so multi-line descriptions are automatically consumed. +# +# Args: response filter_word +_dbt_core_present_completions() { + local response="$1" filter_word="$2" + [[ -z "$response" ]] && return 1 + local ret=1 - local IFS=$'\n' - local response - - # COMP_CWORD in Click is 0-indexed for the position we're completing - response=$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT - 1)) _DBT_COMPLETE=zsh_complete dbt 2>/dev/null) - - if [[ -n "$response" ]]; then - local lines=("${(@f)response}") - local i=1 - local -a completions descriptions - - while (( i <= ${#lines[@]} )); do - local comp_type="${lines[i]}" - local comp_value="${lines[i+1]}" - local comp_desc="${lines[i+2]}" - - if [[ $comp_type == 'dir' ]]; then - _files -/ && ret=0 - elif [[ $comp_type == 'file' ]]; then - _files && ret=0 - elif [[ $comp_type == 'plain' ]]; then - if [[ -n "$comp_desc" ]]; then - descriptions+=("$comp_value:$comp_desc") - else - completions+=("$comp_value") - fi - ret=0 - fi + local -a lines + lines=("${(@f)response}") + local n=${#lines[@]} i=1 + local -a completions descriptions + + while (( i <= n )); do + local comp_type="${lines[i]}" + + # Skip lines that are not a known type marker (handles mid-description lines + # after a multi-line description shifted the alignment). + if [[ "$comp_type" != "plain" && "$comp_type" != "file" && "$comp_type" != "dir" ]]; then + i=$(( i + 1 )) + continue + fi - i=$((i + 3)) + # Guard against a truncated last triplet (e.g. trailing newlines stripped + # by command substitution). + local comp_value="" comp_desc="" + (( i + 1 <= n )) && comp_value="${lines[i+1]}" + (( i + 2 <= n )) && comp_desc="${lines[i+2]}" + + # Advance to the next type marker, consuming any extra description lines. + local next_i=$(( i + 3 )) + while (( next_i <= n )) && \ + [[ "${lines[next_i]}" != "plain" && "${lines[next_i]}" != "file" && "${lines[next_i]}" != "dir" ]]; do + next_i=$(( next_i + 1 )) done - if (( ${#descriptions[@]} > 0 )); then - _describe 'dbt commands' descriptions - elif (( ${#completions[@]} > 0 )); then - compadd -U -a completions + case "$comp_type" in + dir) _files -/ && ret=0 ;; + file) _files && ret=0 ;; + plain) + if [[ -z "$filter_word" ]] || [[ "$comp_value" == ${filter_word}* ]]; then + if [[ -n "$comp_desc" ]]; then + descriptions+=("${comp_value}:${comp_desc}") + else + completions+=("$comp_value") + fi + ret=0 + fi + ;; + esac + + i=$next_i + done + + (( ${#descriptions[@]} > 0 )) && _describe 'dbt completions' descriptions + (( ${#completions[@]} > 0 )) && compadd -U -a completions + return ret +} + + +# Orchestrates dbt-core (click) tab completion. +# +# Two context-building paths depending on whether global flags precede the subcommand: +# +# Normal path (no global flags before subcommand, e.g. "dbt run --"): +# Strip prior flags from COMP_WORDS so completions for the same subcommand +# share a single cache entry regardless of which flags have already been given. +# +# Global-flag path (e.g. "dbt --profiles-dir /p run --"): +# We cannot safely strip global flags because we don't know which take a value. +# Pass the full word sequence to Click verbatim. One cache entry per unique +# global-flag combination — acceptable because that combination is stable within +# a project session. +_dbt_core_complete() { + _dbt_bin_info || return 1 + local bin_mtime="${reply[2]}" + + local current_word="${words[CURRENT]}" + local prev_word="" + (( CURRENT > 1 )) && prev_word="${words[CURRENT-1]}" + + _dbt_core_subcommand_path + local sub_context="${reply[1]}" sub_cword="${reply[2]}" has_global_flags="${reply[3]}" + + local comp_words_str comp_cword filter_word + if (( has_global_flags )); then + # Full-context path: preserve every committed word so Click parses + # global flags correctly. Still normalise the current word when it is a + # flag name so all flag-name completions for this global-flag prefix + # share one cache entry. + if [[ "$current_word" == -* ]]; then + comp_words_str="${(j: :)words[1,CURRENT-1]} -" + filter_word="$current_word" + else + comp_words_str="${(j: :)words[1,CURRENT]}" + filter_word="" fi + comp_cword=$(( CURRENT - 1 )) + else + # Common path: normalise away already-provided flags so all completions + # for the same subcommand share one cache entry. + _dbt_core_build_comp_context "$current_word" "$prev_word" "$sub_context" "$sub_cword" + comp_words_str="${reply[1]}" comp_cword="${reply[2]}" filter_word="${reply[3]}" fi - return ret + _dbt_core_fetch_completions "$bin_mtime" "$comp_words_str" "$comp_cword" + _dbt_core_present_completions "$_dbt_response" "$filter_word" } -# Main function -# Auto-detects dbt-core vs dbt-fusion and routes to the appropriate completion backend. -# For the dbtf alias (always Fusion), the binary path is resolved from the alias value. -# Manifest-based model/selector completions are applied for both. +# ─── main ───────────────────────────────────────────────────────────────────── + +# Auto-detects dbt-core vs dbt-fusion and routes to the appropriate completion +# backend. Manifest-based model/selector completions are applied for both. +# For the dbtf alias (always Fusion), the binary is resolved from the alias. _dbt() { - local ret=1 + # Ensure the cache directory exists so _store_cache/_retrieve_cache work. + local _dbt_cache_dir="${ZDOTDIR:-$HOME}/.zcompcache" + [[ -d "$_dbt_cache_dir" ]] || mkdir -p "$_dbt_cache_dir" - # Manifest-based model/selector completions apply regardless of binary type + local current_word="${words[CURRENT]}" local prev_word="" - if (( CURRENT > 1 )); then - prev_word="${words[CURRENT-1]}" + (( CURRENT > 1 )) && prev_word="${words[CURRENT-1]}" + + # --flag=value style (clap/Fusion and Click both accept this form) + if [[ "$current_word" == *=* ]]; then + case "${current_word%%=*}" in + -s|--select|-m|--model|--models|--exclude) + compset -P '*=' + _dbt_list_models + return $? + ;; + --selector) + compset -P '*=' + _dbt_list_selectors + return $? + ;; + esac fi case "$prev_word" in @@ -384,16 +564,12 @@ _dbt() { ;; esac - # dbtf is always Fusion; resolve the binary from the alias value directly if [[ "$service" == "dbtf" ]]; then - local dbtf_bin="${aliases[dbtf]}" - _dbt_fusion_complete "$dbtf_bin" + _dbt_fusion_complete "${aliases[dbtf]}" return $? fi - if ! command -v dbt &> /dev/null; then - return 1 - fi + command -v dbt &>/dev/null || return 1 local dbt_type dbt_type=$(_dbt_detect_type) @@ -403,7 +579,6 @@ _dbt() { else _dbt_core_complete fi - return $? } _dbt diff --git a/test.zsh b/test.zsh new file mode 100644 index 0000000..21b8781 --- /dev/null +++ b/test.zsh @@ -0,0 +1,467 @@ +#!/usr/bin/env zsh +# Tests for the zsh _dbt completion script. +# +# Covers the pure logic functions that don't require a running dbt binary or a +# live zsh completion context. Run with: +# zsh test.zsh + +setopt NO_UNSET + +# ─── mock zsh completion builtins ──────────────────────────────────────────── +# These builtins are not available outside a real completion context, so we +# define lightweight stubs that capture what would have been offered. + +typeset -ga _t_descs=() # items added via _describe +typeset -ga _t_comps=() # items added via compadd +typeset -gi _t_files=0 # number of _files calls +typeset -gi _t_compset_calls=0 + +_describe() { + # _describe 'tag' array_name — access array by name via dynamic scope + local list_name="$2" + _t_descs+=("${(P@)list_name}") +} + +compadd() { + # capture items from: compadd -U -a varname + local i=1 + while (( i <= $# )); do + if [[ "${@[i]}" == "-a" ]]; then + local vn="${@[i+1]}" + _t_comps+=("${(P@)vn}") + break + fi + i=$(( i + 1 )) + done +} + +_files() { _t_files=$(( _t_files + 1 )) } +_values() { : } # used by _dbt_list_models, not tested here +compset() { _t_compset_calls=$(( _t_compset_calls + 1 )) } + +# ─── source the completion script ──────────────────────────────────────────── +# The final bare `_dbt` call at the bottom of the file runs in a non-completion +# context ($words unset, CURRENT=0) so _dbt_bin_info returns 1 immediately. +# Redirect stderr to suppress any "command not found: dbt" noise. +source "${0:h}/_dbt" 2>/dev/null + +# ─── test framework ────────────────────────────────────────────────────────── + +typeset -gi _pass=0 _fail=0 + +_ok() { + local msg="$1" + _pass=$(( _pass + 1 )) + print -r " ok: $msg" +} + +_fail() { + local msg="$1" extra="${2:-}" + _fail=$(( _fail + 1 )) + print -r "FAIL: $msg${extra:+ — $extra}" +} + +assert_eq() { + local expected="$1" actual="$2" msg="$3" + if [[ "$actual" == "$expected" ]]; then + _ok "$msg" + else + _fail "$msg" "expected $(print -rn -- "$expected"), got $(print -rn -- "$actual")" + fi +} + +# assert that array $2..$n-1 contains element $1; last arg is the message +assert_in() { + local needle="$1" msg="${@[-1]}" + local -a hay=("${@[2,-2]}") + if (( ${hay[(I)$needle]} > 0 )); then + _ok "$msg" + else + _fail "$msg" "'$needle' not found in (${(j:, :)hay})" + fi +} + +assert_not_in() { + local needle="$1" msg="${@[-1]}" + local -a hay=("${@[2,-2]}") + if (( ${hay[(I)$needle]} == 0 )); then + _ok "$msg" + else + _fail "$msg" "'$needle' was unexpectedly present" + fi +} + +_reset_mocks() { + _t_descs=() + _t_comps=() + _t_files=0 + _t_compset_calls=0 +} + +_section() { print "\n── $1 ──" } + +# ─── _dbt_core_present_completions ─────────────────────────────────────────── + +_section "_dbt_core_present_completions" + +# Builds a click-style response string; result is left in $_mr. +# Using a global avoids command substitution which strips trailing newlines. +# Usage: _make_response type val desc [type val desc ...] +typeset -g _mr="" +_make_response() { + _mr="" + while (( $# >= 3 )); do + _mr+="$1"$'\n'"$2"$'\n'"$3"$'\n' + shift 3 + done +} + +# 1. No filter: all plain items appear in descriptions +_reset_mocks +_make_response plain --select "Select resources." plain --exclude "Exclude resources." +_dbt_core_present_completions "$_mr" "" +assert_in "--select:Select resources." "${_t_descs[@]}" "no filter: --select present" +assert_in "--exclude:Exclude resources." "${_t_descs[@]}" "no filter: --exclude present" + +# 2. Filter matches only items starting with prefix +_reset_mocks +_make_response \ + plain --select "Select resources." \ + plain --exclude "Exclude resources." \ + plain --selector "YAML selector." +_dbt_core_present_completions "$_mr" "--sel" +assert_in "--select:Select resources." "${_t_descs[@]}" "filter --sel: --select included" +assert_in "--selector:YAML selector." "${_t_descs[@]}" "filter --sel: --selector included" +assert_not_in "--exclude:Exclude resources." "${_t_descs[@]}" "filter --sel: --exclude excluded" + +# 3. Multi-line description does not drop the following entry +# (this was the --warn-error-options regression: fixed-stride-3 loop broke alignment) +_reset_mocks +multiline_response=$(printf '%s\n' \ + "plain" "--warn-error-options" \ + "First line of long description," \ + "second line of long description." \ + "plain" "--select" "Select resources.") +_dbt_core_present_completions "$multiline_response" "--sel" +assert_in "--select:Select resources." "${_t_descs[@]}" \ + "multi-line desc: --select still found after misaligned triplet" + +# 4. Multi-line description on the last entry does not error or loop forever +_reset_mocks +last_multiline=$(printf '%s\n' \ + "plain" "--select" "Select resources." \ + "plain" "--warn-error-options" \ + "First line." "Second line.") +_dbt_core_present_completions "$last_multiline" "" 2>/dev/null +assert_in "--select:Select resources." "${_t_descs[@]}" \ + "multi-line last entry: earlier entry still present" + +# 5. Empty description → item goes into plain compadd, not _describe +_reset_mocks +_make_response plain --no-desc "" +_dbt_core_present_completions "$_mr" "" +assert_in "--no-desc" "${_t_comps[@]}" "empty desc: item goes to compadd" + +# 6. dir type triggers _files (response intentionally has no description line — +# also tests the bounds guard added to handle truncated triplets) +_reset_mocks +_dbt_core_present_completions $'dir\n/some/path\n' "" +assert_eq "1" "$_t_files" "dir type: _files called" + +# 7. Empty response returns 1 and adds nothing +_reset_mocks +_dbt_core_present_completions "" "" +assert_eq "0" "${#_t_descs[@]}" "empty response: no descs" +assert_eq "0" "${#_t_comps[@]}" "empty response: no comps" + +# 8. Description line containing the word "plain" is not mistaken for a type marker +_reset_mocks +tricky=$(printf '%s\n' \ + "plain" "--flag-a" "Use plain text format for output." \ + "plain" "--flag-b" "Another option.") +_dbt_core_present_completions "$tricky" "" +assert_in "--flag-a:Use plain text format for output." "${_t_descs[@]}" \ + "desc containing 'plain' not treated as type marker" +assert_in "--flag-b:Another option." "${_t_descs[@]}" \ + "entry after tricky description still parsed" + +# ─── _dbt_core_subcommand_path ─────────────────────────────────────────────── + +_section "_dbt_core_subcommand_path" + +# helper: set words+CURRENT and call the function +_subpath() { + local -a words=("${@[1,-2]}") + local CURRENT="${@[-1]}" + _dbt_core_subcommand_path +} + +_subpath "dbt" "run" 3 +assert_eq "dbt run" "${reply[1]}" "basic: sub_context is 'dbt run'" +assert_eq "2" "${reply[2]}" "basic: sub_cword is 2" +assert_eq "0" "${reply[3]}" "basic: no global flags" + +_subpath "dbt" 2 +assert_eq "dbt" "${reply[1]}" "bare dbt: sub_context is 'dbt'" +assert_eq "1" "${reply[2]}" "bare dbt: sub_cword is 1" +assert_eq "0" "${reply[3]}" "bare dbt: no global flags" + +_subpath "dbt" "run" "--project-dir" "/foo" "--sel" 5 +assert_eq "dbt run" "${reply[1]}" "flags stripped: sub_context stops before first flag" +assert_eq "2" "${reply[2]}" "flags stripped: sub_cword is 2" +assert_eq "0" "${reply[3]}" "flags stripped: no global flags (flag after subcommand)" + +# words[2] = "--profiles-dir" → has_global_flags=1 because a flag appears before the subcommand +_subpath "dbt" "--profiles-dir" "/p" "run" 5 +assert_eq "dbt" "${reply[1]}" "global flag before subcommand: sub_context is just 'dbt'" +assert_eq "1" "${reply[2]}" "global flag before subcommand: sub_cword is 1" +assert_eq "1" "${reply[3]}" "global flag before subcommand: has_global_flags=1" + +_subpath "dbt" "run" "-p" "/p" "--sel" 5 +assert_eq "dbt run" "${reply[1]}" "short flag also stops traversal" +assert_eq "0" "${reply[3]}" "short flag after subcommand: has_global_flags=0" + +# CURRENT=2: current word is still being typed, so we never set has_global_flags +# even if it starts with '-' (the user is typing the first arg) +_subpath "dbt" "--sel" 2 +assert_eq "dbt" "${reply[1]}" "CURRENT=2, typing flag: sub_context is 'dbt'" +assert_eq "0" "${reply[3]}" "CURRENT=2: no has_global_flags (current word not yet committed)" + +# ─── _dbt_core_build_comp_context ──────────────────────────────────────────── + +_section "_dbt_core_build_comp_context" + +# flag-name: current starts with '-', normalised to '-', filter preserved +_dbt_core_build_comp_context "--sel" "run" "dbt run" "2" +assert_eq "dbt run -" "${reply[1]}" "flag-name: comp_words normalised to 'dbt run -'" +assert_eq "2" "${reply[2]}" "flag-name: comp_cword = sub_cword" +assert_eq "--sel" "${reply[3]}" "flag-name: filter_word = '--sel'" + +# flag-name with bare '--' +_dbt_core_build_comp_context "--" "run" "dbt run" "2" +assert_eq "dbt run -" "${reply[1]}" "bare --: comp_words normalised" +assert_eq "--" "${reply[3]}" "bare --: filter_word is '--'" + +# flag-name with single '-' +_dbt_core_build_comp_context "-" "run" "dbt run" "2" +assert_eq "dbt run -" "${reply[1]}" "single -: normalises to same 'dbt run -'" +assert_eq "-" "${reply[3]}" "single -: filter_word is '-'" + +# flag-value: prev starts with '-' +_dbt_core_build_comp_context "text" "--log-format" "dbt run" "2" +assert_eq "dbt run --log-format text" "${reply[1]}" "flag-value: flag included in comp_words" +assert_eq "3" "${reply[2]}" "flag-value: comp_cword = sub_cword+1" +assert_eq "" "${reply[3]}" "flag-value: no filter_word" + +# positional / subcommand +_dbt_core_build_comp_context "run" "dbt" "dbt" "1" +assert_eq "dbt run" "${reply[1]}" "positional: current word appended" +assert_eq "1" "${reply[2]}" "positional: comp_cword = sub_cword" +assert_eq "" "${reply[3]}" "positional: no filter_word" + +# ─── _dbt_core_complete context routing ────────────────────────────────────── +# +# Tests that _dbt_core_complete builds the right comp_words_str / comp_cword +# depending on whether global flags are present. We mock _dbt_bin_info and +# _dbt_core_fetch_completions so no real dbt binary is needed. + +_section "_dbt_core_complete context routing" + +# --- mocks --------------------------------------------------------------- +# Capture the last comp_words_str and comp_cword passed to _dbt_core_fetch_completions. +typeset -g _t_fetch_words="" _t_fetch_cword="" +typeset -gi _t_fetch_calls=0 + +_dbt_bin_info() { + # Pretend the binary is at /usr/bin/dbt with a fixed mtime. + reply=("/usr/bin/dbt" "1234567890") + return 0 +} + +_dbt_core_fetch_completions() { + _t_fetch_words="$2" + _t_fetch_cword="$3" + _t_fetch_calls=$(( _t_fetch_calls + 1 )) + _dbt_response="" # empty — _dbt_core_present_completions will no-op +} + +_reset_complete() { + _t_fetch_words="" + _t_fetch_cword="" + _t_fetch_calls=0 + _t_descs=() + _t_comps=() +} + +# wrapper: sets words+CURRENT as locals (dynamic scope used by _dbt_core_complete) +_call_core_complete() { + local -a words=("${@[1,-2]}") + local CURRENT="${@[-1]}" + _dbt_core_complete +} + +# --- normal path (no global flags) --------------------------------------- + +# Flag-name completion: "dbt run --" +# Expected: normalised to "dbt run -", cword=2, filter_word="--" (stripped locally) +_reset_complete +_call_core_complete "dbt" "run" "--" 3 +assert_eq "dbt run -" "$_t_fetch_words" "normal path, flag-name: comp_words normalised" +assert_eq "2" "$_t_fetch_cword" "normal path, flag-name: comp_cword=2" + +# Flag-value completion: "dbt run --log-format text" +# Expected: "dbt run --log-format text", cword=3 +_reset_complete +_call_core_complete "dbt" "run" "--log-format" "text" 4 +assert_eq "dbt run --log-format text" "$_t_fetch_words" "normal path, flag-value: includes flag" +assert_eq "3" "$_t_fetch_cword" "normal path, flag-value: comp_cword=3" + +# Subcommand completion: "dbt run" +# Expected: "dbt run", cword=1 +_reset_complete +_call_core_complete "dbt" "run" 2 +assert_eq "dbt run" "$_t_fetch_words" "normal path, positional: current word appended" +assert_eq "1" "$_t_fetch_cword" "normal path, positional: comp_cword=1" + +# --- global-flag path ---------------------------------------------------- + +# Flag-name completion with global flag: "dbt --profiles-dir /p run --" +# words = (dbt --profiles-dir /p run --) CURRENT=5 +# Expected: full context "dbt --profiles-dir /p run -" (last word normalised to "-"), +# cword = CURRENT-1 = 4, filter_word = "--" +_reset_complete +_call_core_complete "dbt" "--profiles-dir" "/p" "run" "--" 5 +assert_eq "dbt --profiles-dir /p run -" "$_t_fetch_words" \ + "global-flag path, flag-name: full context with current normalised to -" +assert_eq "4" "$_t_fetch_cword" "global-flag path, flag-name: comp_cword=4" + +# Positional completion with global flag: "dbt --profiles-dir /p run" +# words = (dbt --profiles-dir /p run) CURRENT=4 +# Expected: full context "dbt --profiles-dir /p run", cword=3 +_reset_complete +_call_core_complete "dbt" "--profiles-dir" "/p" "run" 4 +assert_eq "dbt --profiles-dir /p run" "$_t_fetch_words" \ + "global-flag path, positional: full context preserved" +assert_eq "3" "$_t_fetch_cword" "global-flag path, positional: comp_cword=3" + +# ─── _get_project_root ─────────────────────────────────────────────────────── + +_section "_get_project_root" + +# respects DBT_PROJECT_DIR +result=$(DBT_PROJECT_DIR=/custom/dir _get_project_root) +assert_eq "/custom/dir" "$result" "DBT_PROJECT_DIR used directly" + +# walks up from a nested directory +tmpdir=$(mktemp -d) +tmpdir=$(cd "$tmpdir" && pwd -P) +mkdir -p "$tmpdir/a/b/c" +touch "$tmpdir/dbt_project.yml" +result=$(cd "$tmpdir/a/b/c" && _get_project_root) +result_canonical=$(cd "$result" 2>/dev/null && pwd -P) +assert_eq "$tmpdir" "$result_canonical" "walks up two levels to find dbt_project.yml" +rm -rf "$tmpdir" + +# returns empty when no dbt_project.yml is found anywhere +tmpdir=$(mktemp -d) +result=$(cd "$tmpdir" && _get_project_root 2>/dev/null) +assert_eq "" "$result" "no dbt_project.yml: returns empty" +rm -rf "$tmpdir" + +# ─── _dbt_manifest_path ────────────────────────────────────────────────────── + +_section "_dbt_manifest_path" + +# DBT_MANIFEST_PATH pointing to a real file +tmpfile=$(mktemp) +result=$(DBT_MANIFEST_PATH="$tmpfile" _dbt_manifest_path) +assert_eq "$tmpfile" "$result" "DBT_MANIFEST_PATH used when file exists" +rm -f "$tmpfile" + +# DBT_MANIFEST_PATH pointing to a missing file → returns 1 +result=$(DBT_MANIFEST_PATH="/no/such/file.json" _dbt_manifest_path 2>/dev/null) +rc=$? +assert_eq "1" "$rc" "DBT_MANIFEST_PATH missing file: returns 1" + +# falls back to project root / target/manifest.json +tmpdir=$(mktemp -d) +tmpdir=$(cd "$tmpdir" && pwd -P) +mkdir -p "$tmpdir/target" +touch "$tmpdir/dbt_project.yml" "$tmpdir/target/manifest.json" +result=$(cd "$tmpdir" && _dbt_manifest_path) +result_canonical=$(cd "${result:h}" 2>/dev/null && echo "$(pwd -P)/${result:t}") +assert_eq "$tmpdir/target/manifest.json" "$result_canonical" "falls back to project root manifest" +rm -rf "$tmpdir" + +# ─── _dbt --flag=value routing ─────────────────────────────────────────────── + +_section "_dbt --flag=value routing" + +# Override the manifest-based functions so tests don't need a real project. +# These definitions shadow the ones from _dbt for the remainder of the file. +typeset -gi _t_list_models_calls=0 _t_list_selectors_calls=0 + +_dbt_list_models() { _t_list_models_calls=$(( _t_list_models_calls + 1 )) } +_dbt_list_selectors() { _t_list_selectors_calls=$(( _t_list_selectors_calls + 1 )) } + +_reset_routing() { + _t_list_models_calls=0 + _t_list_selectors_calls=0 + _t_compset_calls=0 +} + +# Wrapper: sets words+CURRENT as locals so _dbt() can read them via dynamic scope. +# All non-option args become words; last arg is CURRENT. +_call_dbt() { + local -a words=("${@[1,-2]}") + local CURRENT="${@[-1]}" + _dbt +} + +# --select=: compset strips prefix, models listed +_reset_routing +_call_dbt dbt run --select= 3 +assert_eq "1" "$_t_compset_calls" "--select=: compset called" +assert_eq "1" "$_t_list_models_calls" "--select=: models listed" +assert_eq "0" "$_t_list_selectors_calls" "--select=: selectors not listed" + +# --exclude= +_reset_routing +_call_dbt dbt run --exclude=my_mod 3 +assert_eq "1" "$_t_compset_calls" "--exclude=: compset called" +assert_eq "1" "$_t_list_models_calls" "--exclude=: models listed" + +# -m= (short flag) +_reset_routing +_call_dbt dbt run -m= 3 +assert_eq "1" "$_t_compset_calls" "-m=: compset called" +assert_eq "1" "$_t_list_models_calls" "-m=: models listed" + +# --selector=: selectors listed, not models +_reset_routing +_call_dbt dbt run --selector=nightly 3 +assert_eq "1" "$_t_compset_calls" "--selector=: compset called" +assert_eq "1" "$_t_list_selectors_calls" "--selector=: selectors listed" +assert_eq "0" "$_t_list_models_calls" "--selector=: models not listed" + +# --select value (space style) still works without compset +_reset_routing +_call_dbt dbt run --select foo 4 +assert_eq "0" "$_t_compset_calls" "--select value: no compset" +assert_eq "1" "$_t_list_models_calls" "--select value: models still listed" + +# --selector value (space style) +_reset_routing +_call_dbt dbt run --selector nightly 4 +assert_eq "0" "$_t_compset_calls" "--selector value: no compset" +assert_eq "1" "$_t_list_selectors_calls" "--selector value: selectors still listed" + +# ─── summary ───────────────────────────────────────────────────────────────── + +print "" +if (( _fail > 0 )); then + print "${_fail} test(s) FAILED, ${_pass} passed." + exit 1 +else + print "All ${_pass} tests passed." +fi