current commit
261017.1 · fix(nushell): adapt the completer to the 0.116 contract (#4934)
#     ____      ____
#    / __/___  / __/
#   / /_/_  / / /_
#  / __/ / /_/ __/
# /_/   /___/_/ completion.nu


# An implementation of completion.nu
# This loads FZF as a Nushell External Completer
# https://www.nushell.sh/cookbook/external_completers.html
#
# 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.


# --- 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
def __fzf_defaults_completion [prepend: string, append: string]: nothing -> string {
  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
  }

  let fzf_default_opts = (__fzf_defaults_completion "" ($env.FZF_COMPLETION_OPTS | default ''))
  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
                     # 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') }
                   | 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 ---

# 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] {
  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
  }
}

# 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} }
  }
}

# --- 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?

  # 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

  # 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 }

  # 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 :