2026-05-23 10:13:46 -04:00
# ____ ____
# / __/___ / __/
# / /_/_ / / /_
# / __/ / /_/ __/
# /_/ /___/_/ completion.nu
# An implementation of completion.nu
# This loads FZF as a Nushell External Completer
# https://www.nushell.sh/cookbook/external_completers.html
2026-10-16 22:10:52 -04:00
#
# Requires Nushell 0.116.0 or newer.
#
# On 0.116+, the registered completer closure must reach a command carrying the
# `@interactive` attribute: interactivity is seen through the closure to that
# command (a closure cannot carry the attribute itself), and that is what makes
# the completion run on the REPL thread with the terminal, so fzf can take it.
# A closure that does not reach such a command runs on a background worker
# without the terminal, where fzf cannot run.
2026-05-23 10:13:46 -04:00
# --- Default Environment Variables ---
# These can be overridden in your config.nu or environment.
# Example: $env.FZF_COMPLETION_TRIGGER = "!<TAB>"
# - $env.FZF_TMUX (default: 0)
# - $env.FZF_TMUX_OPTS (default: empty)
# - $env.FZF_TMUX_HEIGHT (default: 40%)
# - $env.FZF_COMPLETION_TRIGGER (default: '**')
# - $env.FZF_COMPLETION_OPTS (default: empty)
# - $env.FZF_COMPLETION_PATH_OPTS (default: empty)
# - $env.FZF_COMPLETION_DIR_OPTS (default: empty)
$env .FZF_COMPLETION_TRIGGER = $env .FZF_COMPLETION_TRIGGER? | default '**'
# Options for fzf completion in general. e.g. '--border'
$env .FZF_COMPLETION_OPTS = $env .FZF_COMPLETION_OPTS? | default ''
# Options specific to path completion. e.g. '--extended'
$env .FZF_COMPLETION_PATH_OPTS = $env .FZF_COMPLETION_PATH_OPTS? | default ''
# Options specific to directory completion. e.g. '--extended'
$env .FZF_COMPLETION_DIR_OPTS = $env .FZF_COMPLETION_DIR_OPTS? | default ''
$env .FZF_COMPLETION_DIR_COMMANDS = $env .FZF_COMPLETION_DIR_COMMANDS? | default [ 'cd' , 'pushd' , 'rmdir' ]
# --- Helper Functions ---
# Helper to build default fzf options list
2026-05-23 23:34:04 +09:00
def __fzf_defaults_completion [ prepend : string, append : string] : nothing -> string {
2026-05-23 10:13:46 -04:00
let base = $"--height ( $env .FZF_TMUX_HEIGHT? | default '40%' ) --min-height 20+ --bind=ctrl-z:ignore ( $prepend ) "
let opts_file = if ( $env .FZF_DEFAULT_OPTS_FILE? | default '' | is-not-empty ) {
try { open --raw ( $env .FZF_DEFAULT_OPTS_FILE) | str trim } catch { '' }
} else {
''
}
let default_opts = $env .FZF_DEFAULT_OPTS? | default ''
$" ( $base ) ( $opts_file ) ( $default_opts ) ( $append ) " | str trim
}
# Wrapper for running fzf or fzf-tmux
def __fzf_comprun [ context_name: string # e.g., "fzf-completion" , "fzf-helper" - mainly for potential debugging
, query: string # The initial query string for fzf
, fzf_opts_arg: list<string> # Remaining options for fzf/fzf-tmux
] {
let stdin_content = try {
# Collect stdin into a single string. Adjust if structured data is expected.
$in | into string
} catch {
null # Set to null if there's no stdin or an error occurs reading it
}
2026-05-23 23:34:04 +09:00
let fzf_default_opts = ( __fzf_defaults_completion "" ( $env .FZF_COMPLETION_OPTS | default '' ))
2026-05-23 10:13:46 -04:00
let fzf_prefinal_opt = [ '--query' , $query , '--reverse' ] | append $fzf_opts_arg
# Get the configured height, defaulting to '40%'
let height_opt = $env .FZF_TMUX_HEIGHT? | default '40%'
# Determine if fzf should generate its own candidates via walker
let has_walker = ( $fzf_prefinal_opt | find '--walker' | is-not-empty )
# Check for custom comprun function (Nu equivalent)
if ( which _fzf_comprun | is-not-empty ) {
# Note: Nushell doesn't have a direct equivalent to Zsh/Bash `type -t _fzf_comprun`.
# This check assumes a user might define a custom command named `_fzf_comprun`.
_fzf_comprun $context_name $query ...$fzf_prefinal_opt # Pass args correctly to custom function
} else if ( $env .TMUX_PANE? | default '' | into string | is-not-empty ) and (( $env .FZF_TMUX? | default 0 ) != 0 or ( $env .FZF_TMUX_OPTS? | is-not-empty )) {
# Running inside tmux, use fzf-tmux
let final_fzf_opts = if ( $env .FZF_TMUX_OPTS? | is-not-empty ) {
$env .FZF_TMUX_OPTS | split row ' ' | append [ '--' ] | append $fzf_prefinal_opt
} else {
# Use the default -d option with the configured height for fzf-tmux
[ '-d' , $height_opt , '--' ] | append $fzf_prefinal_opt
}
if $has_walker or ( $stdin_content == null) {
with-env { FZF_DEFAULT_OPTS: $fzf_default_opts , FZF_DEFAULT_OPTS_FILE: '' } { fzf-tmux ...$final_fzf_opts }
} else {
$stdin_content | with-env { FZF_DEFAULT_OPTS: $fzf_default_opts , FZF_DEFAULT_OPTS_FILE: '' } { fzf-tmux ...$final_fzf_opts }
}
} else {
# Not in tmux or not configured for fzf-tmux, use fzf directly
let final_fzf_opts = $fzf_prefinal_opt
if $has_walker or ( $stdin_content == null) {
with-env { FZF_DEFAULT_OPTS: $fzf_default_opts , FZF_DEFAULT_OPTS_FILE: '' } { fzf ...$final_fzf_opts }
} else {
$stdin_content | with-env { FZF_DEFAULT_OPTS: $fzf_default_opts , FZF_DEFAULT_OPTS_FILE: '' } { fzf ...$final_fzf_opts }
}
}
}
# Generate host list for ssh/telnet
def __fzf_list_hosts [] {
# Translate the Zsh pipeline using Nu commands and external tools
let ssh_configs = try { open ~/.ssh/config | lines } catch { [] }
let ssh_configs_d = try { open ~/.ssh/config.d/* | lines } catch { [] }
let ssh_config_global = try { open /etc/ssh/ssh_config | lines } catch { [] }
let known_hosts = try { open ~/.ssh/known_hosts | lines } catch { [] }
let hosts_file = try { open /etc/hosts | lines } catch { [] }
[
(
# Process ssh config files
$ssh_configs | append $ssh_configs_d | append $ssh_config_global
2026-07-09 15:24:41 -04:00
# NOTE: str lowercase is new from Nushell 0.114.0. Please upgrade to Nushell >=0.114.0 if you are seeing `extra positional argument` error regarding `str lowercase` in this block.
| where { | it| ( $it | str lowercase | str starts-with 'host' ) or ( $it | str lowercase | str starts-with 'hostname' ) }
2026-05-23 10:13:46 -04:00
| parse --regex '^\s*host(?:name)?\s+(?<hosts>.+)' # Extract hosts after keyword
| default { hosts: null } # Handle lines that don't match regex
| get hosts
| where { | it| $it != null }
| split row ' '
| where { | it| not ( $it = ~ '[*?%]' ) } # Exclude patterns containing *, ?, or %
)
(
# Process known_hosts file
$known_hosts | parse --regex '^(?:\[)?(?<hosts>[a-z0-9.,:_-]+)' # Extract hostnames (possibly in [], possibly comma-separated) - added underscore
| default { hosts: null }
| get hosts
| where { | it| $it != null }
| each { | it| $it | split row ',' } # Split comma-separated hosts if any
| flatten
)
(
# Process /etc/hosts file
$hosts_file | where { | it| not ( $it | str starts-with '#' ) } # Ignore comments
| where { | it| not ( $it | str trim | is-empty ) } # Ignore empty lines
| where { | it| not ( $it | str contains '0.0.0.0' ) } # Ignore 0.0.0.0
| str replace --regex '#.*$' '' # Remove trailing comments
| parse --regex '^\s*\S+\s+(?<hosts>.+)' # Extract hosts part (after IP)
| default { hosts: null }
| get hosts
| where { | it| $it != null }
| split row ' ' # Split multiple hosts on the same line
)
]
| flatten # Combine all lists into a single stream
| where { | it| not ( $it | is-empty ) } # Remove empty entries
| sort | uniq # Sort and remove duplicates
}
# Base function for path/directory completion
def __fzf_generic_path_completion [ prefix: string # The text before the trigger
, fzf_opts_arg: list<string> # Extra options for fzf
, suffix: string # Suffix to add to selection (e.g. , "/")
] {
# --- Determine walker root and initial query from the prefix ---
mut walker_root = "."
mut initial_query = ""
if ( $prefix | is-empty ) {
# Case: "**"
$walker_root = "."
$initial_query = ""
} else if ( $prefix | str contains ( char separator)) {
# Case: "dir/subdir/partial**" or "dir/**"
$walker_root = $prefix | path dirname
$initial_query = $prefix | path basename
# Handle edge case where prefix ends with separator, e.g., "dir/"
if ( $prefix | str ends-with ( char separator)) {
# Remove trailing separator to get the intended directory
$walker_root = $prefix | str substring 0 ..-2
$initial_query = ""
}
# Ensure walker_root isn't empty if prefix was like "/file**"
# or if path dirname returned empty string for some reason (e.g. prefix="file/")
if ( $walker_root | is-empty ) {
if ( $prefix | str starts-with ( char separator)) {
$walker_root = ( char separator)
} else if ( $prefix | str ends-with ( char separator)) {
$walker_root = $prefix | str substring 0 ..-2
} else { $walker_root = "." } # Fallback if dirname weirdly fails
}
} else {
# Case: "partial**" (no slashes)
$walker_root = "."
$initial_query = $prefix
}
# --- Prepare FZF options ---
let completion_type_opts = if $suffix == '/' {
$env .FZF_COMPLETION_DIR_OPTS? | default '' | split row ' ' | where { not ( $in | is-empty )}
} else {
$env .FZF_COMPLETION_PATH_OPTS? | default '' | split row ' ' | where { not ( $in | is-empty )}
}
let walker_type = if ( $suffix == '/' ) {
"dir,follow"
} else {
"file,dir,follow,hidden"
}
# Expand tilde so fzf receives a valid absolute path as walker-root
let needs_tilde_rewrite = ( $walker_root | str starts-with '~' )
let walker_root_expanded = ( $walker_root | path expand)
# Use the 'walker_root' calculated at the beginning
let fzf_all_opts = [ "--scheme=path" , "--walker" , $walker_type , "--walker-root" , $walker_root_expanded ] | append $fzf_opts_arg
| append $completion_type_opts
# Call FZF run
let fzf_selection = ( __fzf_comprun "fzf-path-completion-walker" $initial_query $fzf_all_opts ) | str trim
# --- Return Result ---
if ( $fzf_selection | is-not-empty ) {
# Restore tilde prefix if the user originally typed ~/
let home = $nu .home-dir | path expand
let result = if $needs_tilde_rewrite {
$fzf_selection | lines | each { | line| $line | str replace $home '~' } | str join ' '
} else {
$fzf_selection | lines | str join ' '
}
[ $result ]
} else {
[]
}
}
# Specific path completion wrapper
def _fzf_path_completion [ prefix: string] {
# Zsh args: base, lbuf, _fzf_compgen_path, "-m", "", " "
# Nu: prefix, empty command name (use find), ["-m"], "", " "
__fzf_generic_path_completion $prefix [ "-m" ] ""
}
# General completion helper for commands that feed a list to fzf
# This is called by ssh, kill, and user-defined completers.
def _fzf_complete [ query: string # The initial query string for fzf
, data_gen_closure: closure # Closure that generates candidates
, fzf_opts_arg: list<string> # Extra options for fzf (like -m, +m)
, --post_process_closure: closure # Closure to process the selected item (optional)
] {
# Generate candidates using the provided command
let candidates = try {
do $data_gen_closure
} catch {
# Capture the actual error object provided by the catch block
let actual_error = $in
# Print a more informative error message including the actual error details
print -e $"Error executing data_gen closure. Closure code: ( $data_gen_closure ) . Actual error: ( $actual_error ) "
[]
}
# Run fzf and get selection
let fzf_selection = $candidates | to text
| __fzf_comprun "fzf-helper" $query $fzf_opts_arg
| str trim # Trim potential trailing newline from fzf
# Apply post-processing if closure provided and selection is not empty
let processed_selection = if ( $fzf_selection | is-not-empty ) and ( $post_process_closure | is-not-empty ) {
# Call the post-processing closure with the selection
try {
do $post_process_closure $fzf_selection
} catch {
print -e $"Error executing post_process closure: ( $post_process_closure ) "
$fzf_selection # Return original selection on error
}
} else {
$fzf_selection
}
if not ( $processed_selection | is-empty ) {
[( $processed_selection | lines | str join ' ' )]
} else {
[]
}
}
# SSH/Telnet completion
def _fzf_complete_ssh [ prefix: string
, input_line_before_trigger: string
] {
let words = ( $input_line_before_trigger | split row ' ' )
let word_count = $words | length
# Find the index of the word being completed (which is the prefix)
# If prefix is empty, completion happens after a space, index is word_count
# If prefix is not empty, it's the last word, index is word_count - 1
let completion_index = if ( $prefix | is-empty ) { $word_count } else { $word_count - 1 }
mut handled = false
mut completion_result = [] # List of completion strings to return
# Check for -i, -F, -E flags immediately preceding the cursor position
if $completion_index > 0 {
let prev_arg = ( $words | get ( $completion_index - 1 ))
if ( $prev_arg in [ '-i' , '-F' , '-E' ]) {
$handled = true
# Call path completion with the current prefix
$completion_result = ( _fzf_path_completion $prefix )
}
}
# If not handled by path completion, do host completion
if not $handled {
let user_part = if ( $prefix | str contains "@" ) { ( $prefix | split row "@" | first ) + "@" } else { "" }
# The part after '@' (or the whole prefix if no '@') is the initial query for fzf
let query = if ( $prefix | str contains "@" ) { $prefix | split row "@" | last } else { $prefix }
let host_candidates_gen = {||
__fzf_list_hosts
| each { | host_item| $user_part + $host_item } # Prepend user@ if present in prefix
}
# Zsh options: +m -- ; Nu: pass ["+m"]
# Pass the host part of the prefix to _fzf_complete for the initial query
let selected_host = ( _fzf_complete $query $host_candidates_gen [ "+m" ]) # Pass host_prefix here
if not ( $selected_host | is-empty ) {
$completion_result = $selected_host # _fzf_complete returns a list
}
}
$completion_result
}
# Kill completion post-processor (extracts PID)
def _fzf_complete_kill_post_get_pid [ selected_line: string] {
# Assuming standard ps output where PID is the second column
$selected_line | lines | each { $in | from ssv --noheaders | get 0 .column1 } | to text
}
# Kill completion to get process PID
def _fzf_complete_kill [ query: string] {
let ps_gen_closure = {|| # Define ps generator as a closure
# Try standard ps, then busybox, then cygwin format approximation
# Use `^ps` to ensure external command execution
try {
^ps -eo user,pid,ppid,start,time,command | complete | if $in .exit_code == 0 { $in .stdout | lines } else { error make { msg: "ps failed" } }
} catch {
try {
^ps -eo user,pid,ppid,time,args | complete | if $in .exit_code == 0 { $in .stdout | lines } else { error make { msg: "ps failed" } }
} catch {
try {
^ps --everyone --full --windows | complete | if $in .exit_code == 0 { $in .stdout | lines } else { error make { msg: "ps failed" } }
} catch {
print -e "Error: ps command failed."
[] # Return empty list on failure
}
}
}
}
# Note: Complex Zsh FZF bindings for kill (click-header transformer) are omitted for simplicity.
# Users can set custom bindings via FZF_DEFAULT_OPTS if needed.
let kill_post_closure = { | selected_line| _fzf_complete_kill_post_get_pid $selected_line }
let fzf_opts = [ "-m" , "--header-lines=1" , "--no-preview" , "--wrap" , "--color" , "fg:dim,nth:regular" ]
_fzf_complete $query $ps_gen_closure $fzf_opts --post_process_closure $kill_post_closure
}
# --- Main FZF External Completer ---
2026-10-16 22:10:52 -04:00
# Main completion logic. It receives the token list of the command being
# completed (the legacy `spans`). Called directly, so no attribute is needed.
def __fzf_completer_logic [ spans] {
2026-05-23 10:13:46 -04:00
let trigger: string = $env .FZF_COMPLETION_TRIGGER? | default '**'
if ( $trigger | is-empty ) { return null } # Cannot work with empty trigger
if (( $spans | length ) == 0 ) { return null } # Nothing to complete
let last_span = $spans | last
if ( $last_span | str ends-with $trigger ) {
# --- Trigger Found ---
# Skip sudo to determine the actual command
let cmd_spans = if ( $spans | first ) == "sudo" { $spans | skip 1 } else { $spans }
let cmd_word = ( $cmd_spans | first | default "" )
# Calculate the prefix (part before the trigger in the last span)
let prefix = $last_span | str substring 0 ..( -1 * ( $trigger | str length ) - 1 )
# Reconstruct the line content *before* the trigger for context
# This is an approximation based on spans
let line_without_trigger = $cmd_spans | take (( $cmd_spans | length ) - 1 ) | append $prefix | str join ' '
# --- Dispatch to Completer ---
mut completion_results = [] # Will hold the list of strings from the completer
# Check for user-defined completer in $env.FZF_COMPLETERS first.
# Users can define custom completers in their config.nu as a record of closures:
# $env.FZF_COMPLETERS = { git: {|prefix, spans| ... }, docker: {|prefix, spans| ... } }
# Each closure receives the prefix (text before the trigger) and the full
# command spans (e.g. ["pacman", "-S", "vim**"]), and should return either:
# - a list of candidate strings, or
# - a record { candidates: [...], opts: [...], post: {|sel| ...} } to pass
# custom fzf options and/or a post-processing closure.
# See shell/completion-examples.nu for examples.
let user_completers = ( $env .FZF_COMPLETERS? | default {})
if ( $cmd_word in $user_completers ) {
let user_gen = ( $user_completers | get $cmd_word )
let user_result = ( do $user_gen $prefix $cmd_spans )
if ( $user_result | describe | str starts-with 'record' ) {
let candidates = ( $user_result | get candidates)
let fzf_opts = ( $user_result | get opts? | default [ "-m" ])
let post = ( $user_result | get post? | default null)
if ( $post != null) {
$completion_results = ( _fzf_complete $prefix {|| $candidates } $fzf_opts --post_process_closure $post )
} else {
$completion_results = ( _fzf_complete $prefix {|| $candidates } $fzf_opts )
}
} else {
$completion_results = ( _fzf_complete $prefix {|| $user_result } [ "-m" ])
}
} else {
match $cmd_word {
"ssh" | "scp" | "sftp" | "telnet" = > { $completion_results = ( _fzf_complete_ssh $prefix $line_without_trigger ) }
"kill" = > { $completion_results = ( _fzf_complete_kill $prefix ) }
_ if ( $cmd_word in $env .FZF_COMPLETION_DIR_COMMANDS) = > {
$completion_results = ( __fzf_generic_path_completion $prefix [] "/" )
}
_ = > {
# Default to path completion if no specific command matches
$completion_results = ( _fzf_path_completion $prefix )
}
}
}
# --- Return Results ---
# The _fzf_... functions return a list of completion strings.
# Nushell's completer expects the suggestions for the token being completed (prefix + trigger).
# The results from the helper functions should be the final desired strings.
# We don't need to manually add spaces; Nushell handles that.
$completion_results # Return the list directly
} else {
# --- Trigger Not Found ---
# Return null to let Nushell fall back to other completers (e.g., default file completion).
null
}
}
2026-10-16 22:10:52 -04:00
# Nushell 0.116+ entry point. The registered closure must END with a call to
# this command for the completion to run on the REPL thread with the terminal.
#
# It declares `place` (a named input) rather than the legacy `spans`, so the
# completer never goes through the deprecated positional bridge. `$place.command`
# is the token list of the command being completed, the same value the old
# `spans` held (with a trailing "" when the cursor sits on an empty slot).
@interactive
def __fzf_external_completer [ place] {
let result = ( __fzf_completer_logic $place .command)
if $result != null {
$result
} else {
# Fall back to the previously configured external completer, if any. It is
# handed the new `place` input first. A completer that still declares the
# legacy `spans` parameter errors on a record, so it is retried with the
# token list, which is what the old positional bridge handed it.
let previous = ( $env .__fzf_previous_completer? | default null)
let previous_result = if $previous != null {
try {
do $previous $place
} catch {
try { do $previous $place .command } catch { null }
}
} else {
null
}
# An @interactive completer cannot decline with null: null reads as a
# dismissed picker with an empty, final answer. Request the fallback
# explicitly so file completion still runs when the trigger is absent.
if $previous_result != null { $previous_result } else { { completions: [] , fallback: true} }
}
}
2026-05-23 10:13:46 -04:00
# --- WRAPPER AND REGISTRATION ---
# Guard against re-sourcing: wrapping the completer multiple times would
# nest wrappers and grow the call chain on every reload.
if ( $env .__fzf_completer_registered? | default false) != true {
# Get the currently configured external completer, if any exists
let previous_external_completer = $env .config? | get completions? | get external? | get completer?
2026-10-16 22:10:52 -04:00
# Stash it for `__fzf_external_completer`: a def cannot capture a local
# variable, and the fallback to the previous completer happens inside it.
$env .__fzf_previous_completer = $previous_external_completer
2026-05-23 10:13:46 -04:00
2026-10-16 22:10:52 -04:00
# The registered closure must END with a call to an `@interactive` command so
# that Nushell 0.116+ runs the completion on the REPL thread with the terminal
# (it inspects the last pipeline element of the closure). Declaring `place`
# instead of `spans` keeps it off the deprecated positional bridge.
let fzf_wrapper_completer = { | place| __fzf_external_completer $place }
2026-05-23 10:13:46 -04:00
# Register the new wrapper completer
# This ensures external completions are enabled and sets our wrapper.
$env .config = $env .config | upsert completions {
external: {
enable: true
completer: $fzf_wrapper_completer
}
}
$env .__fzf_completer_registered = true
}
# vim: set sts=2 ts=2 sw=2 tw=120 et :