From 8fae1e226fd309d07f05404252f9d99b92a38b6e Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Wed, 24 Sep 2025 20:59:12 +0900 Subject: [PATCH 1/5] feat(fish): add fish_shortcuts to list abbreviations and aliases Adds a function that dynamically lists current fish abbreviations and aliases. It extracts descriptions from function --description flags or leading comments. --- .../fish/functions/fish_shortcuts.fish | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 home-manager/programs/fish/functions/fish_shortcuts.fish diff --git a/home-manager/programs/fish/functions/fish_shortcuts.fish b/home-manager/programs/fish/functions/fish_shortcuts.fish new file mode 100644 index 000000000..af4bfe558 --- /dev/null +++ b/home-manager/programs/fish/functions/fish_shortcuts.fish @@ -0,0 +1,120 @@ +function fish_shortcuts --description "List fish abbreviations and aliases with inferred descriptions" + # Prints all abbreviations and aliases currently active in this shell. + # For items that expand to a function, attempts to extract a description from: + # 1) the function's --description flag, or + # 2) the first leading comment lines inside the function body. + + function __fish__get_func_desc --argument-names fname + if not functions -q $fname + return 1 + end + + # Get the full function definition as a single string + set -l def (functions $fname | string collect) + + # Try to extract --description from the function header + set -l desc_line (string match -r -- 'function\s+\S+.*--description(=| )[\"\'].*' -- $def) + if test -n "$desc_line" + # Try double-quoted first + set -l d (string replace -r -- '.*--description(=| )\"([^\"]+)\".*' '$2' -- $desc_line) + if test -n "$d" + echo $d + return 0 + end + # Fallback to single-quoted + set -l d (string replace -r -- ".*--description(=| )'([^']+)'.*" '$2' -- $desc_line) + if test -n "$d" + echo $d + return 0 + end + end + + # Fallback: first non-empty leading comment line after the header + set -l lines (functions $fname | string split \n) + set -l got_header 0 + for line in $lines + if test $got_header -eq 0 + # Skip the header line: it starts with "function " + if string match -rq '^\s*function\s' -- $line + set got_header 1 + end + continue + end + if string match -rq '^\s*#' -- $line + set -l cleaned (string replace -r -- '^\s*#\s*' '' -- $line) + if test -n "$cleaned" + echo $cleaned + return 0 + end + else + # Stop scanning once non-comment code begins + break + end + end + + return 1 + end + + function __fish__format_row --argument-names kind name expansion desc + # kind: "abbr" | "alias" + if test -n "$desc" + printf "%s\t%-20s -> %-40s # %s\n" $kind $name $expansion $desc + else + printf "%s\t%-20s -> %s\n" $kind $name $expansion + end + end + + set -l any_output 0 + + # Abbreviations + if type -q abbr + set -l abbr_list (abbr --list 2>/dev/null) + set -l abbr_show (abbr --show 2>/dev/null) + + for a in $abbr_list + # Find the matching line for this abbreviation in the abbr --show output + set -l line (printf "%s\n" $abbr_show | string match -r -- "^"(string escape --style=regex -- $a)"(\b|\s).*") + set -l expansion "" + if test -n "$line" + # Extract the expansion part after '->' + set expansion (string replace -r -- '^[^>]+->\s*' '' -- $line) + end + + set -l desc "" + # If the expansion is a single token and is a function, derive description + if test -n "$expansion" + set -l first_token (string split ' ' -- $expansion)[1] + if test (count (string split ' ' -- $expansion)) -eq 1; and functions -q $first_token + set desc (__fish__get_func_desc $first_token) + end + end + + __fish__format_row abbr $a "$expansion" "$desc" + set any_output 1 + end + end + + # Aliases + if type -q alias + for line in (alias 2>/dev/null) + # line format typically: alias NAME 'EXPANSION...' + set -l name (string replace -r -- '^alias\s+([^\s]+).*$' '$1' -- $line) + set -l expansion (string replace -r -- "^alias\s+[^\s]+\s+'([^']*)'.*$" '$1' -- $line) + + set -l desc "" + if test -n "$expansion" + set -l first_token (string split ' ' -- $expansion)[1] + if test (count (string split ' ' -- $expansion)) -eq 1; and functions -q $first_token + set desc (__fish__get_func_desc $first_token) + end + end + + __fish__format_row alias $name "$expansion" "$desc" + set any_output 1 + end + end + + if test $any_output -eq 0 + echo "No abbreviations or aliases found." + end +end From 86751124f3d803507b810844e0bd336d0d0f9cfc Mon Sep 17 00:00:00 2001 From: Shun Kakinoki <39187513+shunkakinoki@users.noreply.github.com> Date: Tue, 30 Sep 2025 21:57:31 +0900 Subject: [PATCH 2/5] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- home-manager/programs/fish/functions/fish_shortcuts.fish | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/home-manager/programs/fish/functions/fish_shortcuts.fish b/home-manager/programs/fish/functions/fish_shortcuts.fish index af4bfe558..4479da7ea 100644 --- a/home-manager/programs/fish/functions/fish_shortcuts.fish +++ b/home-manager/programs/fish/functions/fish_shortcuts.fish @@ -13,7 +13,7 @@ function fish_shortcuts --description "List fish abbreviations and aliases with set -l def (functions $fname | string collect) # Try to extract --description from the function header - set -l desc_line (string match -r -- 'function\s+\S+.*--description(=| )[\"\'].*' -- $def) + set -l desc_line (string match -r -- 'function\s+\S+.*--description(=| )([\"\'][^\"\']*[\"\'])' -- $def) if test -n "$desc_line" # Try double-quoted first set -l d (string replace -r -- '.*--description(=| )\"([^\"]+)\".*' '$2' -- $desc_line) From 3a11ffb93424c5a8de278965138c56fd1f2ba9f2 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Tue, 30 Sep 2025 22:29:43 +0900 Subject: [PATCH 3/5] fix(fish): correct function name from __hm_load_env_file to _hm_load_env_file --- home-manager/programs/fish/default.nix | 1 + .../functions/{fish_shortcuts.fish => _fish_shortcuts.fish} | 0 .../{__hm_load_env_file.fish => _hm_load_env_file.fish} | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename home-manager/programs/fish/functions/{fish_shortcuts.fish => _fish_shortcuts.fish} (100%) rename home-manager/programs/fish/functions/{__hm_load_env_file.fish => _hm_load_env_file.fish} (92%) diff --git a/home-manager/programs/fish/default.nix b/home-manager/programs/fish/default.nix index 1e3f6d2b1..9c57394b3 100644 --- a/home-manager/programs/fish/default.nix +++ b/home-manager/programs/fish/default.nix @@ -56,6 +56,7 @@ ffp = "_fzf_file_picker --allow-open-in-editor --prompt-name Files"; ffpf = "_fzf_file_picker --allow-open-in-editor --show-hidden-files --prompt-name Files+"; fhq = "_fzf_ghq_picker"; + shortcuts = "_fish_shortcuts"; }; plugins = [ { diff --git a/home-manager/programs/fish/functions/fish_shortcuts.fish b/home-manager/programs/fish/functions/_fish_shortcuts.fish similarity index 100% rename from home-manager/programs/fish/functions/fish_shortcuts.fish rename to home-manager/programs/fish/functions/_fish_shortcuts.fish diff --git a/home-manager/programs/fish/functions/__hm_load_env_file.fish b/home-manager/programs/fish/functions/_hm_load_env_file.fish similarity index 92% rename from home-manager/programs/fish/functions/__hm_load_env_file.fish rename to home-manager/programs/fish/functions/_hm_load_env_file.fish index dc4f6cc5f..2de7466c2 100644 --- a/home-manager/programs/fish/functions/__hm_load_env_file.fish +++ b/home-manager/programs/fish/functions/_hm_load_env_file.fish @@ -1,4 +1,4 @@ -function __hm_load_env_file --description 'Load environment variables from .env' +function _hm_load_env_file --description 'Load environment variables from .env' set -l candidate_paths if set -q DOTFILES_ENV_FILE set -a candidate_paths $DOTFILES_ENV_FILE From c16492f63aafda463a6323a45d4968c8c6c92118 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Tue, 30 Sep 2025 22:43:46 +0900 Subject: [PATCH 4/5] feat(fish): add --description to all missing function files - _clxe_function: Run Codex with a free-form prompt using the local gpt-oss:120b model - _cxe_function: Run Codex with a free-form prompt - _gco_function: Checkout default branch and pull latest changes - _grco_function: Hard reset default branch to remote state - _grcr_function: Hard reset current branch to remote state --- home-manager/programs/fish/default.nix | 2 +- .../fish/functions/_clxe_function.fish | 13 +- .../fish/functions/_cxe_function.fish | 13 +- .../fish/functions/_fish_shortcuts.fish | 132 +++++++++++++++--- .../fish/functions/_gco_function.fish | 2 +- .../fish/functions/_grco_function.fish | 2 +- .../fish/functions/_grcr_function.fish | 2 +- 7 files changed, 125 insertions(+), 41 deletions(-) diff --git a/home-manager/programs/fish/default.nix b/home-manager/programs/fish/default.nix index 9c57394b3..262a3616e 100644 --- a/home-manager/programs/fish/default.nix +++ b/home-manager/programs/fish/default.nix @@ -13,7 +13,7 @@ fish_add_path -p /etc/profiles/per-user/${config.home.username}/bin ''; interactiveShellInit = '' - __hm_load_env_file + _hm_load_env_file set fish_greeting set fish_theme dracula fish_add_path -p ~/.nix-profile/bin diff --git a/home-manager/programs/fish/functions/_clxe_function.fish b/home-manager/programs/fish/functions/_clxe_function.fish index 1258184dc..4dc1b6c7d 100644 --- a/home-manager/programs/fish/functions/_clxe_function.fish +++ b/home-manager/programs/fish/functions/_clxe_function.fish @@ -1,12 +1,11 @@ -function _clxe_function +function _clxe_function --description "Run Codex with a free-form prompt using the local gpt-oss:120b model" # Run Codex with a free-form prompt (spaces allowed) using the local gpt-oss:120b model - # Usage: clxe + # Usage: clxe [] if test (count $argv) -eq 0 - echo "Usage: clxe " >&2 - return 2 + codex --profile 'gpt-oss-120b' --full-auto -c model_reasoning_summary_format=experimental + else + set -l prompt (string join " " -- $argv) + codex --profile 'gpt-oss-120b' --full-auto -c model_reasoning_summary_format=experimental -- "$prompt" end - - set -l prompt (string join " " -- $argv) - codex --profile 'gpt-oss-120b' --full-auto -c model_reasoning_summary_format=experimental -- "$prompt" end diff --git a/home-manager/programs/fish/functions/_cxe_function.fish b/home-manager/programs/fish/functions/_cxe_function.fish index a89bc778d..da041ef93 100644 --- a/home-manager/programs/fish/functions/_cxe_function.fish +++ b/home-manager/programs/fish/functions/_cxe_function.fish @@ -1,12 +1,11 @@ -function _cxe_function +function _cxe_function --description "Run Codex with a free-form prompt" # Run Codex with a free-form prompt (spaces allowed) - # Usage: cxe + # Usage: cxe [] if test (count $argv) -eq 0 - echo "Usage: cxe " >&2 - return 2 + codex --model 'gpt-5-codex' --full-auto -c model_reasoning_summary_format=experimental + else + set -l prompt (string join " " -- $argv) + codex --model 'gpt-5-codex' --full-auto -c model_reasoning_summary_format=experimental -- "$prompt" end - - set -l prompt (string join " " -- $argv) - codex --model 'gpt-5-codex' --full-auto -c model_reasoning_summary_format=experimental -- "$prompt" end diff --git a/home-manager/programs/fish/functions/_fish_shortcuts.fish b/home-manager/programs/fish/functions/_fish_shortcuts.fish index 4479da7ea..91c0eeed2 100644 --- a/home-manager/programs/fish/functions/_fish_shortcuts.fish +++ b/home-manager/programs/fish/functions/_fish_shortcuts.fish @@ -1,4 +1,4 @@ -function fish_shortcuts --description "List fish abbreviations and aliases with inferred descriptions" +function _fish_shortcuts --description "List fish abbreviations and aliases with inferred descriptions" # Prints all abbreviations and aliases currently active in this shell. # For items that expand to a function, attempts to extract a description from: # 1) the function's --description flag, or @@ -13,20 +13,16 @@ function fish_shortcuts --description "List fish abbreviations and aliases with set -l def (functions $fname | string collect) # Try to extract --description from the function header - set -l desc_line (string match -r -- 'function\s+\S+.*--description(=| )([\"\'][^\"\']*[\"\'])' -- $def) - if test -n "$desc_line" - # Try double-quoted first - set -l d (string replace -r -- '.*--description(=| )\"([^\"]+)\".*' '$2' -- $desc_line) - if test -n "$d" - echo $d - return 0 - end - # Fallback to single-quoted - set -l d (string replace -r -- ".*--description(=| )'([^']+)'.*" '$2' -- $desc_line) - if test -n "$d" - echo $d - return 0 - end + set -l d (string match -r --groups-only -- '--description(?:=| )\"([^\"]*)\"' "$def") + if test -n "$d" + echo $d + return 0 + end + + set d (string match -r --groups-only -- "--description(?:=| )'([^']*)'" "$def") + if test -n "$d" + echo $d + return 0 end # Fallback: first non-empty leading comment line after the header @@ -69,15 +65,33 @@ function fish_shortcuts --description "List fish abbreviations and aliases with # Abbreviations if type -q abbr set -l abbr_list (abbr --list 2>/dev/null) - set -l abbr_show (abbr --show 2>/dev/null) for a in $abbr_list - # Find the matching line for this abbreviation in the abbr --show output - set -l line (printf "%s\n" $abbr_show | string match -r -- "^"(string escape --style=regex -- $a)"(\b|\s).*") set -l expansion "" - if test -n "$line" - # Extract the expansion part after '->' - set expansion (string replace -r -- '^[^>]+->\s*' '' -- $line) + set -l show_line (abbr --show $a 2>/dev/null | string collect) + if test -n "$show_line" + if type -q python3 + set -lx __FISH_SHORTCUT_LINE "$show_line" + set expansion (python3 -c "import os, shlex, sys; line = os.environ['__FISH_SHORTCUT_LINE']; tokens = shlex.split(line); rest = tokens[tokens.index('--') + 2:]; pos = rest.index('--function') if '--function' in rest else -1; exp = rest[pos + 1] if pos != -1 and pos + 1 < len(rest) else (' '.join(rest) if rest else ''); sys.stdout.write(exp)") + set -e __FISH_SHORTCUT_LINE + end + + if test -z "$expansion" + set expansion (string match -r --groups-only ".*'([^']*)'" -- "$show_line") + end + if test -z "$expansion" + set expansion (string match -r --groups-only '.*"([^"]*)"' -- "$show_line") + end + if test -z "$expansion" + set -l fallback (string replace -r -- '^abbr\b.* -- \S+\s+' '' -- "$show_line") + set fallback (string trim -- $fallback) + if test -n "$fallback" + set expansion $fallback + end + end + if test -n "$expansion" + set expansion (string trim --chars="'\"" -- $expansion) + end end set -l desc "" @@ -98,8 +112,21 @@ function fish_shortcuts --description "List fish abbreviations and aliases with if type -q alias for line in (alias 2>/dev/null) # line format typically: alias NAME 'EXPANSION...' - set -l name (string replace -r -- '^alias\s+([^\s]+).*$' '$1' -- $line) - set -l expansion (string replace -r -- "^alias\s+[^\s]+\s+'([^']*)'.*$" '$1' -- $line) + set -l name "" + set -l expansion "" + + set -l alias_parts (string split -m 2 ' ' -- $line) + if test (count $alias_parts) -ge 2 + set name $alias_parts[2] + end + if test (count $alias_parts) -ge 3 + set expansion (string trim --chars="'" -- $alias_parts[3]) + end + + if test -z "$name" + # Skip lines we fail to parse (unexpected alias output format) + continue + end set -l desc "" if test -n "$expansion" @@ -114,7 +141,66 @@ function fish_shortcuts --description "List fish abbreviations and aliases with end end + # Functions stored alongside this helper + set -l script_path (status current-filename) + set -l script_realpath "" + if test -n "$script_path" + if type -q realpath + set script_realpath (realpath "$script_path" 2>/dev/null) + end + if test -z "$script_realpath" + set script_realpath $script_path + end + end + + set -l functions_dir "" + set -l functions_parent "" + if test -n "$script_realpath" + set functions_dir (path dirname $script_realpath) + set functions_parent (path dirname $functions_dir) + end + + if test -n "$functions_dir"; and test -d "$functions_dir" + set -l function_files $functions_dir/*.fish + if test "$function_files" = "$functions_dir/*.fish" + set function_files + end + + for file in $function_files + if test -z "$file"; or test ! -f "$file" + continue + end + + set -l base (path basename $file) + set -l fname (path change-extension '' -- $base) + + # Avoid re-sourcing ourselves repeatedly + if test "$fname" = (status function) + continue + end + + if not functions -q $fname + builtin source $file 2>/dev/null + end + + set -l desc (__fish__get_func_desc $fname) + set -l rel_path "$base" + if test -n "$functions_parent" + set rel_path (string join '' "functions/" $base) + end + + __fish__format_row func $fname "$rel_path" "$desc" + set any_output 1 + end + end + if test $any_output -eq 0 echo "No abbreviations or aliases found." end end + +if not functions -q fish_shortcuts + function fish_shortcuts --wraps _fish_shortcuts --description "Alias for _fish_shortcuts" + _fish_shortcuts $argv + end +end diff --git a/home-manager/programs/fish/functions/_gco_function.fish b/home-manager/programs/fish/functions/_gco_function.fish index 9d502d560..026e13362 100644 --- a/home-manager/programs/fish/functions/_gco_function.fish +++ b/home-manager/programs/fish/functions/_gco_function.fish @@ -1,3 +1,3 @@ -function _gco_function +function _gco_function --description "Checkout default branch and pull latest changes" git checkout (git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@') && git pull end diff --git a/home-manager/programs/fish/functions/_grco_function.fish b/home-manager/programs/fish/functions/_grco_function.fish index 320392f46..d3e06261c 100644 --- a/home-manager/programs/fish/functions/_grco_function.fish +++ b/home-manager/programs/fish/functions/_grco_function.fish @@ -1,4 +1,4 @@ -function _grco_function +function _grco_function --description "Hard reset default branch to remote state" # Determine the default branch (e.g., main or master) from origin/HEAD set -l default_branch (git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@') diff --git a/home-manager/programs/fish/functions/_grcr_function.fish b/home-manager/programs/fish/functions/_grcr_function.fish index 4134ecec7..8140c3ed1 100644 --- a/home-manager/programs/fish/functions/_grcr_function.fish +++ b/home-manager/programs/fish/functions/_grcr_function.fish @@ -1,4 +1,4 @@ -function _grcr_function +function _grcr_function --description "Hard reset current branch to remote state" # Determine the current branch; abort if detached HEAD set -l current_branch (git branch --show-current) if test -z "$current_branch" From 085463cc3a0431c29bdce5f66c2d8f0a90b7ef52 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Tue, 30 Sep 2025 22:59:58 +0900 Subject: [PATCH 5/5] refactor(fish): streamline abbreviation and alias parsing in _fish_shortcuts function --- .../fish/functions/_fish_shortcuts.fish | 129 ++++++++++++------ 1 file changed, 85 insertions(+), 44 deletions(-) diff --git a/home-manager/programs/fish/functions/_fish_shortcuts.fish b/home-manager/programs/fish/functions/_fish_shortcuts.fish index 91c0eeed2..6df43b868 100644 --- a/home-manager/programs/fish/functions/_fish_shortcuts.fish +++ b/home-manager/programs/fish/functions/_fish_shortcuts.fish @@ -25,29 +25,6 @@ function _fish_shortcuts --description "List fish abbreviations and aliases with return 0 end - # Fallback: first non-empty leading comment line after the header - set -l lines (functions $fname | string split \n) - set -l got_header 0 - for line in $lines - if test $got_header -eq 0 - # Skip the header line: it starts with "function " - if string match -rq '^\s*function\s' -- $line - set got_header 1 - end - continue - end - if string match -rq '^\s*#' -- $line - set -l cleaned (string replace -r -- '^\s*#\s*' '' -- $line) - if test -n "$cleaned" - echo $cleaned - return 0 - end - else - # Stop scanning once non-comment code begins - break - end - end - return 1 end @@ -66,34 +43,98 @@ function _fish_shortcuts --description "List fish abbreviations and aliases with if type -q abbr set -l abbr_list (abbr --list 2>/dev/null) - for a in $abbr_list - set -l expansion "" - set -l show_line (abbr --show $a 2>/dev/null | string collect) - if test -n "$show_line" - if type -q python3 - set -lx __FISH_SHORTCUT_LINE "$show_line" - set expansion (python3 -c "import os, shlex, sys; line = os.environ['__FISH_SHORTCUT_LINE']; tokens = shlex.split(line); rest = tokens[tokens.index('--') + 2:]; pos = rest.index('--function') if '--function' in rest else -1; exp = rest[pos + 1] if pos != -1 and pos + 1 < len(rest) else (' '.join(rest) if rest else ''); sys.stdout.write(exp)") - set -e __FISH_SHORTCUT_LINE - end + set -l abbr_lines (abbr --show 2>/dev/null | string split "\n") + set -l abbr_names + set -l abbr_expansions + + for line in $abbr_lines + set line (string trim -- $line) + if test -z "$line" + continue + end - if test -z "$expansion" - set expansion (string match -r --groups-only ".*'([^']*)'" -- "$show_line") + set -l parsed_name "" + set -l parsed_expansion "" + + if type -q python3 + set -lx __FISH_SHORTCUT_LINE "$line" + set -l parsed (python3 -c " +import os, shlex + +line = os.environ['__FISH_SHORTCUT_LINE'] +tokens = shlex.split(line) +name = '' +expansion = '' + +try: + idx = tokens.index('--') +except ValueError: + pass +else: + if idx + 1 < len(tokens): + name = tokens[idx + 1] + rest = tokens[idx + 2:] + if '--function' in tokens: + try: + pos = tokens.index('--function') + except ValueError: + pos = -1 + if pos != -1 and pos + 1 < len(tokens): + expansion = tokens[pos + 1] + if not expansion and rest: + expansion = ' '.join(rest) + +print(name) +print(expansion) +") + set -e __FISH_SHORTCUT_LINE + if test (count $parsed) -ge 1 + set parsed_name $parsed[1] end - if test -z "$expansion" - set expansion (string match -r --groups-only '.*"([^"]*)"' -- "$show_line") + if test (count $parsed) -ge 2 + set parsed_expansion $parsed[2] end - if test -z "$expansion" - set -l fallback (string replace -r -- '^abbr\b.* -- \S+\s+' '' -- "$show_line") - set fallback (string trim -- $fallback) - if test -n "$fallback" - set expansion $fallback - end + end + + if test -z "$parsed_name" + set parsed_name (string match -r --groups-only '^abbr\b.* -- (\S+)' -- "$line") + end + + if test -z "$parsed_name" + continue + end + + if test -z "$parsed_expansion" + set parsed_expansion (string match -r --groups-only "'([^']*)'" -- "$line") + end + if test -z "$parsed_expansion" + set parsed_expansion (string match -r --groups-only "\"([^\"]*)\"" -- "$line") + end + if test -z "$parsed_expansion" + set -l fallback (string replace -r -- '^abbr\b.* -- \S+\s*' '' -- "$line") + set fallback (string trim -- $fallback) + if test -n "$fallback" + set parsed_expansion $fallback end - if test -n "$expansion" - set expansion (string trim --chars="'\"" -- $expansion) + end + + set -a abbr_names $parsed_name + set -a abbr_expansions $parsed_expansion + end + + for a in $abbr_list + set -l expansion "" + for idx in (seq (count $abbr_names)) + if test "$abbr_names[$idx]" = "$a" + set expansion $abbr_expansions[$idx] + break end end + if test -n "$expansion" + set expansion (string trim --chars="'\"" -- $expansion) + end + set -l desc "" # If the expansion is a single token and is a function, derive description if test -n "$expansion"