Skip to main content

zsh/compsys/ported/
compinit.rs

1//! Port of `compinit` from `Completion/compinit`.
2//!
3//! Full upstream body (574 lines verbatim):
4//! ```text
5//! sh:  1  # Initialisation for new style completion. This mainly contains some helper
6//! sh:  2  # functions and setup. Everything else is split into different files that
7//! sh:  3  # will automatically be made autoloaded (see the end of this file).  The
8//! sh:  4  # names of the files that will be considered for autoloading are those that
9//! sh:  5  # begin with an underscores (like `_condition).
10//! sh:  6  #
11//! sh:  7  # The first line of each of these files is read and must indicate what
12//! sh:  8  # should be done with its contents:
13//! sh:  9  #
14//! sh: 10  #   `#compdef <names ...>'
15//! sh: 11  #     If the first line looks like this, the file is autoloaded as a
16//! sh: 12  #     function and that function will be called to generate the matches
17//! sh: 13  #     when completing for one of the commands whose <names> are given.
18//! sh: 14  #     The names may also be interspersed with `-T <assoc>' options
19//! sh: 15  #     specifying for which set of functions this should be added.
20//! sh: 16  #
21//! sh: 17  #   `#compdef -[pP] <patterns ...>'
22//! sh: 18  #     This defines a function that should be called to generate matches
23//! sh: 19  #     for commands whose name matches <pattern>. Note that only one pattern
24//! sh: 20  #     may be given.
25//! sh: 21  #
26//! sh: 22  #   `#compdef -k <style> [ <key-sequence> ... ]'
27//! sh: 23  #     This is used to bind special completions to all the given
28//! sh: 24  #     <key-sequence>(s). The <style> is the name of one of the built-in
29//! sh: 25  #     completion widgets (complete-word, delete-char-or-list,
30//! sh: 26  #     expand-or-complete, expand-or-complete-prefix, list-choices,
31//! sh: 27  #     menu-complete, menu-expand-or-complete, or reverse-menu-complete).
32//! sh: 28  #     This creates a widget behaving like <style> so that the
33//! sh: 29  #     completions are chosen as given in the rest of the file,
34//! sh: 30  #     rather than by the context.  The widget has the same name as
35//! sh: 31  #     the autoload file and can be bound using bindkey in the normal way.
36//! sh: 32  #
37//! sh: 33  #   `#compdef -K <widget-name> <style> <key-sequence> [ ... ]'
38//! sh: 34  #     This is similar to -k, except it takes any number of sets of
39//! sh: 35  #     three arguments.  In each set, the widget <widget-name> will
40//! sh: 36  #     be defined, which will behave as <style>, as with -k, and will
41//! sh: 37  #     be bound to <key-sequence>, exactly one of which must be defined.
42//! sh: 38  #     <widget-name> must be different for each:  this must begin with an
43//! sh: 39  #     underscore, else one will be added, and should not clash with other
44//! sh: 40  #     completion widgets (names based on the name of the function are the
45//! sh: 41  #     clearest), but is otherwise arbitrary.  It can be tested in the
46//! sh: 42  #     function by the parameter $WIDGET.
47//! sh: 43  #
48//! sh: 44  #   `#autoload [ <options> ]'
49//! sh: 45  #     This is for helper functions that are not used to
50//! sh: 46  #     generate matches, but should automatically be loaded
51//! sh: 47  #     when they are called. The <options> will be given to the
52//! sh: 48  #     autoload builtin when making the function autoloaded. Note
53//! sh: 49  #     that this need not include `-U' and `-z'.
54//! sh: 50  #
55//! sh: 51  # Note that no white space is allowed between the `#' and the rest of
56//! sh: 52  # the string.
57//! sh: 53  #
58//! sh: 54  # Functions that are used to generate matches should return zero if they
59//! sh: 55  # were able to add matches and non-zero otherwise.
60//! sh: 56  #
61//! sh: 57  # See the file `compdump' for how to speed up initialisation.
62//! sh: 58
63//! sh: 59  # If we got the `-d'-flag, we will automatically dump the new state (at
64//! sh: 60  # the end).  This takes the dumpfile as an argument.  -d (with the
65//! sh: 61  # default dumpfile) is now the default; to turn off dumping use -D.
66//! sh: 62
67//! sh: 63  # If the dumpfile is being regenerated and you don't know why, you can use
68//! sh: 64  # the -w flag to see if it was because -D was passed, zsh version mismatched,
69//! sh: 65  # or number of files in $fpath differed.
70//! sh: 66
71//! sh: 67  # The -C flag bypasses both the check for rebuilding the dump file and the
72//! sh: 68  # usual call to compaudit; the -i flag causes insecure directories found by
73//! sh: 69  # compaudit to be ignored, and the -u flag causes all directories found by
74//! sh: 70  # compaudit to be used (without security checking).  Otherwise the user is
75//! sh: 71  # queried for whether to use or ignore the insecure directories (which
76//! sh: 72  # means compinit should not be called from non-interactive shells).
77//! sh: 73
78//! sh: 74  emulate -L zsh
79//! sh: 75  setopt extendedglob
80//! sh: 76
81//! sh: 77  typeset _i_dumpfile _i_files _i_line _i_done _i_dir _i_autodump=1
82//! sh: 78  typeset _i_tag _i_file _i_addfiles _i_fail=ask _i_check=yes _i_name _i_why
83//! sh: 79
84//! sh: 80  while [[ $# -gt 0 && $1 = -[dDiuCw] ]]; do
85//! sh: 81    case "$1" in
86//! sh: 82    -d)
87//! sh: 83      _i_autodump=1
88//! sh: 84      shift
89//! sh: 85      if [[ $# -gt 0 && "$1" != -[dfQC] ]]; then
90//! sh: 86        _i_dumpfile="$1"
91//! sh: 87        shift
92//! sh: 88      fi
93//! sh: 89      ;;
94//! sh: 90    -D)
95//! sh: 91      _i_autodump=0
96//! sh: 92      shift
97//! sh: 93      ;;
98//! sh: 94    -i)
99//! sh: 95      _i_fail=ign
100//! sh: 96      shift
101//! sh: 97      ;;
102//! sh: 98    -u)
103//! sh: 99      _i_fail=use
104//! sh:100      shift
105//! sh:101      ;;
106//! sh:102    -C)
107//! sh:103      _i_check=
108//! sh:104      shift
109//! sh:105      ;;
110//! sh:106    -w)
111//! sh:107      _i_why=1
112//! sh:108      shift
113//! sh:109      ;;
114//! sh:110    esac
115//! sh:111  done
116//! sh:112
117//! sh:113  # The associative arrays containing the definitions for the commands and
118//! sh:114  # services.
119//! sh:115
120//! sh:116  typeset -gHA _comps _services _patcomps _postpatcomps
121//! sh:117
122//! sh:118  # `_compautos' contains the names and options for autoloaded functions
123//! sh:119  # that get options.
124//! sh:120
125//! sh:121  typeset -gHA _compautos
126//! sh:122
127//! sh:123  # The associative array use to report information about the last
128//! sh:124  # completion to the outside.
129//! sh:125
130//! sh:126  typeset -gHA _lastcomp
131//! sh:127
132//! sh:128  # Remember dumpfile.
133//! sh:129  if [[ -n $_i_dumpfile ]]; then
134//! sh:130    # Explicitly supplied dumpfile.
135//! sh:131    typeset -g _comp_dumpfile="$_i_dumpfile"
136//! sh:132  else
137//! sh:133    typeset -g _comp_dumpfile="${ZDOTDIR:-$HOME}/.zcompdump"
138//! sh:134  fi
139//! sh:135
140//! sh:136  # The standard options set in completion functions.
141//! sh:137
142//! sh:138  typeset -gHa _comp_options
143//! sh:139  _comp_options=(
144//! sh:140         bareglobqual
145//! sh:141         extendedglob
146//! sh:142         glob
147//! sh:143         multibyte
148//! sh:144         multifuncdef
149//! sh:145         nullglob
150//! sh:146         rcexpandparam
151//! sh:147         unset
152//! sh:148      NO_allexport
153//! sh:149      NO_aliases
154//! sh:150      NO_autonamedirs
155//! sh:151      NO_cshnullglob
156//! sh:152      NO_cshjunkiequotes
157//! sh:153      NO_errexit
158//! sh:154      NO_errreturn
159//! sh:155      NO_globassign
160//! sh:156      NO_globsubst
161//! sh:157      NO_histsubstpattern
162//! sh:158      NO_ignorebraces
163//! sh:159      NO_ignoreclosebraces
164//! sh:160      NO_kshglob
165//! sh:161      NO_ksharrays
166//! sh:162      NO_kshtypeset
167//! sh:163      NO_markdirs
168//! sh:164      NO_octalzeroes
169//! sh:165      NO_posixbuiltins
170//! sh:166      NO_posixidentifiers
171//! sh:167      NO_shwordsplit
172//! sh:168      NO_shglob
173//! sh:169      NO_typesettounset
174//! sh:170      NO_warnnestedvar
175//! sh:171      NO_warncreateglobal
176//! sh:172  )
177//! sh:173
178//! sh:174  # And this one should be `eval'ed at the beginning of every entry point
179//! sh:175  # to the completion system.  It sets up what we currently consider a
180//! sh:176  # sane environment.  That means we set the options above, make sure we
181//! sh:177  # have a valid stdin descriptor (zle closes it before calling widgets)
182//! sh:178  # and don't get confused by user's ZERR trap handlers.
183//! sh:179
184//! sh:180  typeset -gH _comp_setup='local -A _comp_caller_options;
185//! sh:181               _comp_caller_options=(${(kv)options[@]});
186//! sh:182               setopt localoptions localtraps localpatterns ${_comp_options[@]};
187//! sh:183               local IFS=$'\'\ \\t\\r\\n\\0\'';
188//! sh:184               builtin enable -p \| \~ \( \? \* \[ \< \^ \# 2>&-;
189//! sh:185               exec </dev/null;
190//! sh:186               trap - ZERR;
191//! sh:187               local -a reply;
192//! sh:188               local REPLY;
193//! sh:189               local REPORTTIME;
194//! sh:190               unset REPORTTIME'
195//! sh:191
196//! sh:192  # These can hold names of functions that are to be called before/after all
197//! sh:193  # matches have been generated.
198//! sh:194
199//! sh:195  typeset -ga compprefuncs comppostfuncs
200//! sh:196  compprefuncs=()
201//! sh:197  comppostfuncs=()
202//! sh:198
203//! sh:199  # Loading it now ensures that the `funcstack' parameter is always correct.
204//! sh:200
205//! sh:201  : $funcstack
206//! sh:202
207//! sh:203  # This function is used to register or delete completion functions. For
208//! sh:204  # registering completion functions, it is invoked with the name of the
209//! sh:205  # function as it's first argument (after the options). The other
210//! sh:206  # arguments depend on what type of completion function is defined. If
211//! sh:207  # none of the `-p' and `-k' options is given a function for a command is
212//! sh:208  # defined. The arguments after the function name are then interpreted as
213//! sh:209  # the names of the command for which the function generates matches.
214//! sh:210  # With the `-p' option a function for a name pattern is defined. This
215//! sh:211  # function will be invoked when completing for a command whose name
216//! sh:212  # matches the pattern given as argument after the function name (in this
217//! sh:213  # case only one argument is accepted).
218//! sh:214  # The option `-P' is like `-p', but the function will be called after
219//! sh:215  # trying to find a function defined for the command on the line if no
220//! sh:216  # such function could be found.
221//! sh:217  # With the `-k' option a function for a special completion keys is
222//! sh:218  # defined and immediately bound to those keys. Here, the extra arguments
223//! sh:219  # are the name of one of the builtin completion widgets and any number
224//! sh:220  # of key specifications as accepted by the `bindkey' builtin.
225//! sh:221  # In any case the `-a' option may be given which makes the function
226//! sh:222  # whose name is given as the first argument be autoloaded. When defining
227//! sh:223  # a function for command names the `-n' option may be given and keeps
228//! sh:224  # the definitions from overriding any previous definitions for the
229//! sh:225  # commands; with `-k', the `-n' option prevents compdef from rebinding
230//! sh:226  # a key sequence which is already bound.
231//! sh:227  # For deleting definitions, the `-d' option must be given. Without the
232//! sh:228  # `-p' option, this deletes definitions for functions for the commands
233//! sh:229  # whose names are given as arguments. If combined with the `-p' option
234//! sh:230  # it deletes the definitions for the patterns given as argument.
235//! sh:231  # The `-d' option may not be combined with the `-k' option, i.e.
236//! sh:232  # definitions for key function can not be removed.
237//! sh:233  #
238//! sh:234  # Examples:
239//! sh:235  #
240//! sh:236  #  compdef -a foo bar baz
241//! sh:237  #    make the completion for the commands `bar' and `baz' use the
242//! sh:238  #    function `foo' and make this function be autoloaded
243//! sh:239  #
244//! sh:240  #  compdef -p foo 'c*'
245//! sh:241  #    make completion for all command whose name begins with a `c'
246//! sh:242  #    generate matches by calling the function `foo' before generating
247//! sh:243  #    matches defined for the command itself
248//! sh:244  #
249//! sh:245  #  compdef -k foo list-choices '^X^M' '\C-xm'
250//! sh:246  #    make the function `foo' be invoked when typing `Control-X Control-M'
251//! sh:247  #    or `Control-X m'; the function should generate matches and will
252//! sh:248  #    behave like the `list-choices' builtin widget
253//! sh:249  #
254//! sh:250  #  compdef -d bar baz
255//! sh:251  #   delete the definitions for the command names `bar' and `baz'
256//! sh:252
257//! sh:253  compdef() {
258//! sh:254    local opt autol type func delete eval new i ret=0 cmd svc
259//! sh:255    local -a match mbegin mend
260//! sh:256
261//! sh:257    emulate -L zsh
262//! sh:258    setopt extendedglob
263//! sh:259
264//! sh:260    # Get the options.
265//! sh:261
266//! sh:262    if (( ! $# )); then
267//! sh:263      print -u2 "$0: I need arguments"
268//! sh:264      return 1
269//! sh:265    fi
270//! sh:266
271//! sh:267    while getopts "anpPkKde" opt; do
272//! sh:268      case "$opt" in
273//! sh:269      a)    autol=yes;;
274//! sh:270      n)    new=yes;;
275//! sh:271      [pPkK]) if [[ -n "$type" ]]; then
276//! sh:272              # Error if both `-p' and `-k' are given (or one of them
277//! sh:273  	    # twice).
278//! sh:274              print -u2 "$0: type already set to $type"
279//! sh:275  	    return 1
280//! sh:276  	  fi
281//! sh:277  	  if [[ "$opt" = p ]]; then
282//! sh:278  	    type=pattern
283//! sh:279  	  elif [[ "$opt" = P ]]; then
284//! sh:280  	    type=postpattern
285//! sh:281  	  elif [[ "$opt" = K ]]; then
286//! sh:282  	    type=widgetkey
287//! sh:283  	  else
288//! sh:284  	    type=key
289//! sh:285  	  fi
290//! sh:286  	  ;;
291//! sh:287      d) delete=yes;;
292//! sh:288      e) eval=yes;;
293//! sh:289      esac
294//! sh:290    done
295//! sh:291    shift OPTIND-1
296//! sh:292
297//! sh:293    if (( ! $# )); then
298//! sh:294      print -u2 "$0: I need arguments"
299//! sh:295      return 1
300//! sh:296    fi
301//! sh:297
302//! sh:298    if [[ -z "$delete" ]]; then
303//! sh:299      # If the first word contains an equal sign, all words must contain one
304//! sh:300      # and we define which services to use for the commands.
305//! sh:301
306//! sh:302      if [[ -z "$eval" ]] && [[ "$1" = *\=* ]]; then
307//! sh:303        while (( $# )); do
308//! sh:304          if [[ "$1" = *\=* ]]; then
309//! sh:305  	  cmd="${1%%\=*}"
310//! sh:306  	  svc="${1#*\=}"
311//! sh:307            func="$_comps[${_services[(r)$svc]:-$svc}]"
312//! sh:308            [[ -n ${_services[$svc]} ]] &&
313//! sh:309                svc=${_services[$svc]}
314//! sh:310  	  [[ -z "$func" ]] &&
315//! sh:311  	      func="${${_patcomps[(K)$svc][1]}:-${_postpatcomps[(K)$svc][1]}}"
316//! sh:312            if [[ -n "$func" ]]; then
317//! sh:313  	    _comps[$cmd]="$func"
318//! sh:314  	    _services[$cmd]="$svc"
319//! sh:315  	  else
320//! sh:316  	    print -u2 "$0: unknown command or service: $svc"
321//! sh:317  	    ret=1
322//! sh:318  	  fi
323//! sh:319  	else
324//! sh:320  	  print -u2 "$0: invalid argument: $1"
325//! sh:321  	  ret=1
326//! sh:322  	fi
327//! sh:323          shift
328//! sh:324        done
329//! sh:325
330//! sh:326        return ret
331//! sh:327      fi
332//! sh:328
333//! sh:329      # Adding definitions, first get the name of the function name
334//! sh:330      # and probably do autoloading.
335//! sh:331
336//! sh:332      func="$1"
337//! sh:333      [[ -n "$autol" ]] && autoload -rUz "$func"
338//! sh:334      shift
339//! sh:335
340//! sh:336      case "$type" in
341//! sh:337      widgetkey)
342//! sh:338        while [[ -n $1 ]]; do
343//! sh:339  	if [[ $# -lt 3 ]]; then
344//! sh:340  	  print -u2 "$0: compdef -K requires <widget> <comp-widget> <key>"
345//! sh:341  	  return 1
346//! sh:342  	fi
347//! sh:343  	[[ $1 = _* ]] || 1="_$1"
348//! sh:344  	[[ $2 = .* ]] || 2=".$2"
349//! sh:345          [[ $2 = .menu-select ]] && zmodload -i zsh/complist
350//! sh:346  	zle -C "$1" "$2" "$func"
351//! sh:347  	if [[ -n $new ]]; then
352//! sh:348  	  bindkey "$3" | IFS=$' \t' read -A opt
353//! sh:349  	  [[ $opt[-1] = undefined-key ]] && bindkey "$3" "$1"
354//! sh:350  	else
355//! sh:351  	  bindkey "$3" "$1"
356//! sh:352  	fi
357//! sh:353  	shift 3
358//! sh:354        done
359//! sh:355        ;;
360//! sh:356      key)
361//! sh:357        if [[ $# -lt 2 ]]; then
362//! sh:358          print -u2 "$0: missing keys"
363//! sh:359  	return 1
364//! sh:360        fi
365//! sh:361
366//! sh:362        # Define the widget.
367//! sh:363        if [[ $1 = .* ]]; then
368//! sh:364          [[ $1 = .menu-select ]] && zmodload -i zsh/complist
369//! sh:365  	zle -C "$func" "$1" "$func"
370//! sh:366        else
371//! sh:367          [[ $1 = menu-select ]] && zmodload -i zsh/complist
372//! sh:368  	zle -C "$func" ".$1" "$func"
373//! sh:369        fi
374//! sh:370        shift
375//! sh:371
376//! sh:372        # And bind the keys...
377//! sh:373        for i; do
378//! sh:374          if [[ -n $new ]]; then
379//! sh:375  	   bindkey "$i" | IFS=$' \t' read -A opt
380//! sh:376  	   [[ $opt[-1] = undefined-key ]] || continue
381//! sh:377  	fi
382//! sh:378          bindkey "$i" "$func"
383//! sh:379        done
384//! sh:380        ;;
385//! sh:381      *)
386//! sh:382        # For commands store the function name in the
387//! sh:383        # associative array, command names as keys.
388//! sh:384        while (( $# )); do
389//! sh:385          if [[ "$1" = -N ]]; then
390//! sh:386            type=normal
391//! sh:387          elif [[ "$1" = -p ]]; then
392//! sh:388            type=pattern
393//! sh:389          elif [[ "$1" = -P ]]; then
394//! sh:390            type=postpattern
395//! sh:391          else
396//! sh:392            case "$type" in
397//! sh:393            pattern)
398//! sh:394  	    if [[ $1 = (#b)(*)=(*) ]]; then
399//! sh:395  	      _patcomps[$match[1]]="=$match[2]=$func"
400//! sh:396  	    else
401//! sh:397  	      _patcomps[$1]="$func"
402//! sh:398  	    fi
403//! sh:399              ;;
404//! sh:400            postpattern)
405//! sh:401  	    if [[ $1 = (#b)(*)=(*) ]]; then
406//! sh:402  	      _postpatcomps[$match[1]]="=$match[2]=$func"
407//! sh:403  	    else
408//! sh:404  	      _postpatcomps[$1]="$func"
409//! sh:405  	    fi
410//! sh:406              ;;
411//! sh:407            *)
412//! sh:408              if [[ "$1" = *\=* ]]; then
413//! sh:409  	      cmd="${1%%\=*}"
414//! sh:410  	      svc=yes
415//! sh:411              else
416//! sh:412  	      cmd="$1"
417//! sh:413  	      svc=
418//! sh:414              fi
419//! sh:415              if [[ -z "$new" || -z "${_comps[$1]}" ]]; then
420//! sh:416                _comps[$cmd]="$func"
421//! sh:417  	      [[ -n "$svc" ]] && _services[$cmd]="${1#*\=}"
422//! sh:418  	    fi
423//! sh:419              ;;
424//! sh:420            esac
425//! sh:421          fi
426//! sh:422          shift
427//! sh:423        done
428//! sh:424        ;;
429//! sh:425      esac
430//! sh:426    else
431//! sh:427      # Handle the `-d' option, deleting.
432//! sh:428
433//! sh:429      case "$type" in
434//! sh:430      pattern)
435//! sh:431        unset "_patcomps[$^@]"
436//! sh:432        ;;
437//! sh:433      postpattern)
438//! sh:434        unset "_postpatcomps[$^@]"
439//! sh:435        ;;
440//! sh:436      key)
441//! sh:437        # Oops, cannot do that yet.
442//! sh:438
443//! sh:439        print -u2 "$0: cannot restore key bindings"
444//! sh:440        return 1
445//! sh:441        ;;
446//! sh:442      *)
447//! sh:443        unset "_comps[$^@]"
448//! sh:444      esac
449//! sh:445    fi
450//! sh:446  }
451//! sh:447
452//! sh:448  # Now we automatically make the definition files autoloaded.
453//! sh:449
454//! sh:450  typeset _i_wdirs _i_wfiles
455//! sh:451
456//! sh:452  _i_wdirs=()
457//! sh:453  _i_wfiles=()
458//! sh:454
459//! sh:455  autoload -RUz compaudit
460//! sh:456  if [[ -n "$_i_check" ]]; then
461//! sh:457    typeset _i_q
462//! sh:458    if ! eval compaudit; then
463//! sh:459      if [[ -n "$_i_q" ]]; then
464//! sh:460        if [[ "$_i_fail" = ask ]]; then
465//! sh:461          if ! read -q \
466//! sh:462  "?zsh compinit: insecure $_i_q, run compaudit for list.
467//! sh:463  Ignore insecure $_i_q and continue [y] or abort compinit [n]? "; then
468//! sh:464  	  print -u2 "$0: initialization aborted"
469//! sh:465            unfunction compinit compdef
470//! sh:466            unset _comp_dumpfile _comp_secure compprefuncs comppostfuncs \
471//! sh:467                  _comps _patcomps _postpatcomps _compautos _lastcomp
472//! sh:468
473//! sh:469            return 1
474//! sh:470          fi
475//! sh:471        fi
476//! sh:472        fpath=(${fpath:|_i_wdirs})
477//! sh:473        (( $#_i_wfiles )) && _i_files=( "${(@)_i_files:#(${(j:|:)_i_wfiles%.zwc})}"  )
478//! sh:474        (( $#_i_wdirs ))  && _i_files=( "${(@)_i_files:#(${(j:|:)_i_wdirs%.zwc})/*}" )
479//! sh:475      fi
480//! sh:476      typeset -g _comp_secure=yes
481//! sh:477    fi
482//! sh:478  fi
483//! sh:479
484//! sh:480  # Make sure compdump is available, even if we aren't going to use it.
485//! sh:481  autoload -RUz compdump compinstall
486//! sh:482
487//! sh:483  # If we have a dump file, load it.
488//! sh:484
489//! sh:485  _i_done=''
490//! sh:486
491//! sh:487  if [[ -f "$_comp_dumpfile" ]]; then
492//! sh:488    if [[ -n "$_i_check" ]]; then
493//! sh:489      IFS=$' \t' read -rA _i_line < "$_comp_dumpfile"
494//! sh:490      if [[ _i_autodump -eq 1 && $_i_line[2] -eq $#_i_files &&
495//! sh:491          $ZSH_VERSION = $_i_line[4] ]]
496//! sh:492      then
497//! sh:493        builtin . "$_comp_dumpfile"
498//! sh:494        _i_done=yes
499//! sh:495      elif [[ _i_why -eq 1 ]]; then
500//! sh:496        print -nu2 "Loading dump file skipped, regenerating"
501//! sh:497        local pre=" because: "
502//! sh:498        if [[ _i_autodump -ne 1 ]]; then
503//! sh:499          print -nu2 $pre"-D flag given"
504//! sh:500          pre=", "
505//! sh:501        fi
506//! sh:502        if [[ $_i_line[2] -ne $#_i_files ]]; then
507//! sh:503          print -nu2 $pre"number of files in dump $_i_line[2] differ from files found in \$fpath $#_i_files"
508//! sh:504          pre=", "
509//! sh:505        fi
510//! sh:506        if [[ $ZSH_VERSION != $_i_line[4] ]]; then
511//! sh:507          print -nu2 $pre"zsh version changed from $_i_line[4] to $ZSH_VERSION"
512//! sh:508        fi
513//! sh:509        print -u2
514//! sh:510      fi
515//! sh:511    else
516//! sh:512      builtin . "$_comp_dumpfile"
517//! sh:513      _i_done=yes
518//! sh:514    fi
519//! sh:515  elif [[ _i_why -eq 1 ]]; then
520//! sh:516    print -u2 "No existing compdump file found, regenerating"
521//! sh:517  fi
522//! sh:518  if [[ -z "$_i_done" ]]; then
523//! sh:519    typeset -A _i_test
524//! sh:520
525//! sh:521    for _i_dir in $fpath; do
526//! sh:522      [[ $_i_dir = . ]] && continue
527//! sh:523      (( $_i_wdirs[(I)$_i_dir] )) && continue
528//! sh:524      for _i_file in $_i_dir/^([^_]*|*[\;\|\&]*|*~|*.zwc)(N); do
529//! sh:525        _i_name="${_i_file:t}"
530//! sh:526        (( $+_i_test[$_i_name] + $_i_wfiles[(I)$_i_file] )) && continue
531//! sh:527        _i_test[$_i_name]=yes
532//! sh:528        IFS=$' \t' read -rA _i_line < $_i_file
533//! sh:529        _i_tag=$_i_line[1]
534//! sh:530        shift _i_line
535//! sh:531        case $_i_tag in
536//! sh:532        (\#compdef)
537//! sh:533  	if [[ $_i_line[1] = -[pPkK](n|) ]]; then
538//! sh:534  	  compdef ${_i_line[1]}na "${_i_name}" "${(@)_i_line[2,-1]}"
539//! sh:535  	else
540//! sh:536  	  compdef -na "${_i_name}" "${_i_line[@]}"
541//! sh:537  	fi
542//! sh:538  	;;
543//! sh:539        (\#autoload)
544//! sh:540  	autoload -rUz "$_i_line[@]" ${_i_name}
545//! sh:541  	[[ "$_i_line" != \ # ]] && _compautos[${_i_name}]="$_i_line"
546//! sh:542  	;;
547//! sh:543        esac
548//! sh:544      done
549//! sh:545    done
550//! sh:546
551//! sh:547    # If autodumping was requested, do it now.
552//! sh:548
553//! sh:549    if [[ $_i_autodump = 1 ]]; then
554//! sh:550      compdump
555//! sh:551    fi
556//! sh:552  fi
557//! sh:553
558//! sh:554  # Rebind the standard widgets
559//! sh:555  for _i_line in complete-word delete-char-or-list expand-or-complete \
560//! sh:556    expand-or-complete-prefix list-choices menu-complete \
561//! sh:557    menu-expand-or-complete reverse-menu-complete; do
562//! sh:558    zle -C $_i_line .$_i_line _main_complete
563//! sh:559  done
564//! sh:560  zle -la menu-select && zle -C menu-select .menu-select _main_complete
565//! sh:561
566//! sh:562  # If the default completer set includes _expand, and tab is bound
567//! sh:563  # to expand-or-complete, rebind it to complete-word instead.
568//! sh:564  bindkey '^i' | IFS=$' \t' read -A _i_line
569//! sh:565  if [[ ${_i_line[2]} = expand-or-complete ]] &&
570//! sh:566    zstyle -a ':completion:' completer _i_line &&
571//! sh:567    (( ${_i_line[(i)_expand]} <= ${#_i_line} )); then
572//! sh:568    bindkey '^i' complete-word
573//! sh:569  fi
574//! sh:570
575//! sh:571  unfunction compinit compaudit
576//! sh:572  autoload -RUz compinit compaudit
577//! sh:573
578//! sh:574  return 0
579//! ```
580
581use rayon::prelude::*;
582use std::collections::{HashMap, HashSet};
583use std::fs;
584use std::path::{Path, PathBuf};
585use std::sync::Mutex;
586use std::time::Instant;
587
588// =====================================================================
589// Upstream constants (sh:138-197) — exported so every compsys entry
590// point can replay the `_comp_setup` eval and the `_comp_options`
591// list that compinit installs at load time.
592// =====================================================================
593
594/// sh:139-172 — the 33 options compinit forces into every compsys
595/// entry-point scope via `setopt localoptions … ${_comp_options[@]}`.
596/// Mirrors the upstream array verbatim, including the `NO_` prefix
597/// form for negated flags. Consumed by the eval string at
598/// [`COMP_SETUP_EVAL`].
599pub const COMP_OPTIONS: &[&str] = &[
600    "bareglobqual",
601    "extendedglob",
602    "glob",
603    "multibyte",
604    "multifuncdef",
605    "nullglob",
606    "rcexpandparam",
607    "unset",
608    "NO_allexport",
609    "NO_aliases",
610    "NO_autonamedirs",
611    "NO_cshnullglob",
612    "NO_cshjunkiequotes",
613    "NO_errexit",
614    "NO_errreturn",
615    "NO_globassign",
616    "NO_globsubst",
617    "NO_histsubstpattern",
618    "NO_ignorebraces",
619    "NO_ignoreclosebraces",
620    "NO_kshglob",
621    "NO_ksharrays",
622    "NO_kshtypeset",
623    "NO_markdirs",
624    "NO_octalzeroes",
625    "NO_posixbuiltins",
626    "NO_posixidentifiers",
627    "NO_shwordsplit",
628    "NO_shglob",
629    "NO_typesettounset",
630    "NO_warnnestedvar",
631    "NO_warncreateglobal",
632];
633
634/// sh:180-190 — the `_comp_setup` string that every compsys entry
635/// point evals to install the option set + IFS + null stdin + no-ZERR.
636/// Bit-identical to upstream so a user-supplied `_comp_setup`
637/// override (very rare) still matches.
638// sh:180-190 — the value is a single-quoted literal spanning eleven
639// lines, so the 13-space indentation of every continuation line is part
640// of the STRING, not just the source layout. `eval` ignores it, but
641// `$_comp_setup` is readable and was the one parameter whose value still
642// differed from zsh's inside a completion.
643pub const COMP_SETUP_EVAL: &str = concat!(
644    "local -A _comp_caller_options;\n",
645    "             _comp_caller_options=(${(kv)options[@]});\n",
646    "             setopt localoptions localtraps localpatterns ${_comp_options[@]};\n",
647    "             local IFS=$' \\t\\r\\n\\0';\n",
648    "             builtin enable -p \\| \\~ \\( \\? \\* \\[ \\< \\^ \\# 2>&-;\n",
649    "             exec </dev/null;\n",
650    "             trap - ZERR;\n",
651    "             local -a reply;\n",
652    "             local REPLY;\n",
653    "             local REPORTTIME;\n",
654    "             unset REPORTTIME"
655);
656
657/// sh:558 — the 8 standard ZLE widgets that compinit rebinds to
658/// `_main_complete` so any of them triggers a completion attempt.
659pub const STANDARD_COMPLETE_WIDGETS: &[&str] = &[
660    "complete-word",
661    "delete-char-or-list",
662    "expand-or-complete",
663    "expand-or-complete-prefix",
664    "list-choices",
665    "menu-complete",
666    "menu-expand-or-complete",
667    "reverse-menu-complete",
668];
669
670// =====================================================================
671// State publication helpers — keep the shell-side `$compprefuncs`
672// and `$comppostfuncs` arrays initialized empty per sh:195-197.
673// =====================================================================
674
675/// Initialize the shell-side `compprefuncs` / `comppostfuncs` arrays
676/// to empty (sh:195-197). Idempotent; safe to call from compinit
677/// before any user-side `_call_function` would have populated them.
678pub fn init_comp_funcs_arrays() {
679    crate::ported::params::setaparam("compprefuncs", Vec::new());
680    crate::ported::params::setaparam("comppostfuncs", Vec::new());
681}
682
683/// `typeset -g[H][A|a] NAME` — declare a global parameter with the
684/// given type/attribute bits WITHOUT disturbing an existing value.
685///
686/// Port of the `bin_typeset` path upstream's declarations take
687/// (`Src/builtin.c:2469-2575`): when the name is absent the parameter
688/// is created with the requested type; when it is already present only
689/// the attribute bits are OR'd in. `compinit`'s `typeset -gHA _comps`
690/// on a re-`compinit` must not empty the table it just loaded, which is
691/// exactly the "already present" arm.
692fn declare_global(name: &str, kind: u32, attrs: u32) {
693    use crate::ported::params::{paramtab, setaparam, sethparam, setsparam};
694    use crate::ported::zsh_h::{PM_ARRAY, PM_HASHED};
695
696    let exists = paramtab()
697        .read()
698        .ok()
699        .map(|t| t.contains_key(name))
700        .unwrap_or(false);
701    if !exists {
702        // Create through the canonical typed setters, NOT a bare
703        // `createparam`: for a hashed parameter the values live in
704        // `paramtab_hashed_storage`, and only `sethparam` allocates
705        // that side. A raw `createparam(PM_HASHED)` produced a
706        // `_comps` that existed but could never be filled — `${#_comps}`
707        // stayed 0 and `_dispatch` found no completer for ANY command,
708        // so every `<cmd> <TAB>` silently did nothing.
709        if kind & PM_HASHED != 0 {
710            sethparam(name, Vec::new());
711        } else if kind & PM_ARRAY != 0 {
712            setaparam(name, Vec::new());
713        } else {
714            let _ = setsparam(name, "");
715        }
716    }
717    // c:Src/builtin.c:2575 — attribute-only update on an existing
718    // parameter; the value is left alone, which is what a re-`compinit`
719    // needs (`typeset -gHA _comps` must not empty a loaded table).
720    if let Ok(mut tab) = paramtab().write() {
721        if let Some(pm) = tab.get_mut(name) {
722            pm.node.flags |= attrs as i32;
723        }
724    }
725}
726
727/// compinit sh:116-197 — the global parameters `compinit` itself
728/// declares, before any of its branches run.
729///
730/// ```text
731/// sh:116  typeset -gHA _comps _services _patcomps _postpatcomps
732/// sh:121  typeset -gHA _compautos
733/// sh:126  typeset -gHA _lastcomp
734/// sh:131  typeset -g _comp_dumpfile="$_i_dumpfile"      (-d FILE)
735/// sh:133  typeset -g _comp_dumpfile="${ZDOTDIR:-$HOME}/.zcompdump"
736/// sh:138  typeset -gHa _comp_options
737/// sh:180  typeset -gH _comp_setup='…'
738/// sh:195  typeset -ga compprefuncs comppostfuncs
739/// ```
740///
741/// These are unconditional lines in `compinit`'s body — they run on
742/// every path, dump-hit and fresh-scan alike. zshrs keeps the completer
743/// tables in Rust and only published `_comps`/`_services`/`_patcomps`
744/// on the `-C` cache-hit path, so a real session was missing eight
745/// names that upstream always has, and the four assocs it did publish
746/// carried no `-H` (`PM_HIDEVAL`) bit. That is directly observable:
747/// `unset <TAB>` runs `_vars` → `_parameters`, which offers every
748/// parameter whose `${(t)}` lacks `local`, so the missing declarations
749/// were missing completions.
750pub fn declare_compinit_globals(dumpfile: Option<&str>) {
751    use crate::ported::zsh_h::{PM_ARRAY, PM_HASHED, PM_HIDEVAL, PM_UNIQUE};
752
753    // sh:116 / sh:121 / sh:126 — `typeset -gHA …`.
754    for name in [
755        "_comps",
756        "_services",
757        "_patcomps",
758        "_postpatcomps",
759        "_compautos",
760        "_lastcomp",
761    ] {
762        declare_global(name, PM_HASHED, PM_HIDEVAL);
763    }
764
765    // sh:129-134 — `_comp_dumpfile` defaults to
766    // `${ZDOTDIR:-$HOME}/.zcompdump` and is overridden by `-d FILE`.
767    // `typeset -g NAME=VALUE` assigns unconditionally, so an explicit
768    // `-d` always wins; without one the default only fills an
769    // empty/absent value.
770    match dumpfile {
771        Some(f) if !f.is_empty() => {
772            let _ = crate::ported::params::setsparam("_comp_dumpfile", f); // sh:131
773        }
774        _ => {
775            if crate::ported::params::getsparam("_comp_dumpfile")
776                .map(|s| s.is_empty())
777                .unwrap_or(true)
778            {
779                let _ = crate::ported::params::setsparam(
780                    "_comp_dumpfile",
781                    &default_dumpfile_path().to_string_lossy(),
782                ); // sh:133
783            }
784        }
785    }
786
787    // sh:138-172 — `typeset -gHa _comp_options` + the option list.
788    declare_global("_comp_options", PM_ARRAY, PM_HIDEVAL);
789    crate::ported::params::setaparam(
790        "_comp_options",
791        COMP_OPTIONS.iter().map(|s| s.to_string()).collect(),
792    );
793
794    // sh:180-190 — `typeset -gH _comp_setup='…'`.
795    declare_global("_comp_setup", 0, PM_HIDEVAL);
796    let _ = crate::ported::params::setsparam("_comp_setup", COMP_SETUP_EVAL);
797
798    // sh:195-197 — `typeset -ga compprefuncs comppostfuncs` then both
799    // reset to empty.
800    init_comp_funcs_arrays();
801
802    // compdump sh:134-135 — `typeset -gUa _comp_assocs`. compdump
803    // writes those two lines into every dump file, and `compinit -C`
804    // reaches them by sourcing it (sh:493 `builtin . "$_comp_dumpfile"`).
805    // zshrs parses the dump into its own cache instead of sourcing it,
806    // so the declaration has to be made here to reach the same state.
807    declare_global("_comp_assocs", PM_ARRAY, PM_UNIQUE);
808}
809
810/// sh:337 — `[[ -n "$autol" ]] && autoload -rUz "$func"`.
811///
812/// compinit registers every scanned completion file with `compdef -na
813/// "${_i_name}" …` (sh:541), and the `-a` in that call is what makes
814/// `compdef` run `autoload -rUz "$func"` at sh:337. The dump-file fast
815/// path (sh:493 `builtin . "$_comp_dumpfile"`) reaches the same state
816/// from compdump's single `autoload -Uz …` line. Either way a real zsh
817/// finishes `compinit` with an autoload stub in `shfunctab` for EVERY
818/// completer basename found in `$fpath`, and completers read that table:
819/// `_tmux` builds its sub-command list from
820/// `${(M)${(k)functions}:#_tmux-*}` (_tmux sh:1967).
821///
822/// zshrs bulk-loads `$_comps` from its own cache and materializes bodies
823/// lazily, so it skipped this step entirely; `${(k)functions}` held only
824/// the functions the session had actually defined.
825///
826/// Only names with no existing `shfunctab` entry get a stub, mirroring
827/// `bin_functions`' behaviour of leaving an already-defined function
828/// alone. Flags match `autoload -rUz`: `PM_UNDEFINED | PM_UNALIASED`
829/// (c:Src/builtin.c:3352-3355) and `PM_ZSHSTORED` (c:3372). Returns the
830/// number of stubs added.
831pub fn register_autoload_stubs<I, S>(names: I) -> usize
832where
833    I: IntoIterator<Item = S>,
834    S: AsRef<str>,
835{
836    use crate::ported::zsh_h::{PM_UNALIASED, PM_UNDEFINED, PM_ZSHSTORED};
837    let flags = (PM_UNDEFINED | PM_UNALIASED | PM_ZSHSTORED) as i32;
838    let mut added = 0usize;
839    let names = names.into_iter();
840    let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() else {
841        return 0;
842    };
843    tab.reserve(names.size_hint().0);
844    for name in names {
845        let name = name.as_ref();
846        if name.is_empty() || tab.contains_key(name) {
847            continue;
848        }
849        let mut stub = crate::ported::hashtable::shfunc_autoload(name);
850        stub.node.flags = flags;
851        tab.add(stub);
852        added += 1;
853    }
854    added
855}
856
857/// sh:311 / sh:523 — `autoload -rUz NAME` for every file a fresh `$fpath`
858/// scan registered (`compdef -na` for a `#compdef` file, the `#autoload`
859/// arm directly).
860///
861/// `-r` is what [`register_autoload_stubs`] leaves out: check_autoload
862/// (c:Src/builtin.c:3195-3225) resolves the name through `getfpfunc` at
863/// autoload time and records the fpath element it was found in as
864/// `shf->filename` with `PM_LOADDIR`, so the later load (loadautofn
865/// c:5747-5753) searches ONLY that directory instead of walking `$fpath`
866/// again. Observable two ways: `functions NAME` prints
867/// `builtin autoload -XUz <dir>`, and a load never consults a `.zwc`
868/// digest of an EARLIER fpath element — with a corrupt `fp.zwc` ahead of
869/// the distribution tree, zsh warns "invalid zwc file" only for the
870/// functions that live in `fp`, while every stub without the directory
871/// re-walked `$fpath` and warned for `_main_complete`, `_normal`, ….
872///
873/// The directory is `getfpfunc`'s answer: the leftmost element holding a
874/// digest that defines the name, a `<elem>/<name>.zwc`, or `<elem>/<name>`.
875/// The scan already found the leftmost `<elem>/<name>` (sh:509 first claim
876/// wins), so when no element ahead of it carries any `.zwc` the two answers
877/// are the same element and the per-name `getfpfunc` walk — 3 stats per
878/// element per name, ~16k names on a large fpath — is skipped. Any other
879/// case asks `getfpfunc` itself.
880///
881/// c:3216-3226 — a relative element is made absolute against the cwd and
882/// resolved with `xsymlink`. An existing still-undefined entry without a
883/// load directory gets one (c:3195-3199, `PM_UNDEFINED` arm); a defined
884/// function is left alone, as in [`register_autoload_stubs`]. Returns the
885/// number of stubs added.
886pub fn register_scanned_autoload_stubs(result: &CompInitResult, fpath: &[PathBuf]) -> usize {
887    use crate::ported::zsh_h::{PM_LOADDIR, PM_UNALIASED, PM_UNDEFINED, PM_ZSHSTORED};
888
889    // Leftmost index of each element, and of the first one carrying any digest.
890    let mut elem_index: HashMap<&Path, usize> = HashMap::new();
891    for (i, d) in fpath.iter().enumerate() {
892        elem_index.entry(d.as_path()).or_insert(i);
893    }
894    let first_digest = fpath.iter().position(|d| {
895        let mut zwc = d.as_os_str().to_owned();
896        zwc.push(".zwc");
897        Path::new(&zwc).exists()
898            || fs::read_dir(d).is_ok_and(|rd| {
899                rd.flatten()
900                    .any(|e| e.file_name().to_string_lossy().ends_with(".zwc"))
901            })
902    });
903
904    let resolved: Vec<(&str, Option<String>)> = result
905        .files
906        .iter()
907        .filter(|f| matches!(f.def, CompFileDef::CompDef(_) | CompFileDef::Autoload(_)))
908        .map(|f| {
909            let scanned_elem = f
910                .path
911                .parent()
912                .and_then(|p| elem_index.get(p).map(|&i| (i, p)));
913            let dir = match scanned_elem {
914                Some((i, p)) if first_digest.is_none_or(|fd| fd >= i) => {
915                    Some(p.to_string_lossy().into_owned())
916                }
917                _ => {
918                    // c:3216 `getfpfunc(shf->node.nam, NULL, &dir_path, NULL, 1)`
919                    let mut dir: Option<String> = None;
920                    let mut dump = None;
921                    crate::ported::exec::getfpfunc(&f.name, &mut dir, None, 1, &mut dump)
922                        .and(dir)
923                }
924            };
925            // c:3218-3222 — a relative element is absolutized and resolved.
926            let dir = dir.map(|d| {
927                if d.starts_with('/') {
928                    d
929                } else {
930                    let abs = format!("{}/{}", crate::ported::compat::zgetcwd(), d);
931                    crate::ported::utils::xsymlink(&abs).unwrap_or(abs)
932                }
933            });
934            (f.name.as_str(), dir)
935        })
936        .collect();
937
938    let flags = (PM_UNDEFINED | PM_UNALIASED | PM_ZSHSTORED) as i32;
939    let mut added = 0usize;
940    let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() else {
941        return 0;
942    };
943    for (name, dir) in resolved {
944        if let Some(shf) = tab.get_mut(name) {
945            // c:3195-3199 — only a still-undefined function without a load
946            // directory is resolved; anything else keeps what it has.
947            let undefined = shf.node.flags as u32 & PM_UNDEFINED != 0;
948            let has_dir = shf.filename.is_some() && shf.node.flags as u32 & PM_LOADDIR != 0;
949            if undefined && !has_dir {
950                if let Some(d) = dir {
951                    shf.filename = Some(d); // c:3223
952                    shf.node.flags |= PM_LOADDIR as i32; // c:3224
953                }
954            }
955            continue;
956        }
957        let mut stub = crate::ported::hashtable::shfunc_autoload(name);
958        stub.node.flags = flags;
959        if let Some(d) = dir {
960            stub.filename = Some(d); // c:3223
961            stub.node.flags |= PM_LOADDIR as i32; // c:3224
962        }
963        tab.add(stub);
964        added += 1;
965    }
966    added
967}
968
969/// Autoload-stub names contributed by a completed scan — every file
970/// whose header was `#compdef` or `#autoload`, which is exactly the set
971/// compinit hands to `compdef -na` / `autoload -rUz` at sh:537-547.
972pub fn autoload_stub_names(result: &CompInitResult) -> Vec<&str> {
973    result
974        .files
975        .iter()
976        .filter(|f| matches!(f.def, CompFileDef::CompDef(_) | CompFileDef::Autoload(_)))
977        .map(|f| f.name.as_str())
978        .collect()
979}
980
981/// sh:516 `builtin . "$_comp_dumpfile"` — the autoload half of sourcing
982/// a dump file, extracted without executing it.
983///
984/// When a dump file exists, upstream compinit does NOT scan `$fpath` at
985/// all (sh:491-518 sources the dump and sets `_i_done`, which skips the
986/// whole sh:523-550 scan). The names in `${(k)functions}` after such a
987/// compinit therefore come from the dump's `autoload` lines, and
988/// compdump writes those from a DIFFERENT rule than compinit's scan:
989///
990///   compdump:113  `_d_als=($^fpath/(${(o~j.|.)$(typeset +fm '_*')})(N:t))`
991///
992/// i.e. every currently-DEFINED function whose name starts with `_` and
993/// which also has a file somewhere in `$fpath` — no `#compdef` /
994/// `#autoload` first line required. That is a strict superset of the
995/// header-driven set `autoload_stub_names` derives from a scan: a
996/// headerless helper the user's rc autoloaded by hand is in the dump but
997/// invisible to any header-based scan. It can even name a function whose
998/// file has since been deleted from `$fpath`, because the dump is a
999/// snapshot (`autoload` on a missing file still creates the stub and only
1000/// errors when called).
1001///
1002/// Two line shapes are produced by compdump and both are parsed here:
1003///   compdump:118-129  one `autoload -Uz a b c \` + continuations, and
1004///   compdump:135-138  one `autoload -Uz <opts> <name>` per `$_compautos`.
1005/// Word-splitting mirrors the shell's: `autoload` must be the first word,
1006/// tokens starting with `-` or `+` are option words, the rest are names.
1007pub fn dump_autoload_names(path: &Path) -> Vec<String> {
1008    let Ok(text) = std::fs::read_to_string(path) else {
1009        return Vec::new();
1010    };
1011    let mut names = Vec::new();
1012    let mut in_autoload = false;
1013    for line in text.lines() {
1014        let mut rest = line.trim();
1015        if !in_autoload {
1016            let Some(tail) = rest.strip_prefix("autoload") else {
1017                continue;
1018            };
1019            // "autoloadfoo" is not the `autoload` command.
1020            if !tail.is_empty() && !tail.starts_with(|c: char| c.is_ascii_whitespace()) {
1021                continue;
1022            }
1023            rest = tail.trim_start();
1024        }
1025        // A trailing `\` continues the command onto the next line.
1026        in_autoload = rest.ends_with('\\');
1027        if in_autoload {
1028            rest = rest[..rest.len() - 1].trim_end();
1029        }
1030        for word in rest.split_ascii_whitespace() {
1031            if word.starts_with('-') || word.starts_with('+') {
1032                continue; // `-Uz`, `+X`, …
1033            }
1034            names.push(word.to_string());
1035        }
1036    }
1037    names
1038}
1039
1040/// The five association tables a dump file defines (compdump sh:31-72).
1041///
1042/// `IndexMap`, not `HashMap`: compdump writes every table in `${(ok)…}`
1043/// order and sourcing it reproduces exactly that insertion order, which
1044/// `${(k)_comps}` and — for `_patcomps`/`_postpatcomps` — pattern-match
1045/// precedence are both observable through.
1046#[derive(Debug, Default)]
1047pub struct DumpTables {
1048    /// `_comps=(…)`   — command -> completer
1049    pub comps: indexmap::IndexMap<String, String>,
1050    /// `_services=(…)` — command -> service name
1051    pub services: indexmap::IndexMap<String, String>,
1052    /// `_patcomps=(…)` — pattern -> completer (tried before `_comps`)
1053    pub patcomps: indexmap::IndexMap<String, String>,
1054    /// `_postpatcomps=(…)` — pattern -> completer (tried after `_comps`)
1055    pub postpatcomps: indexmap::IndexMap<String, String>,
1056    /// `_compautos=(…)` — autoload name -> extra `autoload` options
1057    pub compautos: indexmap::IndexMap<String, String>,
1058}
1059
1060/// Split one dump line into words the way the shell would, honouring the
1061/// single-quoting `${(qq)}` applies in compdump (sh:38, 45, 52, 59, 64, 70).
1062///
1063/// `(qq)` renders an embedded `'` as `'\''`, so `'brew` is written
1064/// `''\''brew'` — three concatenated segments. Adjacent segments join into
1065/// one word, which is what makes plain `split_whitespace` wrong here.
1066fn split_quoted_words(line: &str) -> Vec<String> {
1067    let mut out = Vec::new();
1068    let mut cur = String::new();
1069    let mut started = false;
1070    let mut it = line.chars();
1071    while let Some(c) = it.next() {
1072        match c {
1073            ' ' | '\t' => {
1074                if started {
1075                    out.push(std::mem::take(&mut cur));
1076                    started = false;
1077                }
1078            }
1079            '\'' => {
1080                started = true;
1081                for d in it.by_ref() {
1082                    if d == '\'' {
1083                        break;
1084                    }
1085                    cur.push(d);
1086                }
1087            }
1088            '\\' => {
1089                started = true;
1090                if let Some(d) = it.next() {
1091                    cur.push(d);
1092                }
1093            }
1094            _ => {
1095                started = true;
1096                cur.push(c);
1097            }
1098        }
1099    }
1100    if started {
1101        out.push(cur);
1102    }
1103    out
1104}
1105
1106/// sh:494 `builtin . "$_comp_dumpfile"` — the association-table half of
1107/// sourcing a dump file, extracted without executing it.
1108///
1109/// On the `-C` path (sh:493-496) upstream sources the dump and sets
1110/// `_i_done`, and sh:501's `if [[ -z "$_i_done" ]]` then skips the whole
1111/// sh:504-528 `$fpath` scan. The dump is therefore the SOLE definition of
1112/// `_comps`/`_services`/`_patcomps`/`_postpatcomps`/`_compautos` for that
1113/// session — nothing else contributes a single key.
1114///
1115/// zshrs substituted its own SQLite cache for that payload, which is
1116/// refreshed on a different schedule than the shared `.zcompdump` and can
1117/// hold a partial scan. On this host the cache carried 1849 `_comps`
1118/// entries against the dump's 51745, so `$_comps[zpwr]` (and `cargo`,
1119/// `brew`, …) was simply absent, `_dispatch` fell through to `-default-`
1120/// and `zpwr <TAB>` completed FILES where zsh runs `_zpwr`.
1121///
1122/// compdump writes each table as a bare `NAME=(` line, one `'key' 'value'`
1123/// pair per line, and a bare `)` (sh:31-72); this parses exactly that
1124/// shape and ignores everything else in the file.
1125pub fn dump_assoc_tables(path: &Path) -> Option<DumpTables> {
1126    let text = std::fs::read_to_string(path).ok()?;
1127    let mut tables = DumpTables::default();
1128    let mut open: Option<usize> = None;
1129    for line in text.lines() {
1130        if let Some(idx) = open {
1131            if line.trim_end() == ")" {
1132                open = None;
1133                continue;
1134            }
1135            let words = split_quoted_words(line);
1136            if words.len() >= 2 {
1137                let table = match idx {
1138                    0 => &mut tables.comps,
1139                    1 => &mut tables.services,
1140                    2 => &mut tables.patcomps,
1141                    3 => &mut tables.postpatcomps,
1142                    _ => &mut tables.compautos,
1143                };
1144                table.insert(words[0].clone(), words[1].clone());
1145            }
1146            continue;
1147        }
1148        open = match line.trim_end() {
1149            "_comps=(" => Some(0),
1150            "_services=(" => Some(1),
1151            "_patcomps=(" => Some(2),
1152            "_postpatcomps=(" => Some(3),
1153            "_compautos=(" => Some(4),
1154            _ => None,
1155        };
1156    }
1157    Some(tables)
1158}
1159
1160/// Overlay `~/.zshrs/functions`'s own registrations onto a dump that
1161/// predates that tree — the zshrs-only half of sh:469-496.
1162///
1163/// Upstream `-C` sources the dump and stops (sh:494-495); sh:501's
1164/// `if [[ -z "$_i_done" ]]` then skips the whole `$fpath` scan, so in C zsh
1165/// the dump alone defines the five tables.  zshrs adds one thing C zsh has no
1166/// equivalent for: it materialises its bundled function tree into
1167/// `~/.zshrs/functions` out of band and puts that directory on `$fpath`, so a
1168/// dump written before an upgrade lists none of `_zjob`, `_ztag`, `_zcache`,
1169/// … even though their files ARE on `$fpath`.
1170///
1171/// The previous handling of that case DISCARDED the whole dump and fell back
1172/// to zshrs's SQLite cache.  That is far too blunt: the cache is a different
1173/// table, not a superset.  Measured on this host with the fpath pinned to
1174/// `zsh -f`'s and only the dump's mtime varied —
1175///
1176/// ```text
1177/// cache path (dump discarded): n=51577  X=_X  7z=_7z    zjob=_zjob
1178/// dump path:                   n=51708  X=_X  7z=_7zip  zjob=
1179/// real zsh, same dump:         n=51708  X=_X  7z=_7zip  zjob=
1180/// ```
1181///
1182/// — so the dump path is byte-identical to zsh while the cache path resolves
1183/// `7z` to a DIFFERENT completer (`_7z` from a plugin instead of the
1184/// distribution's `_7zip`) and, when the cache happens to be only partially
1185/// built, drops tens of thousands of keys outright.  Overlaying instead keeps
1186/// the dump authoritative and adds only what it cannot know about.
1187///
1188/// Routing is `compinit`'s own (sh:514-525) restricted to one directory, and
1189/// sh:519's `compdef -na` — `-n` meaning "do not override an existing
1190/// definition" — is why the dump wins every key it already carries.  Nothing
1191/// is published to `CompdefState` here; the caller publishes the merged
1192/// tables.  Returns the number of `_comps` keys added.
1193pub fn merge_bundled_registrations(tables: &mut DumpTables) -> usize {
1194    let Some(dir) = crate::bundled_functions::functions_dir() else {
1195        return 0;
1196    };
1197    let files = scan_directory(&dir);
1198    let mut added = 0usize;
1199    for file in &files {
1200        let (cmds, pats, postpats): (&[String], &[String], &[String]) = match &file.def {
1201            CompFileDef::CompDef(CompDef::Commands(c)) => (c, &[], &[]),
1202            CompFileDef::CompDef(CompDef::Pattern(p)) => (&[], p, &[]),
1203            CompFileDef::CompDef(CompDef::PostPattern(p)) => (&[], &[], p),
1204            CompFileDef::CompDef(CompDef::Mixed {
1205                commands,
1206                patterns,
1207                postpatterns,
1208            }) => (commands, patterns, postpatterns),
1209            CompFileDef::Autoload(opts) => {
1210                // sh:524 `[[ "$_i_line" != \ # ]] && _compautos[$_i_name]="$_i_line"`
1211                // — only an `#autoload` line WITH options lands in
1212                // `_compautos`; a bare header registers nothing (the same
1213                // guard the full scan applies, see `compinit`'s Autoload arm).
1214                // This overlay inserted every bare `#autoload` file of
1215                // `~/.zshrs/functions`, so a `compinit -C -d DUMP` shell had
1216                // `$#_compautos` 178 where zsh, sourcing the same dump, has 1.
1217                let opts_str = opts.join(" ");
1218                if !opts_str.trim().is_empty() {
1219                    tables
1220                        .compautos
1221                        .entry(file.name.clone())
1222                        .or_insert(opts_str);
1223                }
1224                continue;
1225            }
1226            _ => continue,
1227        };
1228        for cmd in cmds {
1229            // sh:519 `compdef -na` — `-n` keeps an existing claim, and the
1230            // dump IS the existing claim.
1231            let (name, service) = match cmd.find('=') {
1232                Some(i) => (&cmd[..i], Some(&cmd[i + 1..])),
1233                None => (cmd.as_str(), None),
1234            };
1235            if tables.comps.contains_key(name) {
1236                continue;
1237            }
1238            tables.comps.insert(name.to_string(), file.name.clone());
1239            added += 1;
1240            if let Some(svc) = service {
1241                tables.services.insert(name.to_string(), svc.to_string());
1242            }
1243        }
1244        for pat in pats {
1245            tables
1246                .patcomps
1247                .entry(pat.clone())
1248                .or_insert_with(|| file.name.clone());
1249        }
1250        for pat in postpats {
1251            tables
1252                .postpatcomps
1253                .entry(pat.clone())
1254                .or_insert_with(|| file.name.clone());
1255        }
1256    }
1257    // sh:523 — every registered completer is `autoload -rUz`'d.
1258    register_autoload_stubs(
1259        files
1260            .iter()
1261            .filter(|f| matches!(f.def, CompFileDef::CompDef(_) | CompFileDef::Autoload(_)))
1262            .map(|f| f.name.as_str()),
1263    );
1264    added
1265}
1266
1267/// Default `$_comp_dumpfile` path (sh:129-134). User can override
1268/// via `compinit -d <file>`; without that, use `${ZDOTDIR:-$HOME}`
1269/// + `/.zcompdump`.
1270pub fn default_dumpfile_path() -> PathBuf {
1271    let home = std::env::var("ZDOTDIR")
1272        .ok()
1273        .filter(|s| !s.is_empty())
1274        .or_else(|| std::env::var("HOME").ok())
1275        .unwrap_or_else(|| ".".to_string());
1276    PathBuf::from(home).join(".zcompdump")
1277}
1278
1279/// Publish the standard ZLE rebind set to the dispatcher hooks
1280/// (sh:555-560). For each `complete-word` family widget + a
1281/// conditional `menu-select` (when `zsh/complist` is loaded), bind
1282/// it to `_main_complete` via `zle -C`. Returns the count of
1283/// successful binds.
1284pub fn install_standard_complete_widgets() -> usize {
1285    // `zle` is a builtin, not a shell function, so it must be invoked
1286    // through the builtin entry (`bin_zle_complete`) — NOT
1287    // `dispatch_function_call`, which only resolves shell functions and
1288    // silently returns None for a builtin name, leaving every widget
1289    // unbound (the whole compsys engine then never fires on Tab).
1290    let empty_ops = crate::ported::zsh_h::options {
1291        ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1292        args: Vec::new(),
1293        argscount: 0,
1294        argsalloc: 0,
1295    };
1296    let mut count = 0usize;
1297    tracing::debug!(target: "compsys_args", "install_standard_complete_widgets ENTER");
1298    for w in STANDARD_COMPLETE_WIDGETS {
1299        // `zle -C <w> .<w> _main_complete` — args are post-flag:
1300        // [target-thingy, base-comp-widget, completion-func].
1301        let args = [
1302            w.to_string(),
1303            format!(".{}", w),
1304            "_main_complete".to_string(),
1305        ];
1306        let rc_w = crate::ported::zle::zle_thingy::bin_zle_complete("zle", &args, &empty_ops, 0);
1307        tracing::debug!(target: "compsys_args", widget = %w, rc_w, "zle -C standard widget");
1308        if rc_w == 0 {
1309            count += 1;
1310        }
1311    }
1312    // sh:544 — `zle -la menu-select && zle -C menu-select .menu-select _main_complete`.
1313    // The `zle -la` guard is what keeps compinit silent without
1314    // `zsh/complist`: an unguarded `zle -C` warns "invalid widget
1315    // `.menu-select'" on every compinit.
1316    let mut la_ops = crate::ported::zsh_h::options {
1317        ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1318        args: Vec::new(),
1319        argscount: 0,
1320        argsalloc: 0,
1321    };
1322    la_ops.ind[b'a' as usize] = 1;
1323    let has_menu_select =
1324        crate::ported::zle::zle_thingy::bin_zle_list("zle", &["menu-select".to_string()], &la_ops, 0) == 0;
1325    if has_menu_select {
1326        let args = [
1327            "menu-select".to_string(),
1328            ".menu-select".to_string(),
1329            "_main_complete".to_string(),
1330        ];
1331        let rc_w = crate::ported::zle::zle_thingy::bin_zle_complete("zle", &args, &empty_ops, 0);
1332        tracing::debug!(target: "compsys_args", widget = "menu-select", rc_w, "zle -C standard widget");
1333        if rc_w == 0 {
1334            count += 1;
1335        }
1336    }
1337    count
1338}
1339
1340/// `zmodload -i NAME` — mark a statically-linked module booted.
1341///
1342/// zsh has no `zmodload` call for these: referencing an autoloadable
1343/// parameter or builtin loads its module implicitly (`autoparamfn` /
1344/// `autobinfn`). zshrs registers those parameters and builtins at init
1345/// without going through the module, so the module's `MOD_INIT_B` bit —
1346/// the one `zmodload` / `zmodload -L` list on — never got set. Routing
1347/// the implicit load through the real builtin reaches the same state.
1348fn load_module_i(name: &str) {
1349    let mut ops = crate::ported::zsh_h::options {
1350        ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1351        args: Vec::new(),
1352        argscount: 0,
1353        argsalloc: 0,
1354    };
1355    ops.ind[b'i' as usize] = 1;
1356    let _ = crate::ported::module::bin_zmodload("zmodload", &[name.to_string()], &ops, 0);
1357}
1358
1359/// sh:201 — `: $funcstack`, with the upstream comment "Loading it now
1360/// ensures that the `funcstack' parameter is always correct."
1361///
1362/// `funcstack` is one of `zsh/parameter`'s autoloadable parameters
1363/// (Src/Modules/parameter.mdd), so that bare `:` command is a module
1364/// load in disguise — after any compinit, a real zsh lists
1365/// `zsh/parameter` in `zmodload -L`.
1366///
1367/// It is a SINGLE-FEATURE load, not a `zmodload zsh/parameter`: reading
1368/// the name runs `loadparamnode` → `ensurefeature(mn, "p:", "funcstack")`
1369/// (c:Src/params.c:568 → c:Src/module.c:3426-3432), which enables only
1370/// `p:funcstack`. The module's other parameters (`commands`, `aliases`,
1371/// …) stay PM_AUTOLOAD stubs — verified against zsh 5.9:
1372/// `: ${#aliases}; f(){ local -A +h commands; print ${(t)commands} }; f`
1373/// prints `association-local`, while the same after an explicit
1374/// `zmodload zsh/parameter` prints `association-local-special`.
1375/// Routing this through the full `zmodload` marked every sibling
1376/// materialized, so it must go through `ensurefeature`.
1377pub fn touch_funcstack_param() {
1378    // c:Src/params.c:565-568 — `loadparamnode` on a PM_AUTOLOAD stub:
1379    // `(void)ensurefeature(mn, "p:", nam)`. `mark_module_param_used` is
1380    // zshrs's hook for exactly that (vm_helper.rs), and it boots the
1381    // module, so `zmodload -L` still lists `zsh/parameter` afterwards.
1382    crate::vm_helper::mark_module_param_used("funcstack");
1383}
1384
1385/// sh:564-569 — when the configured `completer` chain includes
1386/// `_expand` AND `^i` is currently bound to `expand-or-complete`,
1387/// rebind `^i` to `complete-word` so users don't get unexpected
1388/// glob expansion on TAB.
1389pub fn maybe_rebind_tab_for_expand() {
1390    // sh:572 — `zstyle -a ':completion:' completer _i_line`. `zstyle` is
1391    // `zsh/zutil`'s builtin (Src/Modules/zutil.mdd), so this line is what
1392    // pulls the module in on every compinit and leaves it in
1393    // `zmodload -L`. zshrs answers the query through the native
1394    // `lookupstyle` below instead of the builtin, so the module was never
1395    // marked booted: `zmodload -L` printed four lines against zsh's six.
1396    load_module_i("zsh/zutil");
1397    // sh:565 is the literal context `':completion:'` — NOT
1398    // `:completion:$curcontext:`. The two agree under a `:completion:*`
1399    // fixture, so the difference only shows with a scoped zstyle, but the
1400    // spec string is the unadorned one.
1401    let completers = crate::ported::modules::zutil::lookupstyle(":completion:", "completer");
1402    // sh:566 `(( ${_i_line[(i)_expand]} <= ${#_i_line} ))` is an EXACT
1403    // element match, so a named completer such as `_expand:foo` does not
1404    // arm the rebind in zsh either.
1405    let has_expand = completers.iter().any(|c| c == "_expand");
1406    if !has_expand {
1407        return;
1408    }
1409    // sh:563-564 — `bindkey '^i' | IFS=$' \t' read -A _i_line` /
1410    // `[[ ${_i_line[2]} = expand-or-complete ]]`. `_i_line[2]` is the
1411    // widget name `bindkey` prints after the quoted key sequence, so the
1412    // guard asks exactly what `bin_bindkey_list` resolves at
1413    // `zle_keymap.rs:1840-1846`: `keybind(km, getkeystring("^i"))` on the
1414    // keymap `bindkey` picks with no `-M`/-e/-v/-a, which
1415    // `bin_bindkey` (zle_keymap.rs:1250-1263) fixes at `main`. Reading the
1416    // table directly instead of running the builtin keeps `bindkey`'s
1417    // listing off stdout. Without this guard a user who had already put
1418    // their own widget on TAB got it silently clobbered by every compinit.
1419    let seq = crate::ported::zle::zle_bindings::getkeystring("^i");
1420    // `openkeymap` misses until `default_bindings()` has run; mirror the
1421    // same emptiness gate `bin_bindkey` uses (zle_keymap.rs:1121-1127) so
1422    // a compinit that precedes any `bindkey` call still sees the defaults,
1423    // and an already-built keymap is never rebuilt (that would wipe the
1424    // user's bindings).
1425    let km = crate::ported::zle::zle_keymap::openkeymap("main").or_else(|| {
1426        crate::ported::zle::zle_keymap::default_bindings();
1427        crate::ported::zle::zle_keymap::openkeymap("main")
1428    });
1429    let bound = km
1430        .and_then(|km| crate::ported::zle::zle_keymap::keybind(&km, &seq).0)
1431        .map(|t| t.nam);
1432    if bound.as_deref() != Some("expand-or-complete") {
1433        return;
1434    }
1435    // sh:568 — `bindkey '^i' complete-word`. `bindkey` is a BUILTIN, so it
1436    // must go through `bin_bindkey`; `dispatch_function_call` resolves only
1437    // shell functions and returned None here, making the whole rebind a
1438    // silent no-op (see the same warning in
1439    // `install_standard_complete_widgets`).
1440    let empty_ops = crate::ported::zsh_h::options {
1441        ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1442        args: Vec::new(),
1443        argscount: 0,
1444        argsalloc: 0,
1445    };
1446    let bk_args = ["^i".to_string(), "complete-word".to_string()];
1447    let _ = crate::ported::zle::zle_keymap::bin_bindkey("bindkey", &bk_args, &empty_ops, 0);
1448}
1449
1450// `compaudit` lives in its own file (`src/compsys/ported/compaudit.rs`)
1451// per zsh upstream's layout (`Completion/compaudit` is a sibling of
1452// `Completion/compinit`). Re-export the entry point so existing
1453// compinit-facing callers don't need to change.
1454pub use super::compaudit::{compaudit, CompauditError};
1455
1456/// Completion definition from #compdef line
1457#[derive(Clone, Debug)]
1458pub enum CompDef {
1459    /// Regular command completion: #compdef cmd1 cmd2 ...
1460    Commands(Vec<String>),
1461    /// Pattern completion: #compdef -p 'pattern' [pattern...]
1462    Pattern(Vec<String>),
1463    /// Post-pattern completion: #compdef -P 'pattern' [pattern...]
1464    PostPattern(Vec<String>),
1465    /// One header that registers MORE THAN ONE kind, because `-N`/`-p`/`-P`
1466    /// switch the target table for the words that FOLLOW them (compinit
1467    /// sh:384-420). `_gcc`'s header is the canonical case:
1468    /// `#compdef gcc g++ … -value-,CFLAGS,-default- … -P gcc-* -P g++-* -P c++-*`
1469    /// — eight command names, six `-value-` contexts, then three
1470    /// post-patterns, all from one line.
1471    Mixed {
1472        commands: Vec<String>,
1473        patterns: Vec<String>,
1474        postpatterns: Vec<String>,
1475    },
1476    /// Key binding: #compdef -k style key1 key2 ...
1477    KeyBinding { style: String, keys: Vec<String> },
1478    /// Widget key binding: #compdef -K widget style key
1479    WidgetKey {
1480        widget: String,
1481        style: String,
1482        key: String,
1483    },
1484}
1485
1486/// Parsed completion file
1487#[derive(Clone, Debug)]
1488pub struct CompFile {
1489    /// Full path to the file
1490    pub path: PathBuf,
1491    /// Function name (filename without path)
1492    pub name: String,
1493    /// What this file defines
1494    pub def: CompFileDef,
1495    /// Full file body (read during scan for caching)
1496    pub body: Option<String>,
1497}
1498
1499/// What a completion file defines
1500#[derive(Clone, Debug)]
1501pub enum CompFileDef {
1502    /// #compdef - completion function
1503    CompDef(CompDef),
1504    /// #autoload - helper function with options
1505    Autoload(Vec<String>),
1506    None,
1507}
1508
1509/// Result of compinit scan
1510#[derive(Debug, Default)]
1511pub struct CompInitResult {
1512    /// Command -> function mapping (_comps)
1513    pub comps: HashMap<String, String>,
1514    /// Command -> service mapping (_services)
1515    pub services: HashMap<String, String>,
1516    /// Pattern -> function mapping (_patcomps)
1517    pub patcomps: HashMap<String, String>,
1518    /// Post-pattern -> function mapping (_postpatcomps)
1519    pub postpatcomps: HashMap<String, String>,
1520    /// Autoload functions with options (_compautos)
1521    pub compautos: HashMap<String, String>,
1522    /// All scanned files
1523    pub files: Vec<CompFile>,
1524    /// `#compdef -k <style> <key>...` widget bindings collected from file
1525    /// headers: (func, style, keys). Applied by the foreground compinit
1526    /// caller via `zle -C` + `bindkey` (upstream compinit sh:356-379) —
1527    /// the scan itself may run on a background thread and must not touch
1528    /// keymaps.
1529    pub keybindings: Vec<(String, String, Vec<String>)>,
1530    /// `#compdef -K <widget> <style> <key>` triplets: (widget, style, key, func).
1531    pub widgetkeys: Vec<(String, String, String, String)>,
1532    /// Scan duration
1533    pub scan_time_ms: u64,
1534    /// Number of directories scanned
1535    pub dirs_scanned: usize,
1536    /// Number of files scanned
1537    pub files_scanned: usize,
1538}
1539
1540/// Parse the first line of a completion file
1541///
1542/// Handles all #compdef variants:
1543/// - `#compdef cmd1 cmd2` - regular commands
1544/// - `#compdef - cmd1 cmd2` - bare hyphen + commands (hyphen maps to '-')
1545/// - `#compdef -default-` - special context entries
1546/// - `#compdef -value-,VAR,-default-` - value context entries  
1547/// - `#compdef -p pattern` - pattern completions
1548/// - `#compdef -P pattern` - post-pattern completions
1549/// - `#compdef -k style key` - key bindings
1550/// - `#compdef -K widget style key` - widget key bindings
1551fn parse_first_line(line: &str) -> CompFileDef {
1552    let line = line.trim();
1553
1554    if let Some(rest) = line.strip_prefix("#compdef") {
1555        let rest = rest.trim();
1556        if rest.is_empty() {
1557            return CompFileDef::None;
1558        }
1559
1560        let parts: Vec<&str> = rest.split_whitespace().collect();
1561        if parts.is_empty() {
1562            return CompFileDef::None;
1563        }
1564
1565        // sh:534-539 — ONLY a leading `-[pPkK]` (optionally `n`-suffixed)
1566        // is passed to compdef as an option:
1567        //     if [[ $_i_line[1] = -[pPkK](n|) ]]; then
1568        //       compdef ${_i_line[1]}na "$name" "${(@)_i_line[2,-1]}"
1569        //     else
1570        //       compdef -na "$name" "${_i_line[@]}"
1571        // Everything else — `-n`, `-m`, `-default-`, a bare `-` — is an
1572        // ordinary positional word, not a flag. (`_squishy`'s
1573        // `#compdef squishy "python -m squishy"` is read by `read -rA`,
1574        // which does no quote processing, so zsh really does register a
1575        // command literally named `-m`.)
1576        let leading = parts[0].strip_suffix('n').filter(|f| f.len() == 2);
1577        match leading.unwrap_or(parts[0]) {
1578            "-k" if parts.len() >= 3 => CompFileDef::CompDef(CompDef::KeyBinding {
1579                style: parts[1].to_string(),
1580                keys: parts[2..].iter().map(|s| s.to_string()).collect(),
1581            }),
1582            "-K" if parts.len() >= 4 => CompFileDef::CompDef(CompDef::WidgetKey {
1583                widget: parts[1].to_string(),
1584                style: parts[2].to_string(),
1585                key: parts[3].to_string(),
1586            }),
1587            flag => {
1588                // sh:384-420 — the positional loop. `type` starts at whatever
1589                // the leading flag set (sh:277-285) and is RE-SET by any `-N`
1590                // / `-p` / `-P` met along the way, so one header can feed
1591                // `_comps`, `_patcomps` and `_postpatcomps` at once. Handling
1592                // the flag only in first position lost every trailing `-P` in
1593                // the tree — `_gcc`'s `gcc-*`/`g++-*`/`c++-*`, `_lua`'s
1594                // `lua[0-9.-]##`, `_ruby`, `_php`, `_shasum`, `_rmlint`,
1595                // `_urls`, `_directories`, `_locales`, `_ccache` … 15 of
1596                // zsh's 25 `_postpatcomps` entries were missing, and the
1597                // patterns were wrongly registered as literal command names
1598                // in `_comps` instead.
1599                let mut ty = match flag {
1600                    "-p" => 1,
1601                    "-P" => 2,
1602                    _ => 0,
1603                };
1604                let start = usize::from(ty != 0);
1605                let mut commands: Vec<String> = Vec::new();
1606                let mut patterns: Vec<String> = Vec::new();
1607                let mut postpatterns: Vec<String> = Vec::new();
1608                for word in &parts[start..] {
1609                    match *word {
1610                        "-N" => ty = 0, // sh:385-386
1611                        "-p" => ty = 1, // sh:387-388
1612                        "-P" => ty = 2, // sh:389-390
1613                        _ => match ty {
1614                            1 => patterns.push(word.to_string()),
1615                            2 => postpatterns.push(word.to_string()),
1616                            _ => commands.push(word.to_string()),
1617                        },
1618                    }
1619                }
1620                match (
1621                    commands.is_empty(),
1622                    patterns.is_empty(),
1623                    postpatterns.is_empty(),
1624                ) {
1625                    (true, true, true) => CompFileDef::None,
1626                    (false, true, true) => CompFileDef::CompDef(CompDef::Commands(commands)),
1627                    (true, false, true) => CompFileDef::CompDef(CompDef::Pattern(patterns)),
1628                    (true, true, false) => CompFileDef::CompDef(CompDef::PostPattern(postpatterns)),
1629                    _ => CompFileDef::CompDef(CompDef::Mixed {
1630                        commands,
1631                        patterns,
1632                        postpatterns,
1633                    }),
1634                }
1635            }
1636        }
1637    } else if let Some(rest) = line.strip_prefix("#autoload") {
1638        let opts: Vec<String> = rest.split_whitespace().map(|s| s.to_string()).collect();
1639        CompFileDef::Autoload(opts)
1640    } else {
1641        CompFileDef::None
1642    }
1643}
1644
1645/// Check if a string is a zsh completion context entry
1646/// Context entries are like: -default-, -redirect-, -command-, -value-,VAR,-default-
1647/// Also handles service syntax: -redirect-,<,bunzip2=bunzip2
1648fn is_context_entry(s: &str) -> bool {
1649    if !s.starts_with('-') {
1650        return false;
1651    }
1652    // Strip service suffix for checking
1653    let base = s.split('=').next().unwrap_or(s);
1654
1655    // Check if it's a known context pattern:
1656    // 1. Ends with '-' like -default-, -redirect-
1657    // 2. Contains comma (context specifiers like -redirect-,<,bunzip2 or -value-,VAR,-default-)
1658    // 3. But NOT single letter options like -p, -P, -k, -K, -n
1659    if base.len() <= 2 {
1660        return base == "-"; // bare hyphen is a context entry
1661    }
1662
1663    base.ends_with('-') || base.contains(',')
1664}
1665
1666/// Scan a single completion file - reads full body for caching
1667fn scan_file(path: &Path) -> Option<CompFile> {
1668    let name = path.file_name()?.to_string_lossy().to_string();
1669
1670    // Must start with underscore
1671    if !name.starts_with('_') {
1672        return None;
1673    }
1674
1675    // Skip certain patterns
1676    if name.contains(';')
1677        || name.contains('|')
1678        || name.contains('&')
1679        || name.ends_with('~')
1680        || name.ends_with(".zwc")
1681    {
1682        return None;
1683    }
1684
1685    // Read entire file at once (will be cached in SQLite).
1686    //
1687    // compinit sh:533 reads the tag line with `read -rA _i_line < $_i_file`,
1688    // which has no encoding requirement, and the autoload that later runs
1689    // the file reads raw bytes too (`getfpfunc`, c:Src/exec.c:6187-6192). This
1690    // read used `read_to_string`, which rejects the WHOLE file on its first
1691    // non-UTF-8 byte, so a completer carrying one legacy byte anywhere in its
1692    // body was dropped from the scan: no `#compdef`, no `$_comps` entry, and
1693    // its command completed through `-default-` instead (spec-fuzz 9509,
1694    // `--hostile`: every case whose completer held an invalid or C1 byte).
1695    // `read_script_file` is the same lossless decode the autoload path uses,
1696    // so the cached body is the text the loader would have produced.
1697    let body = crate::script_bytes::read_script_file(path).ok()?;
1698
1699    // Parse first line for directive
1700    let first_line = body.lines().next().unwrap_or("");
1701    let def = parse_first_line(first_line);
1702
1703    Some(CompFile {
1704        path: path.to_path_buf(),
1705        name,
1706        def,
1707        body: Some(body),
1708    })
1709}
1710
1711/// Scan a directory for completion files (parallel)
1712fn scan_directory(dir: &Path) -> Vec<CompFile> {
1713    let entries = match fs::read_dir(dir) {
1714        Ok(e) => e,
1715        Err(_) => return Vec::new(),
1716    };
1717
1718    let mut paths: Vec<PathBuf> = entries
1719        .filter_map(|e| e.ok())
1720        .map(|e| e.path())
1721        .filter(|p| p.is_file())
1722        .collect();
1723    // sh:507 — `for _i_file in $_i_dir/^(…)(N)`. A zsh glob yields its
1724    // matches SORTED, and that order decides which file claims a command
1725    // name first (compdef -n keeps the first claim, sh:393). `read_dir`
1726    // returns filesystem order, so without this sort the winner inside a
1727    // directory varied by inode layout.
1728    //
1729    // The comparator has to be zsh's, not Rust's byte-wise `Ord`:
1730    // c:Src/glob.c:1976 sorts matches with `gmatchcmp`, whose GS_NAME arm
1731    // (c:946) is `zstrcmp(…, 0)` → `strcoll` under the current LC_COLLATE.
1732    // Byte order put `_act-runner` (`-` = 0x2D) ahead of `_act_runner`
1733    // (`_` = 0x5F) while en_US.UTF-8 collation puts `_act_runner` first,
1734    // so ~40 commands whose completer has a `-`/`_` twin in the same
1735    // directory (`act_runner`, `amdgpu_top`, `cloud_sql_proxy`, `dh_make`,
1736    // …) got the WRONG file's function registered in `$_comps`.
1737    paths.sort_by(|a, b| {
1738        let name = |p: &Path| {
1739            p.file_name()
1740                .map(|f| f.to_string_lossy().into_owned())
1741                .unwrap_or_default()
1742        };
1743        crate::ported::sort::zstrcmp(&name(a), &name(b), 0)
1744    });
1745
1746    // Parallel scan of files within directory
1747    paths.par_iter().filter_map(|p| scan_file(p)).collect()
1748}
1749
1750/// Initialize the completion system by scanning fpath
1751///
1752/// This is the main entry point - replaces the zsh compinit function.
1753/// Uses rayon for parallel directory and file scanning.
1754/// Apply the `#compdef -k`/`-K` widget key bindings a scan collected —
1755/// the in-shell half the scan can't do itself (it may run on a background
1756/// thread). Mirrors compdef's key branch: sh:365 `zle -C <func> .<style>
1757/// <func>`, sh:378 `bindkey <key> <func>`. Without this, header-declared
1758/// completion widgets parsed but never bound — `^X?` (_complete_debug,
1759/// `#compdef -k complete-word \C-x?`) and `^Xh` (_complete_help)
1760/// self-inserted literally instead of running.
1761pub fn apply_keybindings(result: &CompInitResult) {
1762    for (func, style, keys) in &result.keybindings {
1763        for key in keys {
1764            install_comp_keybinding(func, style, key, func);
1765        }
1766    }
1767    for (widget, style, key, func) in &result.widgetkeys {
1768        install_comp_keybinding(widget, style, key, func);
1769    }
1770}
1771
1772/// One `#compdef -k`/`-K` binding: sh:346/365 `zle -C <widget> .<style>
1773/// <func>` + sh:351/378 `bindkey <key> <widget>`. Goes through the builtin
1774/// entries directly — `dispatch_function_call` resolves only shell
1775/// functions and silently no-ops for builtins (see
1776/// install_standard_complete_widgets).
1777fn install_comp_keybinding(widget: &str, style: &str, key: &str, func: &str) {
1778    // `zle -C` needs the thingy table populated: bin_zle_complete looks the
1779    // base comp-widget up by its dotted name (`.complete-word`,
1780    // c:Src/Zle/zle_thingy.c:609-614 `rthingy`) and returns 1 when it is
1781    // absent. In C that table is filled by `init_thingies()` at zsh/zle
1782    // module boot (c:zle_thingy.c:1022, reached from zle_main.c:2252), so
1783    // every `zle -C` compdef runs sees it. zshrs fills it LAZILY — from
1784    // `bin_zle` (zle_thingy.rs:673) and from a `$widgets` read
1785    // (zleparameter.rs:63) — and this call site bypasses both by invoking
1786    // `bin_zle_complete` directly, so on a fresh shell the table was still
1787    // empty and EVERY `#compdef -k`/`-K` widget silently failed to bind:
1788    // `_complete_debug`, `_complete_help`, `_complete_tag`,
1789    // `_correct_word`, `_correct_filename`, `_expand_word`,
1790    // `_expand_alias`, `_list_expansions`, `_next_tags`, `_read_comp`,
1791    // `_most_recent_file`, `_history-complete-{older,newer}`,
1792    // `_bash_{complete-word,list-choices}` — 15 widgets zsh has and zshrs
1793    // did not (401 vs 386 `${(k)widgets}` after the same `compinit -C -d`).
1794    // The `bindkey` half below DID run, so `^X?` was bound to a widget that
1795    // did not exist. Trigger the same lazy init `bin_zle` would; it is
1796    // idempotent (per-name `contains_key` guard inside init_thingies).
1797    crate::ported::zle::zle_thingy::init_thingies();
1798    let empty_ops = crate::ported::zsh_h::options {
1799        ind: [0u8; crate::ported::zsh_h::MAX_OPS],
1800        args: Vec::new(),
1801        argscount: 0,
1802        argsalloc: 0,
1803    };
1804    let style_dotted = if style.starts_with('.') {
1805        style.to_string()
1806    } else {
1807        format!(".{}", style)
1808    };
1809    // sh:346/365 — `zle -C <widget> <.style> <func>`.
1810    let zle_args = [widget.to_string(), style_dotted, func.to_string()];
1811    let _ = crate::ported::zle::zle_thingy::bin_zle_complete("zle", &zle_args, &empty_ops, 0);
1812    // sh:351/378 — `bindkey <key> <widget>`.
1813    let bk_args = [key.to_string(), widget.to_string()];
1814    let _ = crate::ported::zle::zle_keymap::bin_bindkey("bindkey", &bk_args, &empty_ops, 0);
1815}
1816
1817/// The `#compdef -k`/`-K` headers shipped in the upstream Completion tree —
1818/// the fixed set compinit produces for a stock install (each cited from its
1819/// file's first line). Installed synchronously with the standard widget
1820/// rebind because the background scan's results merge lazily (and the
1821/// cached path skips the scan entirely); user fpath files with -k headers
1822/// are additionally collected by the scan into CompInitResult.keybindings.
1823const STANDARD_COMP_KEYBINDINGS: &[(&str, &str, &str, &str)] = &[
1824    // (widget, style, key, func)
1825    (
1826        "_complete_debug",
1827        "complete-word",
1828        "\u{18}?",
1829        "_complete_debug",
1830    ), // Base/Widget/_complete_debug:1 \C-x?
1831    (
1832        "_complete_help",
1833        "complete-word",
1834        "\u{18}h",
1835        "_complete_help",
1836    ), // Base/Widget/_complete_help:1 \C-xh
1837    ("_complete_tag", "complete-word", "\u{18}t", "_complete_tag"), // Base/Widget/_complete_tag:1 \C-xt
1838    (
1839        "_correct_filename",
1840        "complete-word",
1841        "\u{18}C",
1842        "_correct_filename",
1843    ), // _correct_filename:1 \C-xC
1844    ("_correct_word", "complete-word", "\u{18}c", "_correct_word"), // _correct_word:1 \C-xc
1845    ("_read_comp", "complete-word", "\u{18}\u{12}", "_read_comp"),  // _read_comp:1 \C-x\C-r
1846    (
1847        "_most_recent_file",
1848        "complete-word",
1849        "\u{18}m",
1850        "_most_recent_file",
1851    ), // _most_recent_file:1 \C-xm
1852    ("_next_tags", "list-choices", "\u{18}n", "_next_tags"),        // _next_tags:1 \C-xn
1853    ("_expand_word", "complete-word", "\u{18}e", "_expand_word"),   // _expand_word:1 -K (1st pair)
1854    (
1855        "_list_expansions",
1856        "list-choices",
1857        "\u{18}d",
1858        "_expand_word",
1859    ), // _expand_word:1 -K (2nd pair)
1860    (
1861        "_bash_complete-word",
1862        "complete-word",
1863        "\u{1b}~",
1864        "_bash_completions",
1865    ), // _bash_completions:1 -K
1866    (
1867        "_bash_list-choices",
1868        "list-choices",
1869        "\u{18}~",
1870        "_bash_completions",
1871    ), // _bash_completions:1 -K
1872    (
1873        "_history-complete-older",
1874        "complete-word",
1875        "\u{1b}/",
1876        "_history_complete_word",
1877    ), // _history_complete_word:1 -K
1878    (
1879        "_history-complete-newer",
1880        "complete-word",
1881        "\u{1b},",
1882        "_history_complete_word",
1883    ), // _history_complete_word:1 -K
1884    ("_expand_alias", "complete-word", "\u{18}a", "_expand_alias"), // Base/Completer/_expand_alias:1 -K
1885];
1886
1887/// Install the stock `#compdef -k`/`-K` bindings (see
1888/// STANDARD_COMP_KEYBINDINGS). Runs on the main thread next to
1889/// install_standard_complete_widgets.
1890pub fn install_standard_comp_keybindings() {
1891    for (widget, style, key, func) in STANDARD_COMP_KEYBINDINGS {
1892        install_comp_keybinding(widget, style, key, func);
1893    }
1894}
1895
1896pub fn compinit(fpath: &[PathBuf]) -> CompInitResult {
1897    let start = Instant::now();
1898
1899    // sh:129-134 — install `$_comp_dumpfile` default so engine code
1900    //   that calls `getsparam("_comp_dumpfile")` sees a sensible
1901    //   path. User-supplied `compinit -d FILE` overrides.
1902    if crate::ported::params::getsparam("_comp_dumpfile")
1903        .map(|s| s.is_empty())
1904        .unwrap_or(true)
1905    {
1906        let _ = crate::ported::params::setsparam(
1907            "_comp_dumpfile",
1908            &default_dumpfile_path().to_string_lossy(),
1909        );
1910    }
1911
1912    // sh:138-172 — publish `_comp_options` to the shell-side param
1913    //   table so `$_comp_setup` eval at every entry point picks it
1914    //   up. Use the canonical const list.
1915    crate::ported::params::setaparam(
1916        "_comp_options",
1917        COMP_OPTIONS.iter().map(|s| s.to_string()).collect(),
1918    );
1919
1920    // sh:180-190 — publish `_comp_setup` (the eval string).
1921    let _ = crate::ported::params::setsparam("_comp_setup", COMP_SETUP_EVAL);
1922
1923    // sh:195-197 — initialize the pre/post-hook arrays.
1924    init_comp_funcs_arrays();
1925
1926    // Parallel scan of all directories. Files are read concurrently but the
1927    // RESULT keeps `$fpath` order (rayon's collect is order-preserving), which
1928    // the dedup below depends on.
1929    let scanned: Vec<CompFile> = fpath
1930        .par_iter()
1931        .filter(|dir| dir.as_os_str() != "." && dir.exists())
1932        .flat_map(|dir| scan_directory(dir))
1933        .collect();
1934
1935    // sh:509 — `(( $+_i_test[$_i_name] … )) && continue`: the FIRST `$fpath`
1936    // directory holding a given completer filename wins; later copies are
1937    // skipped. The previous code ran this dedup INSIDE the parallel filter
1938    // against a shared `seen` set, so the surviving copy was whichever thread
1939    // won the race — not the first in fpath order. Doing it here, over the
1940    // already-ordered vector, makes the winner deterministic and correct.
1941    let mut seen: HashSet<String> = HashSet::new();
1942    let all_files: Vec<CompFile> = scanned
1943        .into_iter()
1944        .filter(|f| seen.insert(f.name.clone()))
1945        .collect();
1946
1947    let files_scanned = all_files.len();
1948    let dirs_scanned = fpath.len();
1949
1950    // Build the result maps
1951    let mut result = CompInitResult {
1952        scan_time_ms: start.elapsed().as_millis() as u64,
1953        dirs_scanned,
1954        files_scanned,
1955        ..Default::default()
1956    };
1957
1958    for file in &all_files {
1959        match &file.def {
1960            CompFileDef::CompDef(compdef) => {
1961                match compdef {
1962                    CompDef::Commands(cmds) => {
1963                        for cmd in cmds {
1964                            // sh:519 — compinit registers every scanned file with
1965                            // `compdef -na`, and `-n` (new) means sh:393
1966                            // `if [[ -z "$new" || -z "${_comps[$1]}" ]]` — an
1967                            // EXISTING entry is kept, so the first `$fpath`
1968                            // directory to claim a command owns it. zshrs inserted
1969                            // unconditionally (last writer won), so with two files
1970                            // claiming the same command (`_df` at fpath[24] has
1971                            // `#compdef df gdf`; zsh-more-completions'
1972                            // `_dwarffortress` at fpath[42] has `#compdef
1973                            // dwarffortress df`) `df` completed as Dwarf Fortress
1974                            // and `df -<TAB>` produced nothing.
1975                            //
1976                            // Handle service syntax: cmd=service
1977// sh:393 — the `-n` guard indexes `${_comps[$1]}`,
1978                            // the WHOLE word, while sh:394/sh:395 assign under
1979                            // `$cmd` (`${1%%\=*}`, sh:387). `_comps[mailx=mail]`
1980                            // is never a key, so upstream's `=`-form ALWAYS
1981                            // (re)claims the command and always records the
1982                            // service, even when a plain `#compdef` earlier on
1983                            // `$fpath` already owns `$cmd`. Guarding on the
1984                            // split-off name instead let `_mailx` suppress
1985                            // `_mail`'s `mailx=mail`/`Mail=mail`, `_ncl`
1986                            // suppress `_nedit`'s `ncl=nc`, and `_dch` suppress
1987                            // `_debchange`'s `dch=debchange`. Plain words keep
1988                            // genuine first-claim-wins.
1989                            if !result.comps.contains_key(cmd) {
1990                                if let Some(eq_pos) = cmd.find('=') {
1991                                    let cmd_name = &cmd[..eq_pos]; // sh:387 ${1%%\=*}
1992                                    let service = &cmd[eq_pos + 1..]; // sh:395 ${1#*\=}
1993                                    result.comps.insert(cmd_name.to_string(), file.name.clone());
1994                                    // sh:395 — `_services[$cmd]` is set inside
1995                                    // the same guard, never on its own.
1996                                    result
1997                                        .services
1998                                        .insert(cmd_name.to_string(), service.to_string());
1999                                } else {
2000                                    result.comps.insert(cmd.clone(), file.name.clone());
2001                                }
2002                            }
2003                        }
2004                    }
2005                    CompDef::Pattern(pats) => {
2006                        // c:compinit sh:396 — `_patcomps[$1]="$func"`. Pattern
2007                        // compdefs go to `_patcomps` ONLY, never `_comps`.
2008                        for pat in pats {
2009                            result.patcomps.insert(pat.clone(), file.name.clone());
2010                        }
2011                    }
2012                    CompDef::PostPattern(pats) => {
2013                        // c:compinit sh:403 — `_postpatcomps[$1]="$func"`.
2014                        for pat in pats {
2015                            result.postpatcomps.insert(pat.clone(), file.name.clone());
2016                        }
2017                    }
2018                    // One header feeding several tables (sh:384-420). Each
2019                    // list lands in exactly the table its `-N`/`-p`/`-P`
2020                    // prefix selected; the command half keeps the same
2021                    // first-claim-wins + `cmd=service` handling as above.
2022                    CompDef::Mixed {
2023                        commands,
2024                        patterns,
2025                        postpatterns,
2026                    } => {
2027                        for cmd in commands {
2028// sh:393 — the `-n` guard indexes `${_comps[$1]}`,
2029                            // the WHOLE word, while sh:394/sh:395 assign under
2030                            // `$cmd` (`${1%%\=*}`, sh:387). `_comps[mailx=mail]`
2031                            // is never a key, so upstream's `=`-form ALWAYS
2032                            // (re)claims the command and always records the
2033                            // service, even when a plain `#compdef` earlier on
2034                            // `$fpath` already owns `$cmd`. Guarding on the
2035                            // split-off name instead let `_mailx` suppress
2036                            // `_mail`'s `mailx=mail`/`Mail=mail`, `_ncl`
2037                            // suppress `_nedit`'s `ncl=nc`, and `_dch` suppress
2038                            // `_debchange`'s `dch=debchange`. Plain words keep
2039                            // genuine first-claim-wins.
2040                            if !result.comps.contains_key(cmd) {
2041                                if let Some(eq_pos) = cmd.find('=') {
2042                                    let cmd_name = &cmd[..eq_pos]; // sh:387 ${1%%\=*}
2043                                    let service = &cmd[eq_pos + 1..]; // sh:395 ${1#*\=}
2044                                    result.comps.insert(cmd_name.to_string(), file.name.clone());
2045                                    // sh:395 — `_services[$cmd]` is set inside
2046                                    // the same guard, never on its own.
2047                                    result
2048                                        .services
2049                                        .insert(cmd_name.to_string(), service.to_string());
2050                                } else {
2051                                    result.comps.insert(cmd.clone(), file.name.clone());
2052                                }
2053                            }
2054                        }
2055                        for pat in patterns {
2056                            result.patcomps.insert(pat.clone(), file.name.clone());
2057                        }
2058                        for pat in postpatterns {
2059                            result.postpatcomps.insert(pat.clone(), file.name.clone());
2060                        }
2061                    }
2062                    CompDef::KeyBinding { style, keys } => {
2063                        // sh:356-379 — `#compdef -k <style> <keys…>`: the widget
2064                        // takes the FILE's name (e.g. _complete_debug). Collected
2065                        // here; zle -C + bindkey happen in apply_keybindings
2066                        // (this scan may run on a background thread).
2067                        result
2068                            .keybindings
2069                            .push((file.name.clone(), style.clone(), keys.clone()));
2070                    }
2071                    CompDef::WidgetKey { widget, style, key } => {
2072                        // sh:336-354 — `#compdef -K <widget> <style> <key>`:
2073                        // the completion FUNCTION is the file's name, the
2074                        // widget name is explicit.
2075                        result.widgetkeys.push((
2076                            widget.clone(),
2077                            style.clone(),
2078                            key.clone(),
2079                            file.name.clone(),
2080                        ));
2081                    }
2082                }
2083            }
2084            CompFileDef::Autoload(opts) => {
2085                let opts_str = opts.join(" ");
2086                // sh:541 `[[ "$_i_line" != \ # ]] && _compautos[$_i_name]="$_i_line"`.
2087                // `\ #` is extendedglob (sh:75 `setopt extendedglob`) for a
2088                // literal space repeated ZERO or more times, so the test is
2089                // "the rest of the `#autoload` line is not empty and not all
2090                // spaces" — a bare `#autoload` header registers NOTHING.
2091                // sh:540's `autoload -rUz` above it is unconditional; only
2092                // this line is guarded, and zshrs was running it for every
2093                // `#autoload` file. Measured against the Cellar 5.9.2 tree:
2094                // `$#_compautos` came back 178 where `/opt/homebrew/bin/zsh`
2095                // ends at 1 (its single entry is `_call_program` → `+X`);
2096                // the tree holds 177 files whose first line is `#autoload`.
2097                // `${(k)_compautos}` is observable directly, and compdump
2098                // branches on it twice — sh:115 drops every `_compautos`
2099                // key from the bulk `autoload -Uz` list and sh:129 re-emits
2100                // it with its options — so each phantom key both removed a
2101                // name from that list and wrote an option-less
2102                // `autoload -Uz  _name` line in its place.
2103                if !opts_str.is_empty() && opts_str.bytes().any(|b| b != b' ') {
2104                    result.compautos.insert(file.name.clone(), opts_str);
2105                }
2106            }
2107            CompFileDef::None => {}
2108        }
2109    }
2110
2111    result.files = all_files;
2112
2113    // sh:553-569 — the standard completion-widget rebind (`zle -C
2114    //   complete-word .complete-word _main_complete` × 8 + conditional
2115    //   menu-select + TAB/_expand rebind) is NOT done here. `compinit`
2116    //   ships this fpath scan to a worker-pool thread (see
2117    //   `ext_builtins::builtin_compinit`), and ZLE keymaps/widgets live
2118    //   on the main thread — a `zle -C` issued from a worker never
2119    //   reaches the interactive keymap, so TAB stays bound to the
2120    //   builtin `expand-or-complete` and `_main_complete` never fires.
2121    //   The rebind is instead performed synchronously on the main
2122    //   thread in `builtin_compinit`, where it belongs; it needs only
2123    //   `_main_complete` (a Rust fn, always present), not the scan
2124    //   results, so deferring it to the background is unnecessary.
2125
2126    // Publish the result-side compdef state so downstream `getaparam(
2127    //   "_comps")` etc. queries reflect the scan. This is the new-
2128    //   compinit-finish complement of `compdef()`'s per-call publish.
2129    with_state(|s| {
2130        for (k, v) in &result.comps {
2131            put(&mut s.comps, &mut s.dirty.comps, k.clone(), v.clone());
2132        }
2133        for (k, v) in &result.services {
2134            put(&mut s.services, &mut s.dirty.services, k.clone(), v.clone());
2135        }
2136        for (k, v) in &result.patcomps {
2137            put(&mut s.patcomps, &mut s.dirty.patcomps, k.clone(), v.clone());
2138        }
2139        for (k, v) in &result.postpatcomps {
2140            put(&mut s.postpatcomps, &mut s.dirty.postpatcomps, k.clone(), v.clone());
2141        }
2142        for (k, v) in &result.compautos {
2143            put(&mut s.compautos, &mut s.dirty.compautos, k.clone(), v.clone());
2144        }
2145        publish_compdef_state_mut(s);
2146    });
2147
2148    result
2149}
2150
2151// `compdump`, `check_dump`, and `escape_zsh_string` moved to
2152// `compsys/ported/compdump.rs` (1:1 with upstream `Completion/compdump`).
2153pub use super::compdump::{check_dump, compdump};
2154
2155/// Build SQLite cache from fpath scan
2156///
2157/// This is the main entry point for initializing the completion system.
2158/// It scans fpath directories, parses #compdef directives, and populates
2159/// the SQLite cache for fast lookups.
2160pub fn build_cache_from_fpath(
2161    fpath: &[PathBuf],
2162    cache: &mut crate::compsys::cache::CompsysCache,
2163) -> std::io::Result<CompInitResult> {
2164    use std::time::Instant;
2165
2166    let t0 = Instant::now();
2167    let result = compinit(fpath);
2168    let scan_time = t0.elapsed();
2169
2170    let t1 = Instant::now();
2171
2172    // Populate comps table (_comps hash)
2173    let comps: Vec<(String, String)> = result
2174        .comps
2175        .iter()
2176        .map(|(k, v)| (k.clone(), v.clone()))
2177        .collect();
2178    cache
2179        .set_comps_bulk(&comps)
2180        .map_err(|e| std::io::Error::other(e.to_string()))?;
2181
2182    // Populate services table (_services hash)
2183    let services: Vec<(String, String)> = result
2184        .services
2185        .iter()
2186        .map(|(k, v)| (k.clone(), v.clone()))
2187        .collect();
2188    cache
2189        .set_services_bulk(&services)
2190        .map_err(|e| std::io::Error::other(e.to_string()))?;
2191
2192    // Populate patcomps table (_patcomps hash)
2193    for (pattern, function) in &result.patcomps {
2194        cache
2195            .set_patcomp(pattern, function)
2196            .map_err(|e| std::io::Error::other(e.to_string()))?;
2197    }
2198
2199    // Populate postpatcomps table (_postpatcomps hash). These are the
2200    // `#compdef -P pat` entries (compinit sh:404). They must NOT go into
2201    // `patcomps`: `_dispatch` walks `_patcomps` before the `$_comps` name
2202    // lookup (sh:26) and `_postpatcomps` after it (sh:71), and only the
2203    // post pass sets `_compskip=default` (sh:72), which is what suppresses
2204    // the sh:84 default-completer fallback. Merging the two tables ran
2205    // every `-P` completer in the wrong phase without that flag, so
2206    // `PATH=/usr/bin:<TAB>` ran `_dir_list` and then ALSO fell through to
2207    // `_value` -> `_default`, listing every file instead of directories.
2208    for (pattern, function) in &result.postpatcomps {
2209        cache
2210            .set_postpatcomp(pattern, function)
2211            .map_err(|e| std::io::Error::other(e.to_string()))?;
2212    }
2213
2214    let comps_time = t1.elapsed();
2215    let t2 = Instant::now();
2216
2217    // Populate autoloads table with function bodies for instant loading
2218    // Bodies were already read during parallel scan - no extra I/O here
2219    let autoloads: Vec<(String, String, String)> = result
2220        .files
2221        .iter()
2222        .filter(|f| matches!(f.def, CompFileDef::CompDef(_) | CompFileDef::Autoload(_)))
2223        .filter_map(|f| {
2224            let path_str = f.path.to_string_lossy().to_string();
2225            let body = f.body.as_ref()?.clone();
2226            Some((f.name.clone(), path_str, body))
2227        })
2228        .collect();
2229    cache
2230        .add_autoloads_with_bodies_bulk(&autoloads)
2231        .map_err(|e| std::io::Error::other(e.to_string()))?;
2232
2233    let autoloads_time = t2.elapsed();
2234
2235    // Timing logged by caller in vm_helper via tracing
2236
2237    Ok(result)
2238}
2239
2240/// Load _comps from existing cache (instantaneous)
2241///
2242/// Returns a CompInitResult populated from the SQLite cache without rescanning fpath.
2243/// Use this after the cache has been built with `build_cache_from_fpath`.
2244///
2245/// This is the equivalent of `compinit -C` with a valid zcompdump - it skips
2246/// the fpath scan entirely and just loads from cache.
2247#[allow(clippy::field_reassign_with_default)] // result is mutated across many subsequent statements; struct-literal init not practical
2248pub fn load_from_cache(
2249    cache: &crate::compsys::cache::CompsysCache,
2250) -> std::io::Result<CompInitResult> {
2251    use std::time::Instant;
2252    let start = Instant::now();
2253
2254    let mut result = CompInitResult::default();
2255
2256    // Load comps - single query
2257    result.comps = cache
2258        .get_all_comps()
2259        .map_err(|e| std::io::Error::other(e.to_string()))?;
2260
2261    // Load patcomps - single query
2262    for (pat, func) in cache
2263        .patcomps_kv()
2264        .map_err(|e| std::io::Error::other(e.to_string()))?
2265    {
2266        result.patcomps.insert(pat, func);
2267    }
2268
2269    // Load postpatcomps - single query. Without this the `-C` cache-hit path
2270    // published an EMPTY `_postpatcomps` (ext_builtins.rs, sh:116/121), so
2271    // `_dispatch`'s post pass had nothing to walk and every `#compdef -P`
2272    // completer (`_dir_list`, `_urls`, `_locales`, `_gcc`, `_python`, …) was
2273    // dead on any shell that started from the cache.
2274    for (pat, func) in cache
2275        .postpatcomps_kv()
2276        .map_err(|e| std::io::Error::other(e.to_string()))?
2277    {
2278        result.postpatcomps.insert(pat, func);
2279    }
2280
2281    // sh:94 declares `_services` beside `_comps`, and sh:292 / sh:395 fill it
2282    // AT REGISTRATION TIME, not on demand. `_dispatch` reads the PARAMETER
2283    // (`service="${_services[$str]:-$str}"`, sh:Completion/Base/Core/_dispatch:26),
2284    // so nothing ever calls back into the cache and a lazy `get_service` is
2285    // never reached — the previous comment here ("loaded on-demand … zsh
2286    // lazily populates") was simply wrong, and the table was left empty.
2287    // `$service` then fell back to the COMMAND NAME, so `_virtualbox`'s
2288    // `_call_function ret _$service` (sh:20) ran `_VBoxManage` instead of
2289    // `_vboxmanage` — a different completer file with a different subcommand
2290    // set. The rows were in the database the whole time (`VBoxManage` ->
2291    // `vboxmanage`); only the read was missing.
2292    for (cmd, svc) in cache
2293        .services_kv()
2294        .map_err(|e| std::io::Error::other(e.to_string()))?
2295    {
2296        result.services.insert(cmd, svc);
2297    }
2298
2299    result.scan_time_ms = start.elapsed().as_millis() as u64;
2300    result.files_scanned = result.comps.len();
2301
2302    Ok(result)
2303}
2304
2305/// Fast check if compinit is needed
2306///
2307/// Returns the number of completion entries in cache, or 0 if cache is empty/invalid.
2308/// Use this to decide whether to run full compinit or load_from_cache.
2309pub fn cache_entry_count(cache: &crate::compsys::cache::CompsysCache) -> usize {
2310    cache.comp_count().unwrap_or(0) as usize
2311}
2312
2313/// Lazy compinit - validates cache exists but doesn't load into memory
2314///
2315/// This is the fastest option for shell startup. It just verifies the cache
2316/// is valid and returns immediately. Actual lookups happen via cache.get_comp().
2317///
2318/// Returns (is_valid, entry_count) in microseconds.
2319pub fn compinit_lazy(cache: &crate::compsys::cache::CompsysCache) -> (bool, usize) {
2320    let count = cache.comp_count().unwrap_or(0) as usize;
2321    (count > 0, count)
2322}
2323
2324/// Metadata key holding the `comps` row count a cache build finished
2325/// with. Written as the LAST statement of `build_cache_from_fpath`'s
2326/// caller, after every table is populated — see
2327/// `stamp_cache_complete` and `cache_is_valid`.
2328pub const CACHE_COMPLETE_KEY: &str = "comps_rows_at_build_end";
2329
2330/// Metadata key holding `(mtime_secs, mtime_nsecs, len)` of the `zshrs`
2331/// binary that built the cache &mdash; see
2332/// `crate::compsys::cache::binary_identity_stamp`.
2333///
2334/// The row-count stamp above says a build FINISHED; it says nothing
2335/// about WHICH build. The rows carry completer names, service names and
2336/// pattern splits whose meaning is fixed by the code that wrote them,
2337/// and the only guard against a cache outliving that code was a
2338/// hand-bumped `COMPLETION_SCHEMA_GENERATION` constant, i.e. a human
2339/// remembering. Stamping the producing binary makes every rebuilt
2340/// `zshrs` reject its predecessor's rows without anyone remembering
2341/// anything.
2342pub const CACHE_BINARY_KEY: &str = "builder_binary_identity";
2343
2344/// Metadata key holding the ORDERED `$fpath` the cache was built from.
2345///
2346/// The row-count stamp says a build FINISHED and the binary stamp says
2347/// WHICH BUILD wrote it; neither says WHAT INPUT it was built from. The
2348/// cache is a function of `$fpath` — which directories exist and, because
2349/// `compdef -na` is first-claim-wins (sh:519), in WHAT ORDER — so a cache
2350/// built from a 4-directory `$fpath` was silently accepted by a session
2351/// whose `$fpath` had 50, and every command whose completer lived in the
2352/// other 46 resolved to nothing. With up to 16 of the user's shells racing
2353/// on one cache file, whichever shell rebuilt it last decided what every
2354/// later shell completed: observed `comps` row counts of 1849, 1881, 1928
2355/// and 51578 for the same user on the same day, which is why parity cells
2356/// appeared to "fix themselves" and then regress.
2357///
2358/// The value is the NUL-joined directory list, stored verbatim rather than
2359/// hashed so a mismatch can be read straight out of the database.
2360pub const CACHE_FPATH_KEY: &str = "fpath_identity";
2361
2362/// The cache-identity value for an ordered `$fpath`.
2363pub fn fpath_identity(fpath: &[PathBuf]) -> String {
2364    fpath
2365        .iter()
2366        .map(|p| p.to_string_lossy().into_owned())
2367        .collect::<Vec<_>>()
2368        .join("\0")
2369}
2370
2371/// Record that a freshly built cache is complete.
2372///
2373/// Must be the final write of a build. Pairs with `cache_is_valid`.
2374pub fn stamp_cache_complete(
2375    cache: &crate::compsys::cache::CompsysCache,
2376    fpath: &[PathBuf],
2377) -> bool {
2378    let Some(binary) = crate::compsys::cache::binary_identity_stamp() else {
2379        // No `current_exe()`: the cache cannot be attributed to a build,
2380        // and an unattributable cache is never installed.
2381        return false;
2382    };
2383    if cache.set_metadata(CACHE_BINARY_KEY, &binary).is_err() {
2384        return false;
2385    }
2386    // Built FROM this `$fpath`, in this order.
2387    if cache
2388        .set_metadata(CACHE_FPATH_KEY, &fpath_identity(fpath))
2389        .is_err()
2390    {
2391        return false;
2392    }
2393    match cache.comp_count() {
2394        Ok(n) => cache
2395            .set_metadata(CACHE_COMPLETE_KEY, &n.to_string())
2396            .is_ok(),
2397        Err(_) => false,
2398    }
2399}
2400
2401/// Check if cache is valid and up-to-date
2402///
2403/// Returns true only for a cache some build finished writing; false when
2404/// it is empty, unstamped, or still filling.
2405///
2406/// A bare `comp_count() > 0` was NOT a validity test. The rebuild path
2407/// spends seconds inserting ~50k `comps` rows, and up to 16 of the user's
2408/// shells run against this one file, so a shell starting inside that
2409/// window saw a non-zero partial count, took the cache-hit branch, and
2410/// published a `_comps` holding a fraction of the registrations —
2411/// `_dispatch` then resolved no completer for any command and every
2412/// `<cmd> <TAB>` completed nothing. Matching the stamped count against
2413/// the live count makes "still filling" and "builder died midway" both
2414/// fail: the stamp is written once, at the end, and any later insert
2415/// moves the live count away from it.
2416pub fn cache_is_valid(
2417    cache: &crate::compsys::cache::CompsysCache,
2418    fpath: &[PathBuf],
2419) -> bool {
2420    let rows = cache.comp_count().unwrap_or(0);
2421    if rows <= 0 {
2422        return false;
2423    }
2424    // Built by THIS binary, exactly. A cache written by another build
2425    // is rejected whether it is older or newer, so a downgrade is a
2426    // miss rather than a silent hit on rows the running code no longer
2427    // agrees with.
2428    let Some(running) = crate::compsys::cache::binary_identity_stamp() else {
2429        return false;
2430    };
2431    match cache.get_metadata(CACHE_BINARY_KEY) {
2432        Ok(Some(built_by)) if built_by == running => {}
2433        _ => return false,
2434    }
2435    // Built from the SAME `$fpath`, in the same order. Without this a cache
2436    // built by a shell with a short `$fpath` is accepted by one with a long
2437    // `$fpath`, publishing a `_comps` that is missing every completer from
2438    // the directories the builder never saw.
2439    match cache.get_metadata(CACHE_FPATH_KEY) {
2440        Ok(Some(built_from)) if built_from == fpath_identity(fpath) => {}
2441        _ => return false,
2442    }
2443    match cache.get_metadata(CACHE_COMPLETE_KEY) {
2444        Ok(Some(stamp)) => stamp.parse::<i64>().map(|n| n == rows).unwrap_or(false),
2445        _ => false,
2446    }
2447}
2448
2449/// Get system fpath from environment or defaults
2450pub fn get_system_fpath() -> Vec<PathBuf> {
2451    // Try FPATH env var first
2452    if let Ok(fpath_str) = std::env::var("FPATH") {
2453        if !fpath_str.is_empty() {
2454            return fpath_str.split(':').map(PathBuf::from).collect();
2455        }
2456    }
2457
2458    // Default paths for common systems
2459    let mut paths = Vec::new();
2460
2461    // macOS Homebrew
2462    for base in &["/opt/homebrew", "/usr/local"] {
2463        paths.push(PathBuf::from(format!("{}/share/zsh/site-functions", base)));
2464        paths.push(PathBuf::from(format!("{}/share/zsh/functions", base)));
2465    }
2466
2467    // System zsh
2468    for version in &["5.9", "5.8", "5.7"] {
2469        paths.push(PathBuf::from(format!(
2470            "/usr/share/zsh/{}/functions",
2471            version
2472        )));
2473    }
2474    paths.push(PathBuf::from("/usr/share/zsh/functions"));
2475    paths.push(PathBuf::from("/usr/share/zsh/site-functions"));
2476
2477    // Zinit/zplugin common paths
2478    if let Ok(home) = std::env::var("HOME") {
2479        paths.push(PathBuf::from(format!("{}/.zinit/completions", home)));
2480        paths.push(PathBuf::from(format!("{}/.zplugin/completions", home)));
2481        paths.push(PathBuf::from(format!(
2482            "{}/.local/share/zsh/site-functions",
2483            home
2484        )));
2485    }
2486
2487    // Filter to existing directories
2488    paths.into_iter().filter(|p| p.exists()).collect()
2489}
2490
2491/// Options for compinit
2492#[derive(Clone, Debug, Default)]
2493pub struct CompInitOpts {
2494    /// Dump file path (-d)
2495    pub dump_file: Option<PathBuf>,
2496    /// Skip dump (-D)
2497    pub no_dump: bool,
2498    /// Skip security check (-C)
2499    pub no_check: bool,
2500    /// Ignore insecure dirs (-i)
2501    pub ignore_insecure: bool,
2502    /// Use insecure dirs (-u)
2503    pub use_insecure: bool,
2504}
2505
2506impl CompInitOpts {
2507    /// Parse compinit arguments
2508    pub fn parse(args: &[String]) -> Self {
2509        let mut opts = Self::default();
2510        let mut i = 0;
2511
2512        while i < args.len() {
2513            match args[i].as_str() {
2514                "-d" if i + 1 < args.len() && !args[i + 1].starts_with('-') => {
2515                    opts.dump_file = Some(PathBuf::from(&args[i + 1]));
2516                    i += 1;
2517                }
2518                "-D" => opts.no_dump = true,
2519                "-C" => opts.no_check = true,
2520                "-i" => opts.ignore_insecure = true,
2521                "-u" => opts.use_insecure = true,
2522                _ => {}
2523            }
2524            i += 1;
2525        }
2526
2527        opts
2528    }
2529}
2530
2531// =====================================================================
2532// compdef() — runtime registration entry point.
2533//
2534// Upstream defines this inside `Completion/compinit` (sh:253-446); we
2535// mirror that organizational choice. User `.zshrc` lines like:
2536//   compdef _git git
2537//   compdef -p '*-test' _test
2538//   compdef -d obsolete
2539// land here.
2540//
2541// State lives in a session-side `CompdefState` (a Mutex<HashMap>
2542// quintet for `_comps`, `_services`, `_patcomps`, `_postpatcomps`,
2543// `_compautos`). Cluster ports already read these via shell-side
2544// `assoc_get("_comps")` etc.; the published-to-paramtab step happens
2545// in `publish_compdef_state_mut` at the bottom of this section, which
2546// MERGES into those parameters rather than rebuilding them (see
2547// `merge_hparam`) — the state is one contributor to `_comps`, never
2548// its definition.
2549// =====================================================================
2550
2551/// Session-side compdef registrations. Mirrors the five upstream
2552/// assoc arrays one-for-one.
2553#[derive(Default)]
2554pub struct CompdefState {
2555    pub comps: HashMap<String, String>,
2556    pub services: HashMap<String, String>,
2557    pub patcomps: HashMap<String, String>,
2558    pub postpatcomps: HashMap<String, String>,
2559    pub compautos: HashMap<String, String>,
2560    /// Keys a `compdef -d` (sh:426-444 `unset "_comps[$^@]"`) removed and
2561    /// that the next publish still has to unset on the shell side.
2562    /// Removal is the one edit a merge cannot express, so it is carried
2563    /// explicitly instead of being implied by the absence of a key.
2564    removed: CompdefRemovals,
2565    /// Keys set since the last publish. A publish writes only these (plus
2566    /// `removed`) into the shell-side hashes, the way upstream compdef
2567    /// assigns one element (`_comps[$cmd]=$func`, Completion/compinit
2568    /// sh:447-534). Re-publishing the whole table on every compdef made
2569    /// loading N completions O(N^2) over a 50k-entry `_comps`.
2570    dirty: CompdefDirty,
2571}
2572
2573/// Per-array keys set since the last publish.
2574#[derive(Default)]
2575struct CompdefDirty {
2576    comps: HashSet<String>,
2577    services: HashSet<String>,
2578    patcomps: HashSet<String>,
2579    postpatcomps: HashSet<String>,
2580    compautos: HashSet<String>,
2581}
2582
2583/// Set `k` to `v` in one compdef table and mark it for the next publish.
2584fn put(map: &mut HashMap<String, String>, dirty: &mut HashSet<String>, k: String, v: String) {
2585    dirty.insert(k.clone());
2586    map.insert(k, v);
2587}
2588
2589/// Per-array key lists for pending `compdef -d` removals.
2590#[derive(Default)]
2591struct CompdefRemovals {
2592    comps: Vec<String>,
2593    services: Vec<String>,
2594    patcomps: Vec<String>,
2595    postpatcomps: Vec<String>,
2596}
2597
2598static COMPDEF_STATE: Mutex<Option<CompdefState>> = Mutex::new(None);
2599
2600/// Depth of the enclosing `compdef_batch` calls. See that function.
2601static PUBLISH_DEPTH: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
2602
2603/// Lock the session-side state, initializing on first call.
2604fn with_state<F, R>(f: F) -> R
2605where
2606    F: FnOnce(&mut CompdefState) -> R,
2607{
2608    let mut guard = COMPDEF_STATE.lock().unwrap();
2609    if guard.is_none() {
2610        *guard = Some(CompdefState::default());
2611    }
2612    f(guard.as_mut().unwrap())
2613}
2614
2615/// Run `f` with shell-side publication held until it returns, then
2616/// publish once.
2617///
2618/// Publication is a whole-hash read-modify-write: `gethparam`/`sethparam`
2619/// are the only associative-array accessors `params.rs` exposes, so there
2620/// is no way to assign a single `_comps[$cmd]` the way sh:376 does. A bulk
2621/// replay (`cdreplay` after zinit turbo) calls `compdef` once per deferred
2622/// registration and would otherwise pay that read-modify-write over a
2623/// 50k-entry `_comps` on every one of them. The batched end state is
2624/// identical — the same accumulated `CompdefState`, published once.
2625pub fn compdef_batch<R>(f: impl FnOnce() -> R) -> R {
2626    use std::sync::atomic::Ordering;
2627    // Restores the depth even if `f` panics or returns early.
2628    struct Depth;
2629    impl Drop for Depth {
2630        fn drop(&mut self) {
2631            PUBLISH_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
2632        }
2633    }
2634    PUBLISH_DEPTH.fetch_add(1, Ordering::Relaxed);
2635    let out = {
2636        let _depth = Depth;
2637        f()
2638    };
2639    with_state(publish_compdef_state_mut);
2640    out
2641}
2642
2643/// Apply one array's pending edits to its shell-side associative array.
2644///
2645/// `set` is overlaid onto whatever the parameter already holds and
2646/// `remove` is unset from it — the parameter is never rebuilt from `set`
2647/// alone. That distinction is the whole fix: `CompdefState` holds only
2648/// what THIS process's `compdef` calls (and its own `$fpath` scan)
2649/// registered, while `compinit -C`'s cache-hit path fills the parameter
2650/// directly (`ext_builtins.rs`, `set_assoc`) without going through the
2651/// state at all. Publishing `flatten(&s.comps)` wholesale therefore
2652/// replaced a 51 647-entry `_comps` with however many keys the state
2653/// happened to have — one, for a session whose only `compdef` was
2654/// `_zstyle zstyle` — and `_dispatch` then resolved an empty completer
2655/// for EVERY command, so `man <TAB>`, `git <TAB>` and `kill <TAB>` all
2656/// completed nothing at all.
2657fn merge_hparam(name: &str, set: &HashMap<String, String>, remove: &[String]) {
2658    if set.is_empty() && remove.is_empty() {
2659        return;
2660    }
2661    // BTreeMap so the published pair order is deterministic, matching what
2662    // the previous sort-by-key flatten produced. The read goes through
2663    // `subst::assoc_get` because that is the accessor backed by the same
2664    // `paramtab_hashed_storage` `sethparam` writes — `gethparam` returns
2665    // the VALUES only (params.rs:5723-5728), not the key/value pairs.
2666    let mut merged: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
2667    if let Some(existing) = crate::ported::subst::assoc_get(name) {
2668        for (k, v) in existing {
2669            merged.insert(k, v);
2670        }
2671    }
2672    for k in remove {
2673        merged.remove(k);
2674    }
2675    for (k, v) in set {
2676        merged.insert(k.clone(), v.clone());
2677    }
2678    let mut out = Vec::with_capacity(merged.len() * 2);
2679    for (k, v) in merged {
2680        out.push(k);
2681        out.push(v);
2682    }
2683    // These are ASSOCIATIVE arrays in zsh (`typeset -gHA _comps _services
2684    // _patcomps _postpatcomps` / `_compautos`, sh:116/121). They MUST be
2685    // published via `sethparam` (hashed) — the `[k, v, k, v, …]` pair
2686    // layout above is what `sethparam` consumes. Using `setaparam` (plain
2687    // array) made `_comps` a flat array, so `${_comps[ls]}` couldn't
2688    // hash-lookup and `_complete`/`_dispatch` found no completer for ANY
2689    // command → every `<cmd> <TAB>` (incl. `ls -<TAB>`) just rang the bell.
2690    // The synchronous `compinit -C` path used `set_assoc` (→ sethparam) and
2691    // worked, which is why only the fresh/compdef-driven path was broken.
2692    // Bug #655.
2693    crate::ported::params::sethparam(name, out);
2694}
2695
2696/// Publish the in-memory state into the shell-side assoc arrays so
2697/// engine cluster code (which reads via `getaparam("_comps")` etc.)
2698/// sees the updates.
2699///
2700/// A no-op inside `compdef_batch`; the batch publishes on exit.
2701fn publish_compdef_state_mut(s: &mut CompdefState) {
2702    use std::sync::atomic::Ordering;
2703    if PUBLISH_DEPTH.load(Ordering::Relaxed) > 0 {
2704        return;
2705    }
2706    publish_hparam("_comps", &s.comps, &s.dirty.comps, &s.removed.comps);
2707    publish_hparam("_services", &s.services, &s.dirty.services, &s.removed.services);
2708    publish_hparam("_patcomps", &s.patcomps, &s.dirty.patcomps, &s.removed.patcomps);
2709    publish_hparam("_postpatcomps", &s.postpatcomps, &s.dirty.postpatcomps, &s.removed.postpatcomps);
2710    publish_hparam("_compautos", &s.compautos, &s.dirty.compautos, &[]);
2711    s.removed = CompdefRemovals::default();
2712    s.dirty = CompdefDirty::default();
2713}
2714
2715/// Publish one compdef table. Once the shell-side hash exists, only the keys
2716/// changed since the last publish are written, element by element, the way
2717/// upstream compdef does `_comps[$cmd]=$func` / `unset "_comps[$^@]"`
2718/// (Completion/compinit sh:421-534). The first publish, or one after the
2719/// hash was unset, takes the whole-table merge.
2720fn publish_hparam(
2721    name: &str,
2722    full: &HashMap<String, String>,
2723    dirty: &HashSet<String>,
2724    remove: &[String],
2725) {
2726    if dirty.is_empty() && remove.is_empty() {
2727        return;
2728    }
2729    {
2730        let mut store = crate::ported::params::paramtab_hashed_storage()
2731            .lock()
2732            .unwrap_or_else(|e| e.into_inner());
2733        if let Some(hash) = store.get_mut(name) {
2734            for k in remove {
2735                hash.shift_remove(k);
2736            }
2737            for k in dirty {
2738                if let Some(v) = full.get(k) {
2739                    hash.insert(k.clone(), v.clone());
2740                }
2741            }
2742            return;
2743        }
2744    }
2745    merge_hparam(name, full, remove);
2746}
2747
2748/// Read one key out of a shell-side assoc array.
2749///
2750/// The parameter — not `CompdefState` — is the source of truth for what
2751/// is registered: `compinit -C` fills it from the dump/cache without
2752/// touching the state (see `merge_hparam`).
2753fn hparam_has_key(name: &str, key: &str) -> bool {
2754    crate::ported::subst::assoc_get(name)
2755        .map(|m| m.contains_key(key))
2756        .unwrap_or(false)
2757}
2758
2759/// Parse `compdef`'s short-option flags via the upstream
2760/// `getopts "anpPkKde"` (sh:267).
2761#[derive(Default, Debug)]
2762struct CompdefFlags {
2763    autol: bool,
2764    new: bool,
2765    delete: bool,
2766    eval: bool,
2767    /// Mutually-exclusive `-p`/`-P`/`-k`/`-K`. Only the most-recent
2768    /// wins; upstream errors on duplicate, we tolerate.
2769    spec_type: SpecType,
2770}
2771
2772#[derive(Default, Debug, PartialEq, Clone, Copy)]
2773enum SpecType {
2774    #[default]
2775    Normal,
2776    Pattern,
2777    PostPattern,
2778    Key,
2779    WidgetKey,
2780}
2781
2782fn parse_compdef_flags(args: &[String]) -> Result<(CompdefFlags, usize), String> {
2783    let mut flags = CompdefFlags::default();
2784    let mut idx = 0usize;
2785    while idx < args.len() {
2786        let a = &args[idx];
2787        if !a.starts_with('-') || a == "-" || a == "--" {
2788            break;
2789        }
2790        // sh:267 getopts allows combined flags like `-an`. Walk each
2791        //   letter after the leading `-`.
2792        for c in a.chars().skip(1) {
2793            match c {
2794                'a' => flags.autol = true,
2795                'n' => flags.new = true,
2796                'd' => flags.delete = true,
2797                'e' => flags.eval = true,
2798                'p' => flags.spec_type = SpecType::Pattern,
2799                'P' => flags.spec_type = SpecType::PostPattern,
2800                'k' => flags.spec_type = SpecType::Key,
2801                'K' => flags.spec_type = SpecType::WidgetKey,
2802                _ => return Err(format!("compdef: unknown option: -{}", c)),
2803            }
2804        }
2805        idx += 1;
2806    }
2807    Ok((flags, idx))
2808}
2809
2810/// `compdef` — register or unregister completion functions for
2811/// commands. Faithful to upstream `Completion/compinit` sh:253-446.
2812///
2813/// Returns the upstream-compatible exit code: 0 on success, 1 on
2814/// usage error.
2815pub fn compdef(args: &[String]) -> i32 {
2816    // sh:262
2817    if args.is_empty() {
2818        eprintln!("compdef: I need arguments");
2819        return 1;
2820    }
2821    let (flags, mut idx) = match parse_compdef_flags(args) {
2822        Ok(p) => p,
2823        Err(e) => {
2824            eprintln!("{}", e);
2825            return 1;
2826        }
2827    };
2828    // sh:293
2829    if idx >= args.len() {
2830        eprintln!("compdef: I need arguments");
2831        return 1;
2832    }
2833
2834    if flags.delete {
2835        // sh:426-444  -d: delete by name from the right hash. The key has
2836        // to be dropped from the session state AND queued for removal from
2837        // the shell parameter — a key registered by `compinit -C`'s cache
2838        // load is only ever in the parameter, so removing it from the state
2839        // alone would leave `compdef -d` a no-op for everything the dump
2840        // defined.
2841        let names = &args[idx..];
2842        with_state(|s| match flags.spec_type {
2843            SpecType::Pattern => {
2844                for n in names {
2845                    s.patcomps.remove(n);
2846                    s.removed.patcomps.push(n.clone());
2847                }
2848            }
2849            SpecType::PostPattern => {
2850                for n in names {
2851                    s.postpatcomps.remove(n);
2852                    s.removed.postpatcomps.push(n.clone());
2853                }
2854            }
2855            SpecType::Key | SpecType::WidgetKey => {
2856                eprintln!("compdef: cannot restore key bindings");
2857            }
2858            SpecType::Normal => {
2859                for n in names {
2860                    s.comps.remove(n);
2861                    s.services.remove(n);
2862                    s.removed.comps.push(n.clone());
2863                    s.removed.services.push(n.clone());
2864                }
2865            }
2866        });
2867        with_state(publish_compdef_state_mut);
2868        return 0;
2869    }
2870
2871    // sh:298-327  service-alias mode: no flags + first arg contains `=`.
2872    //   Each subsequent arg must also contain `=` (else it's an error).
2873    if !flags.eval && args[idx].contains('=') {
2874        let mut ret: i32 = 0;
2875        while idx < args.len() {
2876            let entry = args[idx].clone();
2877            idx += 1;
2878            if !entry.contains('=') {
2879                eprintln!("compdef: invalid argument: {}", entry);
2880                ret = 1;
2881                continue;
2882            }
2883            let mut sp = entry.splitn(2, '=');
2884            let cmd = sp.next().unwrap_or("").to_string();
2885            let svc_in = sp.next().unwrap_or("").to_string();
2886            // sh:307-311 — resolve `$svc` and look up its completion
2887            //   function. zsh reads the `_comps`/`_services` PARAMETERS
2888            //   (`func="$_comps[...]"`), which are the source of truth:
2889            //   the `compinit -C` dump-source path and third-party plugins
2890            //   populate the parameters directly, while the internal
2891            //   `s.comps` state only sees native `compdef` calls — so it
2892            //   lags and made `compdef func=cmd` wrongly report
2893            //   "unknown command or service" even though `$_comps[cmd]`
2894            //   was set. Read the parameters first, fall back to s.comps.
2895            let comps_param = crate::ported::subst::assoc_get("_comps").unwrap_or_default();
2896            // sh:307 — `${_services[(r)$svc]:-$svc}`. `(r)` returns the
2897            //   matching VALUE (== $svc for a literal), else the `:-$svc`
2898            //   default, so the effective service key is `$svc` itself.
2899            let resolved_svc = svc_in.clone();
2900            let func = comps_param
2901                .get(&resolved_svc)
2902                .filter(|f| !f.is_empty())
2903                .cloned()
2904                .or_else(|| {
2905                    with_state(|s| s.comps.get(&resolved_svc).cloned()).filter(|f| !f.is_empty())
2906                })
2907                .or_else(|| {
2908                    // sh:311 fallback to first matching pat/postpat key,
2909                    //   again preferring the parameters over s.* state.
2910                    let pat = crate::ported::subst::assoc_get("_patcomps").unwrap_or_default();
2911                    let postpat =
2912                        crate::ported::subst::assoc_get("_postpatcomps").unwrap_or_default();
2913                    pat.iter()
2914                        .find(|(k, _)| pattern_matches(k, &svc_in))
2915                        .map(|(_, v)| v.clone())
2916                        .or_else(|| {
2917                            postpat
2918                                .iter()
2919                                .find(|(k, _)| pattern_matches(k, &svc_in))
2920                                .map(|(_, v)| v.clone())
2921                        })
2922                        .or_else(|| {
2923                            with_state(|s| {
2924                                s.patcomps
2925                                    .iter()
2926                                    .find(|(k, _)| pattern_matches(k, &svc_in))
2927                                    .map(|(_, v)| v.clone())
2928                                    .or_else(|| {
2929                                        s.postpatcomps
2930                                            .iter()
2931                                            .find(|(k, _)| pattern_matches(k, &svc_in))
2932                                            .map(|(_, v)| v.clone())
2933                                    })
2934                            })
2935                        })
2936                })
2937                .unwrap_or_default();
2938            if func.is_empty() {
2939                eprintln!("compdef: unknown command or service: {}", svc_in);
2940                ret = 1;
2941                continue;
2942            }
2943            // sh:308-309 — `[[ -n ${_services[$svc]} ]] && svc=${_services[$svc]}`.
2944            let services_param = crate::ported::subst::assoc_get("_services").unwrap_or_default();
2945            let svc_for_state = services_param
2946                .get(&svc_in)
2947                .filter(|v| !v.is_empty())
2948                .cloned()
2949                .or_else(|| with_state(|s| s.services.get(&svc_in).cloned()))
2950                .unwrap_or(svc_in.clone());
2951            with_state(|s| {
2952                put(&mut s.comps, &mut s.dirty.comps, cmd.clone(), func.clone());
2953                put(&mut s.services, &mut s.dirty.services, cmd, svc_for_state);
2954            });
2955        }
2956        with_state(publish_compdef_state_mut);
2957        return ret;
2958    }
2959
2960    // sh:332-334  First positional after flags is the function name.
2961    let func = args[idx].clone();
2962    idx += 1;
2963
2964    // sh:333  `-a` → autoload
2965    if flags.autol && func.starts_with('_') {
2966        // dispatch `autoload -rUz <func>` via the exec accessors bridge.
2967        let _ = crate::ported::exec::dispatch_function_call(
2968            "autoload",
2969            &["-rUz".to_string(), func.clone()],
2970        );
2971        // sh:333 is the WHOLE of `-a`: `[[ -n "$autol" ]] && autoload -rUz
2972        // "$func"`. `_compautos` is written in exactly one place upstream,
2973        // compinit sh:541's `#autoload` arm — `compdef` never touches it.
2974        // zshrs additionally inserted `_compautos[$func]="-rUz"` here, which
2975        // has no counterpart and is observable both ways: `${(k)_compautos}`
2976        // gains a key zsh does not have, and compdump then moves that name
2977        // out of its `autoload -Uz` list (sh:115) into a bogus
2978        // `autoload -Uz -rUz $func` line (sh:129). Verified against
2979        // /opt/homebrew/bin/zsh 5.9.2: `compdef -a _mytest mytestcmd` leaves
2980        // `$#_compautos` at 1 and `${_compautos[_mytest]}` unset.
2981    }
2982
2983    // sh:336-425
2984    match flags.spec_type {
2985        SpecType::WidgetKey => {
2986            // sh:337-355  -K widget-name comp-widget key  (in triples)
2987            let mut i = idx;
2988            while i + 2 < args.len() {
2989                let mut wname = args[i].clone();
2990                let mut comp_widget = args[i + 1].clone();
2991                let key = args[i + 2].clone();
2992                if !wname.starts_with('_') {
2993                    wname = format!("_{}", wname);
2994                }
2995                if !comp_widget.starts_with('.') {
2996                    comp_widget = format!(".{}", comp_widget);
2997                }
2998                // sh:346 `zle -C` + sh:347-352 `bindkey` — through the
2999                // BUILTIN entries; dispatch_function_call resolves only
3000                // shell functions and silently no-ops for builtins, which
3001                // left every `#compdef -K` widget (^Xe _expand_word,
3002                // \e/ _history-complete-older, …) unbound.
3003                install_comp_keybinding(&wname, &comp_widget, &key, &func);
3004                i += 3;
3005            }
3006        }
3007        SpecType::Key => {
3008            // sh:356-379  -k style key... (in 1+ pairs)
3009            if idx >= args.len() {
3010                eprintln!("compdef: missing keys");
3011                return 1;
3012            }
3013            let mut style = args[idx].clone();
3014            idx += 1;
3015            if !style.starts_with('.') {
3016                style = format!(".{}", style);
3017            }
3018            // sh:365 `zle -C` + sh:373-378 `bindkey` — through the BUILTIN
3019            // entries (see the -K arm above): the dispatch_function_call
3020            // spelling silently no-op'd, so `#compdef -k` widgets — ^X?
3021            // _complete_debug, ^Xh _complete_help — never bound and the
3022            // keys self-inserted literally.
3023            for key in &args[idx..] {
3024                install_comp_keybinding(&func, &style, key, &func);
3025            }
3026        }
3027        _ => {
3028            // sh:381-424  normal / pattern / postpattern
3029            let mut effective_type = flags.spec_type;
3030            while idx < args.len() {
3031                let arg = args[idx].clone();
3032                idx += 1;
3033                // sh:385-390  inline type switch
3034                match arg.as_str() {
3035                    "-N" => {
3036                        effective_type = SpecType::Normal;
3037                        continue;
3038                    }
3039                    "-p" => {
3040                        effective_type = SpecType::Pattern;
3041                        continue;
3042                    }
3043                    "-P" => {
3044                        effective_type = SpecType::PostPattern;
3045                        continue;
3046                    }
3047                    _ => {}
3048                }
3049                with_state(|s| match effective_type {
3050                    SpecType::Pattern => {
3051                        // sh:393-398 — `key=val` rewrites to `=val=func`
3052                        if let Some(eq) = arg.find('=') {
3053                            let key = arg[..eq].to_string();
3054                            let val = arg[eq + 1..].to_string();
3055                            put(&mut s.patcomps, &mut s.dirty.patcomps, key, format!("={}={}", val, func));
3056                        } else {
3057                            put(&mut s.patcomps, &mut s.dirty.patcomps, arg.clone(), func.clone());
3058                        }
3059                    }
3060                    SpecType::PostPattern => {
3061                        if let Some(eq) = arg.find('=') {
3062                            let key = arg[..eq].to_string();
3063                            let val = arg[eq + 1..].to_string();
3064                            put(&mut s.postpatcomps, &mut s.dirty.postpatcomps, key, format!("={}={}", val, func));
3065                        } else {
3066                            put(&mut s.postpatcomps, &mut s.dirty.postpatcomps, arg.clone(), func.clone());
3067                        }
3068                    }
3069                    _ => {
3070                        // sh:407-419  normal: cmd or cmd=svc
3071                        let (cmd, svc) = if let Some(eq) = arg.find('=') {
3072                            (arg[..eq].to_string(), Some(arg[eq + 1..].to_string()))
3073                        } else {
3074                            (arg.clone(), None)
3075                        };
3076                        // sh:415 — `-n`: no-clobber. zsh tests
3077                        // `[[ -z ${_comps[$1]} ]]` against the PARAMETER, so
3078                        // an entry that came from the dump/cache load counts
3079                        // as already-defined; testing only the session state
3080                        // let every `compdef -na` from an fpath rescan
3081                        // overwrite the dump's registration.
3082                        // sh:393 — `${_comps[$1]}` is the WHOLE argument, so a
3083                        // `cmd=svc` word is never "already defined" and always
3084                        // reclaims. Keyed on `cmd` this made
3085                        // `compdef -na _mail mailx=mail` a silent no-op whenever
3086                        // another file already held `_comps[mailx]`.
3087                        if flags.new
3088                            && (s.comps.contains_key(&arg) || hparam_has_key("_comps", &arg))
3089                        {
3090                            return;
3091                        }
3092                        put(&mut s.comps, &mut s.dirty.comps, cmd.clone(), func.clone());
3093                        if let Some(svc) = svc {
3094                            put(&mut s.services, &mut s.dirty.services, cmd, svc);
3095                        }
3096                    }
3097                });
3098            }
3099        }
3100    }
3101    with_state(publish_compdef_state_mut);
3102    0
3103}
3104
3105/// sh:311 pattern-matching helper. Uses the real `pattern.rs`
3106/// matcher so `(K)` assoc-key glob matching is faithful.
3107fn pattern_matches(pat: &str, s: &str) -> bool {
3108    match crate::ported::pattern::patcompile(
3109        &{
3110            let mut __pat_tok = (pat).to_string();
3111            crate::ported::glob::tokenize(&mut __pat_tok);
3112            __pat_tok
3113        },
3114        0,
3115        None,
3116    ) {
3117        Some(prog) => crate::ported::pattern::pattry(&prog, s),
3118        None => pat == s,
3119    }
3120}
3121
3122/// Reset session-side state (test-only helper; exposed via
3123/// `#[cfg(test)]` users).
3124///
3125/// Clears the shell-side parameters too. Publication merges into them
3126/// (`merge_hparam`), so they outlive the `CompdefState` and would carry
3127/// one case's registrations into the next — `-n`'s no-clobber test reads
3128/// `_comps` directly, and a leftover `_comps[git]` would silently skip
3129/// the registration the next case is asserting on.
3130#[cfg(test)]
3131pub fn reset_compdef_state() {
3132    *COMPDEF_STATE.lock().unwrap() = Some(CompdefState::default());
3133    for name in [
3134        "_comps",
3135        "_services",
3136        "_patcomps",
3137        "_postpatcomps",
3138        "_compautos",
3139    ] {
3140        crate::ported::params::sethparam(name, Vec::new());
3141    }
3142}
3143
3144/// Snapshot of the session-side state — what THIS process's `compdef`
3145/// calls and `$fpath` scan registered.
3146///
3147/// Not the full `_comps`: a `compinit -C` that loaded the dump/SQLite
3148/// cache fills the shell parameter without going through this state, so
3149/// read `subst::assoc_get("_comps")` when the question is "what is
3150/// registered", and this when the question is "what did we register".
3151pub fn snapshot_compdef_state() -> CompdefState {
3152    with_state(|s| CompdefState {
3153        comps: s.comps.clone(),
3154        services: s.services.clone(),
3155        patcomps: s.patcomps.clone(),
3156        postpatcomps: s.postpatcomps.clone(),
3157        compautos: s.compautos.clone(),
3158        removed: CompdefRemovals::default(),
3159        dirty: CompdefDirty::default(),
3160    })
3161}
3162
3163#[cfg(test)]
3164mod tests {
3165    use super::*;
3166
3167    #[test]
3168    fn test_parse_compdef_commands() {
3169        let def = parse_first_line("#compdef git svn hg");
3170        match def {
3171            CompFileDef::CompDef(CompDef::Commands(cmds)) => {
3172                assert_eq!(cmds, vec!["git", "svn", "hg"]);
3173            }
3174            _ => panic!("Expected Commands"),
3175        }
3176    }
3177
3178    /// compinit sh:533 reads the tag line with `read -rA`, which has no
3179    /// encoding requirement. A completer whose BODY holds one non-UTF-8 byte
3180    /// must still be scanned and registered for its `#compdef` commands, and
3181    /// its cached body must keep that byte (decoded the way autoload decodes
3182    /// it), not drop the file. `read_to_string` rejected it outright.
3183    #[test]
3184    fn scan_file_keeps_a_completer_with_a_non_utf8_body() {
3185        let dir = tempfile::tempdir().expect("tempdir");
3186        let path = dir.path().join("_rawc");
3187        std::fs::write(&path, b"#compdef rawc\n\ncompadd -- \"x\x83y\" plain\n").expect("write");
3188        let f = scan_file(&path).expect("a non-UTF-8 body must not drop the file from the scan");
3189        match f.def {
3190            CompFileDef::CompDef(CompDef::Commands(cmds)) => assert_eq!(cmds, vec!["rawc"]),
3191            _ => panic!("expected `#compdef rawc` to register the command"),
3192        }
3193        assert_eq!(
3194            f.body.as_deref(),
3195            Some(
3196                crate::script_bytes::read_script_file(&path)
3197                    .expect("read")
3198                    .as_str()
3199            ),
3200            "the cached body is the autoload path's lossless decode"
3201        );
3202    }
3203
3204    #[test]
3205    fn test_parse_compdef_pattern() {
3206        let def = parse_first_line("#compdef -p 'c*'");
3207        match def {
3208            CompFileDef::CompDef(CompDef::Pattern(pats)) => {
3209                assert_eq!(pats, vec!["'c*'".to_string()]);
3210            }
3211            _ => panic!("Expected Pattern"),
3212        }
3213    }
3214
3215    // Bug #657 — `#compdef -P pat` must route to `_postpatcomps` (post-pattern),
3216    // NOT `_comps`; `#compdef -p pat` to `_patcomps` only, NOT `_comps`.
3217    #[test]
3218    fn test_parse_compdef_postpattern_routing() {
3219        match parse_first_line("#compdef -P 'pip[0-9.]#'") {
3220            CompFileDef::CompDef(CompDef::PostPattern(pats)) => {
3221                assert_eq!(pats, vec!["'pip[0-9.]#'".to_string()]);
3222            }
3223            other => panic!("Expected PostPattern, got {:?}", other),
3224        }
3225    }
3226
3227    /// sh:384-420 — `-N`/`-p`/`-P` are POSITIONAL: they re-target the words
3228    /// that follow, anywhere in the line. Reading a flag only in first
3229    /// position registered `_gcc`'s three post-patterns as literal command
3230    /// names, so `gcc-14 <TAB>` never reached `_gcc` and `$_postpatcomps`
3231    /// held 10 entries where zsh 5.9.2 holds 25.
3232    #[test]
3233    fn test_parse_compdef_trailing_flags_switch_table() {
3234        let line = "#compdef gcc g++ -value-,CFLAGS,-default- -P gcc-* -P g++-* -p early*";
3235        match parse_first_line(line) {
3236            CompFileDef::CompDef(CompDef::Mixed {
3237                commands,
3238                patterns,
3239                postpatterns,
3240            }) => {
3241                assert_eq!(commands, vec!["gcc", "g++", "-value-,CFLAGS,-default-"]);
3242                assert_eq!(patterns, vec!["early*"]);
3243                assert_eq!(postpatterns, vec!["gcc-*", "g++-*"]);
3244            }
3245            other => panic!("Expected Mixed, got {:?}", other),
3246        }
3247        // `-N` switches back to plain command names (sh:385-386).
3248        match parse_first_line("#compdef -p pat* -N cmd") {
3249            CompFileDef::CompDef(CompDef::Mixed {
3250                commands,
3251                patterns,
3252                postpatterns,
3253            }) => {
3254                assert_eq!(commands, vec!["cmd"]);
3255                assert_eq!(patterns, vec!["pat*"]);
3256                assert!(postpatterns.is_empty());
3257            }
3258            other => panic!("Expected Mixed, got {:?}", other),
3259        }
3260        // sh:534-539 — only a leading `-[pPkK](n|)` is an option. Anything
3261        // else is a positional NAME, including `-m` (which is how zsh ends
3262        // up with a command literally named `-m` from `_squishy`'s header).
3263        match parse_first_line(r#"#compdef squishy "python -m squishy""#) {
3264            CompFileDef::CompDef(CompDef::Commands(cmds)) => {
3265                assert_eq!(cmds, vec!["squishy", "\"python", "-m", "squishy\""]);
3266            }
3267            other => panic!("Expected Commands, got {:?}", other),
3268        }
3269    }
3270
3271    #[test]
3272    fn test_parse_autoload() {
3273        let def = parse_first_line("#autoload -U -z");
3274        match def {
3275            CompFileDef::Autoload(opts) => {
3276                assert_eq!(opts, vec!["-U", "-z"]);
3277            }
3278            _ => panic!("Expected Autoload"),
3279        }
3280    }
3281
3282    #[test]
3283    fn test_parse_compdef_key() {
3284        let def = parse_first_line("#compdef -k complete-word ^X^C");
3285        match def {
3286            CompFileDef::CompDef(CompDef::KeyBinding { style, keys }) => {
3287                assert_eq!(style, "complete-word");
3288                assert_eq!(keys, vec!["^X^C"]);
3289            }
3290            _ => panic!("Expected KeyBinding"),
3291        }
3292    }
3293
3294    #[test]
3295    fn test_parse_compdef_redirect_context() {
3296        // _bzip2 line: has regular commands + context entries with services
3297        let def = parse_first_line("#compdef bzip2 bunzip2 bzcat=bunzip2 bzip2recover -redirect-,<,bunzip2=bunzip2 -redirect-,>,bzip2=bunzip2 -redirect-,<,bzip2=bzip2");
3298        match def {
3299            CompFileDef::CompDef(CompDef::Commands(cmds)) => {
3300                // Should contain all entries
3301                assert!(cmds.contains(&"bzip2".to_string()), "missing bzip2");
3302                assert!(cmds.contains(&"bunzip2".to_string()), "missing bunzip2");
3303                assert!(
3304                    cmds.contains(&"bzcat=bunzip2".to_string()),
3305                    "missing bzcat=bunzip2"
3306                );
3307                assert!(
3308                    cmds.contains(&"bzip2recover".to_string()),
3309                    "missing bzip2recover"
3310                );
3311                assert!(
3312                    cmds.contains(&"-redirect-,<,bunzip2=bunzip2".to_string()),
3313                    "missing redirect bunzip2"
3314                );
3315                assert!(
3316                    cmds.contains(&"-redirect-,>,bzip2=bunzip2".to_string()),
3317                    "missing redirect >,bzip2"
3318                );
3319                assert!(
3320                    cmds.contains(&"-redirect-,<,bzip2=bzip2".to_string()),
3321                    "missing redirect <,bzip2"
3322                );
3323                assert_eq!(cmds.len(), 7, "cmds: {:?}", cmds);
3324            }
3325            other => panic!("Expected Commands, got {:?}", other),
3326        }
3327    }
3328
3329    #[test]
3330    fn test_parse_compdef_context_entries() {
3331        // -default- style entries
3332        let def = parse_first_line("#compdef -default-");
3333        match def {
3334            CompFileDef::CompDef(CompDef::Commands(cmds)) => {
3335                assert_eq!(cmds, vec!["-default-"]);
3336            }
3337            other => panic!("Expected Commands, got {:?}", other),
3338        }
3339
3340        // bare hyphen + commands
3341        let def = parse_first_line("#compdef - nohup eval time");
3342        match def {
3343            CompFileDef::CompDef(CompDef::Commands(cmds)) => {
3344                assert!(cmds.contains(&"-".to_string()));
3345                assert!(cmds.contains(&"nohup".to_string()));
3346                assert!(cmds.contains(&"eval".to_string()));
3347                assert!(cmds.contains(&"time".to_string()));
3348            }
3349            other => panic!("Expected Commands, got {:?}", other),
3350        }
3351
3352        // -value- entries
3353        let def = parse_first_line("#compdef -value- -array-value- -value-,-default-,-default-");
3354        match def {
3355            CompFileDef::CompDef(CompDef::Commands(cmds)) => {
3356                assert!(cmds.contains(&"-value-".to_string()));
3357                assert!(cmds.contains(&"-array-value-".to_string()));
3358                assert!(cmds.contains(&"-value-,-default-,-default-".to_string()));
3359            }
3360            other => panic!("Expected Commands, got {:?}", other),
3361        }
3362    }
3363
3364    #[test]
3365    fn test_is_context_entry() {
3366        assert!(is_context_entry("-default-"));
3367        assert!(is_context_entry("-redirect-"));
3368        assert!(is_context_entry("-value-,DISPLAY,-default-"));
3369        assert!(is_context_entry("-redirect-,<,bunzip2=bunzip2"));
3370        assert!(is_context_entry("-redirect-,>,bzip2"));
3371        assert!(!is_context_entry("-p")); // option flag, not context
3372        assert!(!is_context_entry("-P")); // option flag
3373        assert!(!is_context_entry("git")); // regular command
3374    }
3375
3376    // =================================================================
3377    // compdef() tests — faithful to upstream `Completion/compinit`
3378    // sh:253-446. Lock the global state via reset_compdef_state to
3379    // isolate each case.
3380    // =================================================================
3381
3382    fn run(args: &[&str]) -> i32 {
3383        let owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
3384        compdef(&owned)
3385    }
3386
3387    #[test]
3388    fn compdef_empty_args_errors() {
3389        let _g = crate::test_util::global_state_lock();
3390        reset_compdef_state();
3391        assert_eq!(compdef(&[]), 1);
3392    }
3393
3394    #[test]
3395    fn compdef_normal_registration() {
3396        // sh:407-419 — `compdef _git git` writes `_comps[git]=_git`.
3397        let _g = crate::test_util::global_state_lock();
3398        reset_compdef_state();
3399        assert_eq!(run(&["_git", "git", "git-commit", "git-push"]), 0);
3400        let state = snapshot_compdef_state();
3401        assert_eq!(state.comps.get("git"), Some(&"_git".to_string()));
3402        assert_eq!(state.comps.get("git-commit"), Some(&"_git".to_string()));
3403        assert_eq!(state.comps.get("git-push"), Some(&"_git".to_string()));
3404    }
3405
3406    #[test]
3407    fn compdef_normal_with_service() {
3408        // sh:408-414 — `cmd=svc` records the service alongside.
3409        let _g = crate::test_util::global_state_lock();
3410        reset_compdef_state();
3411        assert_eq!(run(&["_git", "hub=git"]), 0);
3412        let state = snapshot_compdef_state();
3413        assert_eq!(state.comps.get("hub"), Some(&"_git".to_string()));
3414        assert_eq!(state.services.get("hub"), Some(&"git".to_string()));
3415    }
3416
3417    #[test]
3418    fn compdef_pattern_via_dash_p() {
3419        // sh:393-398 — `-p` writes into `_patcomps`.
3420        let _g = crate::test_util::global_state_lock();
3421        reset_compdef_state();
3422        assert_eq!(run(&["-p", "_test", "*-test"]), 0);
3423        let state = snapshot_compdef_state();
3424        assert_eq!(state.patcomps.get("*-test"), Some(&"_test".to_string()));
3425    }
3426
3427    #[test]
3428    fn compdef_postpattern_via_dash_p_caps() {
3429        // sh:400-405 — `-P` → `_postpatcomps`.
3430        let _g = crate::test_util::global_state_lock();
3431        reset_compdef_state();
3432        assert_eq!(run(&["-P", "_last", "_*"]), 0);
3433        let state = snapshot_compdef_state();
3434        assert_eq!(state.postpatcomps.get("_*"), Some(&"_last".to_string()));
3435    }
3436
3437    #[test]
3438    fn compdef_pattern_with_eq_rewrites_to_eq_form() {
3439        // sh:394-397 — `key=val` form is rewritten to `=val=func`.
3440        let _g = crate::test_util::global_state_lock();
3441        reset_compdef_state();
3442        assert_eq!(run(&["-p", "_test", "*=postfix"]), 0);
3443        let state = snapshot_compdef_state();
3444        assert_eq!(state.patcomps.get("*"), Some(&"=postfix=_test".to_string()));
3445    }
3446
3447    #[test]
3448    fn compdef_delete_removes_from_comps() {
3449        // sh:426-444 — `-d` deletes from the right hash.
3450        let _g = crate::test_util::global_state_lock();
3451        reset_compdef_state();
3452        run(&["_git", "git"]);
3453        assert!(snapshot_compdef_state().comps.contains_key("git"));
3454        assert_eq!(run(&["-d", "git"]), 0);
3455        assert!(!snapshot_compdef_state().comps.contains_key("git"));
3456    }
3457
3458    #[test]
3459    fn compdef_delete_pattern_removes_from_patcomps() {
3460        // sh:429-432 — `-d -p` deletes a pattern entry.
3461        let _g = crate::test_util::global_state_lock();
3462        reset_compdef_state();
3463        run(&["-p", "_test", "*-test"]);
3464        assert!(snapshot_compdef_state().patcomps.contains_key("*-test"));
3465        assert_eq!(run(&["-d", "-p", "*-test"]), 0);
3466        assert!(!snapshot_compdef_state().patcomps.contains_key("*-test"));
3467    }
3468
3469    #[test]
3470    fn compdef_no_clobber_skips_existing() {
3471        // sh:415 — `-n` keeps the existing binding.
3472        let _g = crate::test_util::global_state_lock();
3473        reset_compdef_state();
3474        run(&["_first", "git"]);
3475        run(&["-n", "_second", "git"]);
3476        assert_eq!(
3477            snapshot_compdef_state().comps.get("git"),
3478            Some(&"_first".to_string())
3479        );
3480    }
3481
3482    #[test]
3483    fn compdef_no_clobber_honours_a_registration_only_the_parameter_holds() {
3484        // sh:415 tests `[[ -z ${_comps[$1]} ]]` — the PARAMETER. Everything
3485        // `compinit -C` loaded from the dump lives only there, so checking
3486        // `CompdefState` alone let a later `compdef -n` overwrite it.
3487        let _g = crate::test_util::global_state_lock();
3488        reset_compdef_state();
3489        crate::ported::params::sethparam(
3490            "_comps",
3491            vec!["git".to_string(), "_git_from_dump".to_string()],
3492        );
3493        run(&["-n", "_second", "git"]);
3494        assert_eq!(
3495            crate::ported::subst::assoc_get("_comps")
3496                .and_then(|m| m.get("git").cloned())
3497                .as_deref(),
3498            Some("_git_from_dump")
3499        );
3500    }
3501
3502    #[test]
3503    fn compdef_keeps_registrations_it_did_not_make() {
3504        // The regression this whole merge-on-publish design exists for.
3505        // `compinit -C`'s cache-hit path fills `_comps` directly
3506        // (ext_builtins.rs, `set_assoc`) and never touches `CompdefState`,
3507        // so publishing the state wholesale replaced ~51k registrations
3508        // with the one key this process happened to register — after which
3509        // `_dispatch` resolved an empty completer for every command and
3510        // `man <TAB>` / `git <TAB>` / `kill <TAB>` all produced nothing.
3511        let _g = crate::test_util::global_state_lock();
3512        reset_compdef_state();
3513        crate::ported::params::sethparam(
3514            "_comps",
3515            vec![
3516                "man".to_string(),
3517                "_man".to_string(),
3518                "git".to_string(),
3519                "_git".to_string(),
3520            ],
3521        );
3522        assert_eq!(run(&["_zstyle", "zstyle"]), 0);
3523        let comps = crate::ported::subst::assoc_get("_comps").expect("_comps must still be a hash");
3524        assert_eq!(comps.get("man").map(String::as_str), Some("_man"));
3525        assert_eq!(comps.get("git").map(String::as_str), Some("_git"));
3526        assert_eq!(comps.get("zstyle").map(String::as_str), Some("_zstyle"));
3527    }
3528
3529    #[test]
3530    fn compdef_delete_removes_a_key_only_the_parameter_holds() {
3531        // The flip side: sh:442 `unset "_comps[$^@]"` has to reach an entry
3532        // that came from the dump, which a merge cannot express by omission.
3533        let _g = crate::test_util::global_state_lock();
3534        reset_compdef_state();
3535        crate::ported::params::sethparam(
3536            "_comps",
3537            vec![
3538                "man".to_string(),
3539                "_man".to_string(),
3540                "git".to_string(),
3541                "_git".to_string(),
3542            ],
3543        );
3544        assert_eq!(run(&["-d", "man"]), 0);
3545        let comps = crate::ported::subst::assoc_get("_comps").expect("_comps must still be a hash");
3546        assert_eq!(comps.get("man"), None);
3547        assert_eq!(comps.get("git").map(String::as_str), Some("_git"));
3548    }
3549
3550    #[test]
3551    fn compdef_batch_defers_publication_but_still_publishes() {
3552        // The batch must be a deferral, not a drop: a `cdreplay` whose
3553        // registrations never reached `_comps` would be the same outage
3554        // by another route.
3555        let _g = crate::test_util::global_state_lock();
3556        reset_compdef_state();
3557        compdef_batch(|| {
3558            run(&["_git", "git"]);
3559            assert!(
3560                crate::ported::subst::assoc_get("_comps")
3561                    .map(|m| m.is_empty())
3562                    .unwrap_or(true),
3563                "publication must be held until the batch ends"
3564            );
3565            run(&["_man", "man"]);
3566        });
3567        let comps = crate::ported::subst::assoc_get("_comps").expect("_comps must still be a hash");
3568        assert_eq!(comps.get("git").map(String::as_str), Some("_git"));
3569        assert_eq!(comps.get("man").map(String::as_str), Some("_man"));
3570    }
3571
3572    #[test]
3573    fn cache_is_valid_rejects_a_cache_another_build_wrote() {
3574        let fp = vec![PathBuf::from("/a"), PathBuf::from("/b")];
3575        let cache = crate::compsys::cache::CompsysCache::memory().expect("in-memory cache");
3576        cache.set_comp("git", "_git").unwrap();
3577        assert!(stamp_cache_complete(&cache, &fp));
3578        assert!(cache_is_valid(&cache, &fp), "our own stamp must be accepted");
3579
3580        // Same rows, same count, stamped by a binary that is not this
3581        // one. Equality, so it is rejected whether that binary is older
3582        // or newer than the running one.
3583        cache
3584            .set_metadata(CACHE_BINARY_KEY, "1.2.3")
3585            .expect("restamp");
3586        assert!(
3587            !cache_is_valid(&cache, &fp),
3588            "a cache built by another zshrs must be rebuilt, not read"
3589        );
3590    }
3591
3592    /// A cache is a function of `$fpath`: which directories, in what order
3593    /// (`compdef -na` is first-claim-wins, sh:519). Up to 16 of the user's
3594    /// shells share one cache file, so without this a shell that rebuilt it
3595    /// from a short `$fpath` decided what every later shell completed.
3596    #[test]
3597    fn cache_is_valid_rejects_a_cache_built_from_a_different_fpath() {
3598        let short = vec![PathBuf::from("/a")];
3599        let long = vec![PathBuf::from("/a"), PathBuf::from("/b")];
3600        let cache = crate::compsys::cache::CompsysCache::memory().expect("in-memory cache");
3601        cache.set_comp("git", "_git").unwrap();
3602        assert!(stamp_cache_complete(&cache, &short));
3603        assert!(cache_is_valid(&cache, &short), "same fpath is accepted");
3604        assert!(
3605            !cache_is_valid(&cache, &long),
3606            "a cache built from a SHORTER fpath must not be read by a shell \
3607             whose fpath has directories the builder never scanned"
3608        );
3609        // Order matters too: the same set in a different order resolves
3610        // contested commands to a different completer.
3611        let reordered = vec![PathBuf::from("/b"), PathBuf::from("/a")];
3612        assert!(stamp_cache_complete(&cache, &long));
3613        assert!(
3614            !cache_is_valid(&cache, &reordered),
3615            "the same directories in a different order are a different cache"
3616        );
3617    }
3618
3619    #[test]
3620    fn cache_is_valid_rejects_a_cache_that_is_still_filling() {
3621        // A row count alone cannot distinguish "finished" from "another
3622        // shell is 200 rows into a 50k-row rebuild" — accepting the latter
3623        // is what published a `_comps` with a handful of entries.
3624        let fp = vec![PathBuf::from("/a")];
3625        let cache = crate::compsys::cache::CompsysCache::memory().expect("in-memory cache");
3626        assert!(!cache_is_valid(&cache, &fp), "an empty cache is not valid");
3627        cache.set_comp("git", "_git").unwrap();
3628        assert!(
3629            !cache_is_valid(&cache, &fp),
3630            "a cache no build has stamped is not valid, however many rows it has"
3631        );
3632        assert!(stamp_cache_complete(&cache, &fp));
3633        assert!(cache_is_valid(&cache, &fp));
3634        cache.set_comp("man", "_man").unwrap();
3635        assert!(
3636            !cache_is_valid(&cache, &fp),
3637            "a row written after the stamp means the build was not the last writer"
3638        );
3639    }
3640
3641    #[test]
3642    fn compdef_inline_type_switch_dash_p() {
3643        // sh:385-390 — bare `-p` mid-args toggles to pattern mode.
3644        let _g = crate::test_util::global_state_lock();
3645        reset_compdef_state();
3646        run(&["_x", "cmd1", "-p", "pat*", "-N", "cmd2"]);
3647        let s = snapshot_compdef_state();
3648        assert_eq!(s.comps.get("cmd1"), Some(&"_x".to_string()));
3649        assert_eq!(s.patcomps.get("pat*"), Some(&"_x".to_string()));
3650        assert_eq!(s.comps.get("cmd2"), Some(&"_x".to_string()));
3651    }
3652
3653    #[test]
3654    fn compdef_combined_flags_an() {
3655        // sh:267 getopts allows `-an` combined.
3656        let _g = crate::test_util::global_state_lock();
3657        reset_compdef_state();
3658        // `-an` = autol + new
3659        assert_eq!(run(&["-an", "_git", "git"]), 0);
3660        let s = snapshot_compdef_state();
3661        assert_eq!(s.comps.get("git"), Some(&"_git".to_string()));
3662        // sh:333 is the WHOLE of `-a`: `autoload -rUz "$func"`. `_compautos`
3663        // is written in exactly one place upstream — compinit sh:541's
3664        // `#autoload` arm — and `compdef` never touches it. This assertion
3665        // used to require `_compautos[_git] == "-rUz"`, pinning a zshrs
3666        // fabrication. Verified against /opt/homebrew/bin/zsh 5.9.2:
3667        //   compinit -u -D; print $#_compautos            → 1
3668        //   compdef -a _mytest mytestcmd; print $#_compautos → 1
3669        //   print ${_compautos[_mytest]-UNSET}            → UNSET
3670        assert!(
3671            !s.compautos.contains_key("_git"),
3672            "compdef -a must not register a _compautos entry, got {:?}",
3673            s.compautos
3674        );
3675    }
3676
3677    #[test]
3678    fn compdef_service_alias_mode_resolves_existing_func() {
3679        // sh:298-326  — first arg with `=` triggers service-alias.
3680        //   Each entry resolves via `_services[(r)$svc]` reverse +
3681        //   `_comps[$svc]`.
3682        let _g = crate::test_util::global_state_lock();
3683        reset_compdef_state();
3684        run(&["_git", "git"]); // first set up git→_git
3685                               // Now `hub=git` should reuse _git
3686        assert_eq!(run(&["hub=git"]), 0);
3687        let s = snapshot_compdef_state();
3688        assert_eq!(s.comps.get("hub"), Some(&"_git".to_string()));
3689        assert_eq!(s.services.get("hub"), Some(&"git".to_string()));
3690    }
3691
3692    #[test]
3693    fn compdef_service_alias_unknown_returns_one() {
3694        // sh:316-318  unknown svc → error
3695        let _g = crate::test_util::global_state_lock();
3696        reset_compdef_state();
3697        assert_eq!(run(&["xyz=never-registered"]), 1);
3698    }
3699
3700    #[test]
3701    fn compdef_unknown_flag_errors() {
3702        let _g = crate::test_util::global_state_lock();
3703        reset_compdef_state();
3704        assert_eq!(run(&["-z", "_x", "cmd"]), 1);
3705    }
3706
3707    #[test]
3708    fn compdef_publishes_state_to_shell_arrays() {
3709        // `_comps` is an ASSOCIATIVE array in zsh (`typeset -gHA`), so the
3710        // shell-side view must be a hash where `${_comps[git]}` == `_git`.
3711        // (Previously published via setaparam as a flat array, which broke
3712        // `${_comps[cmd]}` key lookup and every completion — Bug #655.)
3713        let _g = crate::test_util::global_state_lock();
3714        reset_compdef_state();
3715        run(&["_git", "git"]);
3716        // Must be a proper association, not a flat array.
3717        let map = crate::ported::params::paramtab_hashed_storage()
3718            .lock()
3719            .unwrap()
3720            .get("_comps")
3721            .cloned()
3722            .expect("_comps must be a hashed (associative) param");
3723        assert_eq!(map.get("git").map(String::as_str), Some("_git"));
3724    }
3725
3726    /// compinit registers every scanned file with `compdef -na`, and `-n`
3727    /// keeps an EXISTING `_comps` entry (Completion/compinit sh:393). So
3728    /// when two completers claim the same command, the one in the earlier
3729    /// `$fpath` directory owns it.
3730    ///
3731    /// Regression: the scan inserted unconditionally, so the LAST writer
3732    /// won. On this host `_df` (`#compdef df gdf`, fpath[24]) lost to
3733    /// zsh-more-completions' `_dwarffortress` (`#compdef dwarffortress
3734    /// df`, fpath[42]) and `df -<TAB>` completed Dwarf Fortress options,
3735    /// i.e. nothing.
3736    #[test]
3737    fn scan_keeps_first_fpath_claim_on_a_command() {
3738        let _g = crate::test_util::global_state_lock();
3739        let base = std::env::temp_dir().join("zshrs_compinit_firstwins_test");
3740        let early = base.join("early");
3741        let late = base.join("late");
3742        let _ = fs::remove_dir_all(&base);
3743        fs::create_dir_all(&early).unwrap();
3744        fs::create_dir_all(&late).unwrap();
3745        // Same command claimed by two different files in two directories.
3746        fs::write(early.join("_zzcmd"), "#compdef zzcmd zzother\n").unwrap();
3747        fs::write(late.join("_zzgame"), "#compdef zzgame zzcmd\n").unwrap();
3748
3749        let result = compinit(&[early.clone(), late.clone()]);
3750        assert_eq!(
3751            result.comps.get("zzcmd").map(String::as_str),
3752            Some("_zzcmd"),
3753            "earlier fpath dir must keep the command"
3754        );
3755        // The later file still owns the commands nobody claimed first.
3756        assert_eq!(
3757            result.comps.get("zzgame").map(String::as_str),
3758            Some("_zzgame")
3759        );
3760
3761        // Reversing fpath order reverses the winner — order is what decides.
3762        let reversed = compinit(&[late.clone(), early.clone()]);
3763        assert_eq!(
3764            reversed.comps.get("zzcmd").map(String::as_str),
3765            Some("_zzgame")
3766        );
3767        let _ = fs::remove_dir_all(&base);
3768    }
3769
3770    /// Regression: `compinit` must leave an autoload stub in `shfunctab`
3771    /// for every completer it registers (sh:337 `autoload -rUz "$func"`,
3772    /// reached via the `compdef -na` at sh:541), because completers read
3773    /// `$functions` to discover their siblings — `_tmux` derives its
3774    /// sub-command list from `${(M)${(k)functions}:#_tmux-*}`
3775    /// (_tmux sh:1967). zshrs bulk-loaded `$_comps` without this step, so
3776    /// `tmux <TAB>` was missing the five `_tmux-*` helpers in `$fpath`.
3777    #[test]
3778    fn scan_registers_autoload_stubs_for_every_completer() {
3779        let _g = crate::test_util::global_state_lock();
3780        let dir = std::env::temp_dir().join("zshrs_compinit_stubs_test");
3781        let _ = fs::remove_dir_all(&dir);
3782        fs::create_dir_all(&dir).unwrap();
3783        fs::write(dir.join("_zzt"), "#compdef zzt\n").unwrap();
3784        fs::write(dir.join("_zzt-helper"), "#compdef zzt-helper\n").unwrap();
3785        fs::write(dir.join("_zzt_util"), "#autoload\n").unwrap();
3786        // A file with neither header contributes no function.
3787        fs::write(dir.join("_zzt_readme"), "just text\n").unwrap();
3788
3789        // A name already defined must survive untouched — `bin_functions`
3790        // leaves an existing function alone.
3791        for n in ["_zzt", "_zzt-helper", "_zzt_util", "_zzt_readme"] {
3792            if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
3793                t.remove(n);
3794            }
3795        }
3796        if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
3797            let mut defined = crate::ported::hashtable::shfunc_autoload("_zzt");
3798            defined.node.flags = 0;
3799            defined.body = Some("true".to_string());
3800            t.add(defined);
3801        }
3802
3803        let result = compinit(&[dir.clone()]);
3804        let names = autoload_stub_names(&result);
3805        assert!(names.contains(&"_zzt-helper"), "got {names:?}");
3806        assert!(names.contains(&"_zzt_util"), "got {names:?}");
3807        assert!(!names.contains(&"_zzt_readme"), "got {names:?}");
3808
3809        assert_eq!(
3810            register_autoload_stubs(&names),
3811            2,
3812            "the already-defined _zzt must not be re-stubbed"
3813        );
3814
3815        let tab = crate::ported::hashtable::shfunctab_lock();
3816        let tab = tab.read().unwrap();
3817        for n in ["_zzt-helper", "_zzt_util"] {
3818            let shf = tab.get(n).unwrap_or_else(|| panic!("{n} has no stub"));
3819            let flags = shf.node.flags as u32;
3820            assert!(flags & crate::ported::zsh_h::PM_UNDEFINED != 0, "{n}");
3821            assert!(flags & crate::ported::zsh_h::PM_UNALIASED != 0, "{n}");
3822        }
3823        assert_eq!(
3824            tab.get("_zzt").and_then(|f| f.body.clone()),
3825            Some("true".to_string()),
3826            "an already-defined function must keep its body"
3827        );
3828        assert!(tab.get("_zzt_readme").is_none());
3829        drop(tab);
3830        let _ = fs::remove_dir_all(&dir);
3831    }
3832
3833    /// Regression: the scan's stubs are `autoload -rUz` (compinit sh:311,
3834    /// sh:523), and `-r` records the fpath element the name resolves to as
3835    /// `shf->filename` with `PM_LOADDIR` (check_autoload,
3836    /// c:Src/builtin.c:3216-3224). Without it every later load re-walked
3837    /// `$fpath`, so a corrupt `<elem>.zwc` ahead of the distribution tree
3838    /// made zshrs warn "invalid zwc file" for `_main_complete`, `_normal`,
3839    /// … on TAB where zsh warns only for the functions living in that
3840    /// element (`--layout-fuzz` layout digest-corrupt-D). `functions NAME`
3841    /// shows the difference as `builtin autoload -XUz <dir>`.
3842    #[test]
3843    fn scan_stubs_record_their_resolved_load_directory() {
3844        use crate::ported::zsh_h::{PM_LOADDIR, PM_UNDEFINED};
3845        let _g = crate::test_util::global_state_lock();
3846        let base = std::env::temp_dir().join("zshrs_compinit_loaddir_test");
3847        let early = base.join("early");
3848        let late = base.join("late");
3849        let _ = fs::remove_dir_all(&base);
3850        fs::create_dir_all(&early).unwrap();
3851        fs::create_dir_all(&late).unwrap();
3852        fs::write(early.join("_zzr_b"), "#autoload\n").unwrap();
3853        fs::write(late.join("_zzr_a"), "#compdef zzra\n").unwrap();
3854        fs::write(late.join("_zzr_pre"), "#compdef zzrpre\n").unwrap();
3855        // The same name in both: the leftmost element is the one recorded.
3856        fs::write(early.join("_zzr_dup"), "#compdef zzrdup\n").unwrap();
3857        fs::write(late.join("_zzr_dup"), "#compdef zzrdup2\n").unwrap();
3858
3859        let names = ["_zzr_a", "_zzr_b", "_zzr_pre", "_zzr_dup"];
3860        if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
3861            for n in names {
3862                t.remove(n);
3863            }
3864            // An undefined stub made earlier WITHOUT a directory (a plain
3865            // `autoload -Uz`) is resolved too: c:3195 only needs PM_UNDEFINED.
3866            t.add(crate::ported::hashtable::shfunc_autoload("_zzr_pre"));
3867        }
3868
3869        let fpath = [early.clone(), late.clone()];
3870        let result = compinit(&fpath);
3871        assert_eq!(register_scanned_autoload_stubs(&result, &fpath), 3);
3872
3873        let tab = crate::ported::hashtable::shfunctab_lock();
3874        let tab = tab.read().unwrap();
3875        for (n, want) in [
3876            ("_zzr_a", &late),
3877            ("_zzr_b", &early),
3878            ("_zzr_pre", &late),
3879            ("_zzr_dup", &early),
3880        ] {
3881            let shf = tab.get(n).unwrap_or_else(|| panic!("{n} has no stub"));
3882            let flags = shf.node.flags as u32;
3883            assert!(flags & PM_UNDEFINED != 0, "{n} must stay undefined");
3884            assert!(flags & PM_LOADDIR != 0, "{n} must carry PM_LOADDIR");
3885            assert_eq!(
3886                shf.filename.as_deref(),
3887                Some(want.to_string_lossy().as_ref()),
3888                "{n} must load from the element it resolved to"
3889            );
3890        }
3891        drop(tab);
3892        if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
3893            for n in names {
3894                t.remove(n);
3895            }
3896        }
3897        let _ = fs::remove_dir_all(&base);
3898    }
3899
3900    /// Regression: `compinit -C -d FILE` takes the sh:515-518 branch, which
3901    /// sources the dump instead of scanning `$fpath` — so the dump's
3902    /// `autoload` lines, not a header scan, decide what ends up in
3903    /// `${(k)functions}`. compdump lists every defined `_*` function that
3904    /// has a file in `$fpath` (compdump:113), which is why the real dump on
3905    /// a zpwr host names 12 headerless helpers (`_command_names`,
3906    /// `__zpwr_aliases`, …) that no `#compdef`/`#autoload` scan can find.
3907    /// This asserts the parse of both line shapes compdump emits: the one
3908    /// backslash-continued `autoload -Uz a b c` list (compdump:118-129) and
3909    /// the per-`$_compautos` `autoload -Uz <opts> <name>` lines
3910    /// (compdump:135-138).
3911    #[test]
3912    fn dump_autoload_names_reads_both_compdump_line_shapes() {
3913        let dir = std::env::temp_dir().join("zshrs_compinit_dumpnames_test");
3914        let _ = fs::remove_dir_all(&dir);
3915        fs::create_dir_all(&dir).unwrap();
3916        let dump = dir.join("zcompdump");
3917        fs::write(
3918            &dump,
3919            concat!(
3920                "#files: 3\tversion: 5.9.2\n",
3921                "\n",
3922                "_comps=(\n",
3923                // A _comps KEY may literally be `autoload`; the quoting must
3924                // keep it out of the name list.
3925                "'autoload' '_autoload'\n",
3926                "'zzt' '_zzt'\n",
3927                ")\n",
3928                "\n",
3929                "zle -C _complete_help complete-word _complete_help\n",
3930                "bindkey '^Xh' _complete_help\n",
3931                "\n",
3932                "autoload -Uz _zzt _zzt_two \\\n",
3933                "           __zzt_headerless _zzt_gone\n",
3934                "autoload -Uz +X _call_program\n",
3935                "typeset -gUa _comp_assocs\n",
3936            ),
3937        )
3938        .unwrap();
3939
3940        let names = dump_autoload_names(&dump);
3941        assert_eq!(
3942            names,
3943            vec![
3944                "_zzt",
3945                "_zzt_two",
3946                "__zzt_headerless",
3947                "_zzt_gone",
3948                "_call_program",
3949            ],
3950            "continuation lines, `+X`/`-Uz` option words and the quoted \
3951             `_comps` key must all be handled"
3952        );
3953        let _ = fs::remove_dir_all(&dir);
3954    }
3955
3956    /// Regression: on the sh:493-496 `-C` branch the dump is sourced and
3957    /// sh:501's `[[ -z "$_i_done" ]]` skips the `$fpath` scan, so the dump
3958    /// alone defines all five association tables. zshrs read them from its
3959    /// SQLite cache instead, and a partially-built cache (1849 `_comps`
3960    /// keys against the real dump's 51745 on a zpwr host) silently dropped
3961    /// `$_comps[zpwr]`, `$_comps[cargo]`, `$_comps[brew]`, … — every one of
3962    /// those commands then fell through `_dispatch` to `-default-` and
3963    /// completed FILES where zsh runs the registered completer.
3964    ///
3965    /// The value shapes asserted here are the ones compdump's `${(qq)}`
3966    /// actually emits (compdump:38-70): a plain `'k' 'v'` pair, a key that
3967    /// starts with a literal quote (`''\''brew'` → `'brew`), and a key with
3968    /// an embedded one (`'services'\'''` → `services'`).
3969    #[test]
3970    fn dump_assoc_tables_reads_all_five_compdump_tables() {
3971        let dir = std::env::temp_dir().join("zshrs_compinit_dumptables_test");
3972        let _ = fs::remove_dir_all(&dir);
3973        fs::create_dir_all(&dir).unwrap();
3974        let dump = dir.join("zcompdump");
3975        fs::write(
3976            &dump,
3977            concat!(
3978                "#files: 3\tversion: 5.9.2\n",
3979                "\n",
3980                "_comps=(\n",
3981                "'zpwr' '_zpwr'\n",
3982                "''\\''brew' '_brew_services'\n",
3983                "'services'\\''' '_brew_services'\n",
3984                ")\n",
3985                "\n",
3986                "_services=(\n",
3987                "'ftp' 'ftp'\n",
3988                ")\n",
3989                "\n",
3990                "_patcomps=(\n",
3991                "'*/(init|rc[0-9S]#).d/*' '_init_d'\n",
3992                ")\n",
3993                "\n",
3994                "_postpatcomps=(\n",
3995                "'_*' '_compadd'\n",
3996                "'gcc-*' '_gcc'\n",
3997                ")\n",
3998                "\n",
3999                "_compautos=(\n",
4000                "'_call_program' '+X'\n",
4001                ")\n",
4002                "\n",
4003                // Everything after the tables must be ignored, including a
4004                // `)` that does not close one.
4005                "zle -C _complete_help complete-word _complete_help\n",
4006                "autoload -Uz _zzt\n",
4007                "typeset -gUa _comp_assocs\n",
4008                "_comp_assocs=( '' )\n",
4009            ),
4010        )
4011        .unwrap();
4012
4013        let t = dump_assoc_tables(&dump).expect("dump is readable");
4014        assert_eq!(t.comps.get("zpwr").map(String::as_str), Some("_zpwr"));
4015        assert_eq!(
4016            t.comps.get("'brew").map(String::as_str),
4017            Some("_brew_services"),
4018            "`''\\''brew'` is three concatenated (qq) segments = `'brew`"
4019        );
4020        assert_eq!(
4021            t.comps.get("services'").map(String::as_str),
4022            Some("_brew_services")
4023        );
4024        assert_eq!(t.comps.len(), 3, "no stray keys from the trailing lines");
4025        assert_eq!(t.services.get("ftp").map(String::as_str), Some("ftp"));
4026        assert_eq!(
4027            t.patcomps.get("*/(init|rc[0-9S]#).d/*").map(String::as_str),
4028            Some("_init_d")
4029        );
4030        // Order is load-bearing: `_postpatcomps` is tried in insertion order,
4031        // and compdump writes it in `${(ok)}` order (compdump:61-66).
4032        assert_eq!(
4033            t.postpatcomps
4034                .keys()
4035                .map(String::as_str)
4036                .collect::<Vec<_>>(),
4037            vec!["_*", "gcc-*"]
4038        );
4039        assert_eq!(
4040            t.compautos.get("_call_program").map(String::as_str),
4041            Some("+X")
4042        );
4043        let _ = fs::remove_dir_all(&dir);
4044    }
4045
4046    /// sh:541 `[[ "$_i_line" != \ # ]] && _compautos[$_i_name]="$_i_line"`.
4047    ///
4048    /// A bare `#autoload` header contributes NOTHING to `$_compautos`; only
4049    /// one carrying options does. zshrs registered every `#autoload` file
4050    /// with an empty value, so `$#_compautos` came back 178 over the Cellar
4051    /// 5.9.2 tree where `/opt/homebrew/bin/zsh` ends at 1. The key set is
4052    /// observable directly, and compdump branches on it twice — sh:115
4053    /// drops those names from its `autoload -Uz` list and sh:129 re-emits
4054    /// each with its options — so every phantom key wrote an option-less
4055    /// `autoload -Uz  _name` line.
4056    #[test]
4057    fn bare_autoload_header_registers_no_compautos_entry() {
4058        let _g = crate::test_util::global_state_lock();
4059        let dir = std::env::temp_dir().join("zshrs_compinit_compautos_guard");
4060        let _ = fs::remove_dir_all(&dir);
4061        fs::create_dir_all(&dir).unwrap();
4062        // sh:539 — all three are `#autoload` files, so all three are
4063        // `autoload -rUz`'d (sh:540); only the ones with a non-blank
4064        // remainder reach sh:541.
4065        fs::write(dir.join("_zzbare"), "#autoload\nprint bare\n").unwrap();
4066        fs::write(dir.join("_zzblank"), "#autoload   \nprint blank\n").unwrap();
4067        fs::write(dir.join("_zzopts"), "#autoload +X\nprint opts\n").unwrap();
4068
4069        let result = compinit(&[dir.clone()]);
4070        assert_eq!(
4071            result.compautos.get("_zzopts").map(String::as_str),
4072            Some("+X"),
4073            "an `#autoload` line WITH options is what sh:541 records"
4074        );
4075        assert!(
4076            !result.compautos.contains_key("_zzbare"),
4077            "bare `#autoload` must not appear in _compautos, got {:?}",
4078            result.compautos
4079        );
4080        assert!(
4081            !result.compautos.contains_key("_zzblank"),
4082            "all-blank `#autoload` remainder matches sh:541's `\\ #` and must \
4083             not appear either, got {:?}",
4084            result.compautos
4085        );
4086        // sh:540 is unconditional — all three still autoload.
4087        let names = autoload_stub_names(&result);
4088        for n in ["_zzbare", "_zzblank", "_zzopts"] {
4089            assert!(names.contains(&n), "sh:540 autoloads every #autoload file");
4090        }
4091        let _ = fs::remove_dir_all(&dir);
4092    }
4093}