Fix Zsh completion output, again
Marco Ricci

Marco Ricci commited on 2025-01-17 20:51:03
Zeige 2 geänderte Dateien mit 77 Einfügungen und 16 Löschungen.


The new Zsh serialization handler in
bba4bd075ab5e1d6a6a76d90b129ad0d58425b96 was tested only against
completion items with descriptions, and it did not properly take into
account that items *without* descriptions would be passed directly to
Zsh as unescaped completion entries, i.e., no interpretation of escape
sequences would occur.  We fix this in both the code and the tests, and
include a safeguard against applying this against the wrong Zsh
completion script version (at least as far as we can programmatically
determine from within Python).

References: [click#2703](https://github.com/pallets/click/issues/2703)
... ...
@@ -1203,11 +1203,19 @@ class ZshComplete(click.shell_completion.ZshComplete):
1203 1203
     """Zsh completion class that supports colons.
1204 1204
 
1205 1205
     `click`'s Zsh completion class (at least v8.1.7 and v8.1.8) uses
1206
-    completion helper functions (provided by Zsh) that parse each
1206
+    some completion helper functions (provided by Zsh) that parse each
1207 1207
     completion item into value-description pairs, separated by a colon.
1208
-    Correspondingly, any internal colons in the completion item's value
1209
-    need to be escaped.  `click` doesn't do this.  So, this subclass
1210
-    overrides those parts, and adds the missing escaping.
1208
+    Other completion helper functions don't.  Correspondingly, any
1209
+    internal colons in the completion item's value sometimes need to be
1210
+    escaped, and sometimes don't.
1211
+
1212
+    The "right" way to fix this is to modify the Zsh completion script
1213
+    to only use one type of serialization: either escaped, or unescaped.
1214
+    However, the Zsh completion script itself may already be installed
1215
+    in the user's Zsh settings, and we have no way of knowing that.
1216
+    Therefore, it is better to change the `format_completion` method to
1217
+    adaptively and "smartly" emit colon-escaped output or not, based on
1218
+    whether the completion script will be using it.
1211 1219
 
1212 1220
     """
1213 1221
 
... ...
@@ -1219,17 +1227,69 @@ class ZshComplete(click.shell_completion.ZshComplete):
1219 1227
         """Return a suitable serialization of the CompletionItem.
1220 1228
 
1221 1229
         This serialization ensures colons in the item value are properly
1222
-        escaped.
1230
+        escaped if and only if the completion script will attempt to
1231
+        pass a colon-separated key/description pair to the underlying
1232
+        Zsh machinery.  This is the case if and only if the help text is
1233
+        non-degenerate.
1223 1234
 
1224 1235
         """
1225
-        type, value, help = (  # noqa: A001
1226
-            item.type,
1227
-            item.value.replace(':', '\\:'),
1228
-            item.help or '_',
1229
-        )
1230
-        return f'{type}\n{value}\n{help}'
1236
+        help_ = item.help or '_'
1237
+        value = item.value.replace(':', r'\:' if help_ != '_' else ':')
1238
+        return f'{item.type}\n{value}\n{help_}'
1231 1239
 
1232 1240
 
1241
+# Our ZshComplete class depends crucially on the exact shape of the Zsh
1242
+# completion script.  So only fix the completion formatter if the
1243
+# completion script is still the same.
1244
+#
1245
+# (This Zsh script is part of click, and available under the
1246
+# 3-clause-BSD license.)
1247
+_ORIG_SOURCE_TEMPLATE = """\
1248
+#compdef %(prog_name)s
1249
+
1250
+%(complete_func)s() {
1251
+    local -a completions
1252
+    local -a completions_with_descriptions
1253
+    local -a response
1254
+    (( ! $+commands[%(prog_name)s] )) && return 1
1255
+
1256
+    response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) \
1257
+%(complete_var)s=zsh_complete %(prog_name)s)}")
1258
+
1259
+    for type key descr in ${response}; do
1260
+        if [[ "$type" == "plain" ]]; then
1261
+            if [[ "$descr" == "_" ]]; then
1262
+                completions+=("$key")
1263
+            else
1264
+                completions_with_descriptions+=("$key":"$descr")
1265
+            fi
1266
+        elif [[ "$type" == "dir" ]]; then
1267
+            _path_files -/
1268
+        elif [[ "$type" == "file" ]]; then
1269
+            _path_files -f
1270
+        fi
1271
+    done
1272
+
1273
+    if [ -n "$completions_with_descriptions" ]; then
1274
+        _describe -V unsorted completions_with_descriptions -U
1275
+    fi
1276
+
1277
+    if [ -n "$completions" ]; then
1278
+        compadd -U -V unsorted -a completions
1279
+    fi
1280
+}
1281
+
1282
+if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
1283
+    # autoload from fpath, call function directly
1284
+    %(complete_func)s "$@"
1285
+else
1286
+    # eval/source/. command, register function for later
1287
+    compdef %(complete_func)s %(prog_name)s
1288
+fi
1289
+"""
1290
+if (
1291
+    click.shell_completion.ZshComplete.source_template == _ORIG_SOURCE_TEMPLATE
1292
+):  # pragma: no cover
1233 1293
     click.shell_completion.add_completion_class(ZshComplete)
1234 1294
 
1235 1295
 
... ...
@@ -3538,12 +3538,13 @@ def fish_format(item: click.shell_completion.CompletionItem) -> str:
3538 3538
 
3539 3539
 
3540 3540
 def zsh_format(item: click.shell_completion.CompletionItem) -> str:
3541
-    type, value, help = (  # noqa: A001
3542
-        item.type,
3543
-        item.value.replace(':', r'\:'),
3544
-        item.help or '_',
3541
+    empty_help = '_'
3542
+    help_, value = (
3543
+        (item.help, item.value.replace(':', r'\:'))
3544
+        if item.help and item.help == empty_help
3545
+        else (empty_help, item.value)
3545 3546
     )
3546
-    return f'{type}\n{value}\n{help}'
3547
+    return f'{item.type}\n{value}\n{help_}'
3547 3548
 
3548 3549
 
3549 3550
 def completion_item(
3550 3551