Subject: bug#77718: 31.0.50; completion styles substring and flex are broken

From: Spencer Baugh <[email-masked]>
Date: Tue, 06 May 2025 11:39:00 -0400

Stefan Monnier <[email-masked]> writes:
>> diff --git a/lisp/minibuffer.el b/lisp/minibuffer.el
>> index 7b2b986aa1d..e35db9dad64 100644
>> --- a/lisp/minibuffer.el
>> +++ b/lisp/minibuffer.el
>> @@ -3597,8 +3597,18 @@ completion--file-name-table
>>      (if (eq (car-safe action) 'boundaries)
>>          (cons 'boundaries (completion--sifn-boundaries orig table pred (cdr action)))
>>        (let* ((sifned (substitute-in-file-name orig))
>> +             (orig-start (car (completion--sifn-boundaries orig table pred "")))
>> +             (sifned-start (car (completion-boundaries sifned table pred "")))
>> +             (orig-in-bounds (substring orig orig-start))
>> +             (sifned-in-bounds (substring sifned sifned-start))
>> +             (did-expansion-in-bounds (not (string-equal orig-in-bounds sifned-in-bounds)))
>
> Doesn't this set `did-expansion-in-bounds` to non-nil if there was a $$
> in `orig`?

Good point, I didn't think about that.  That's probably not
acceptable... fixed in the latest version at the end.

>>               (result
>> -              (let ((completion-regexp-list
>> +              (let ((completion-ignore-case
>> +                     ;; If we expanded an environment variable in the completion
>> +                     ;; bounds, don't ignore-case, otherwise `read-file-name'
>> +                     ;; can return the wrong filename.
>> +                     (and (not did-expansion-in-bounds) completion-ignore-case))
>
> AFAICT we're not "doing The Right Thing" here but falling back to
> a suboptimal behavior that favors not returning bogus completions at the
> cost of missing some completions.  And we do this because doing TRT is
> a lot more complicated.
>
> I'm not opposed to this trade-off, but the comment should make it more
> clear.  And it should come with a good example of the wrong thing we're
> trying to avoid.

Yes, that's true.  I'll try to include a nice example in later versions.

>> +        ;; For each completion, quote dollar signs in newly added text.  If sifn
>> +        ;; changed text within the completion boundaries, replace that with text
>> +        ;; from ORIG; otherwise, use text from the completion (it may differ if
>> +        ;; `completion-ignore-case' is non-nil)
>
> Here also, clarify that we choose between those two because just don't
> want to write the code that correctly merges the capitalization changes
> back into the non-env-var part of the original string.  An example is
> probably the easier way to illustrate the difficulty.

That's fair.

Though to be clear, at this point the issue isn't really the
capitalization changes.  I have what I think is a pretty much correct
approach for that, where we just give up on preserving the environment
variables if the capitalization changes can't be merged into the
original string.

That approach works great if the text inside the completion boundaries
in ORIG (the original string) "matches up" with the text inside the
completion boundaries in SIFNED (the string after s-i-f-n).

But, when the last component of ORIG contains an environment variable
which contains a slash, then the completion boundaries on ORIG and
SIFNED don't "match up".  The completions returned from the underlying
table will be for the boundaries on SIFNED, and need to be adjusted for
the boundaries on ORIG (by adding additional text in front), since those
are the boundaries our table returns.

e.g., supposing SLASH_B=/b
orig: /foo/bar${SLASH_B}az
orig split on the completion boundaries we returned: /foo/ bar${SLASH_B}az
sifned: /foo/bar/baz
sifned split on the underlying table's completion boundaries: /foo/bar/ baz

underlying table returns completions like:
baz1 baz2

we need to return completions like:
bar${SLASH_B}az1 bar${SLASH_B}az2

This is easy if we ignore case completely, and that's what we do in the
version currently in the master branch: Simply replace "baz" (the text
in the underlying table's completion boundaries) with "bar${SLASH_B}az"
(the text in our completion in each completion, and we're good.

But if we want to preserve case, then even if we don't care about
preserving environment variables at all, this becomes difficult.

e.g. if the underlying table returns completions like:
baz1 Baz2

Suppose we're okay with returning completions like:
bar/baz1 bar/Baz2

It's still unclear how to compute that "bar/" is the text which we
should prefer in front of each completion.

It's hard to even figure out that this ${SLASH_B} scenario is happening; I
haven't yet thought of a robust way to figure out that there's a /
inside the completion boundaries in ORIG.

So instead I'm trying to side-step this problem by identifying the
simple common scenario where the text inside the completion boundaries
doesn't contain any environment variables, and then behaving right in
that scenario, and just blindly preserving environment variables
heedless of case when not in that scenario.  Here's an attempt at that,
do you think it might be fine?

diff --git a/lisp/minibuffer.el b/lisp/minibuffer.el
index 7b2b986aa1d..6f9e6c67541 100644
--- a/lisp/minibuffer.el
+++ b/lisp/minibuffer.el
@@ -3597,6 +3597,16 @@ completion--file-name-table
     (if (eq (car-safe action) 'boundaries)
         (cons 'boundaries (completion--sifn-boundaries orig table pred (cdr action)))
       (let* ((sifned (substitute-in-file-name orig))
+             (orig-start (car (completion--sifn-boundaries orig table pred "")))
+             (sifned-start (car (completion-boundaries sifned table pred "")))
+             (orig-in-bounds (substring orig orig-start))
+             (sifned-in-bounds (substring sifned sifned-start))
+             (only-need-double-dollars
+              ;; If true, sifn only un-doubled $s in ORIG, so we can fix a
+              ;; completion to match ORIG by just doubling $s again.  This
+              ;; preserves more text from the completion, behaving better with
+              ;; non-nil `completion-ignore-case'.
+              (string-equal orig-in-bounds (minibuffer--double-dollars sifned-in-bounds)))
              (result
               (let ((completion-regexp-list
                      ;; Regexps are matched against the real file names after
@@ -3611,21 +3621,21 @@ completion--file-name-table
           (if (stringp result)
               ;; Extract the newly added text, quote any dollar signs, and
               ;; append it to ORIG.
-              (let ((new-text (substring result (length sifned))))
-                (concat orig (minibuffer--double-dollars new-text)))
+              (if only-need-double-dollars
+                  (concat (substring orig nil orig-start)
+                          (minibuffer--double-dollars (substring result sifned-start)))
+                (let ((new-text (substring result (length sifned))))
+                  (concat orig (minibuffer--double-dollars new-text))))
             result))
          ((eq action t)                 ; all-completions
           (mapcar
-           (let ((orig-prefix
-                  (substring orig (car (completion--sifn-boundaries orig table pred ""))))
-                 (sifned-prefix-length
-                  (- (length sifned)
-                     (car (completion-boundaries sifned table pred "")))))
+           (if only-need-double-dollars
+               #'minibuffer--double-dollars
              ;; Extract the newly added text, quote any dollar signs, and append
              ;; it to the part of ORIG inside the completion boundaries.
              (lambda (compl)
-               (let ((new-text (substring compl sifned-prefix-length)))
-                 (concat orig-prefix (minibuffer--double-dollars new-text)))))
+               (let ((new-text (substring compl (length sifned-in-bounds))))
+                 (concat orig-in-bounds (minibuffer--double-dollars new-text)))))
            result))
          (t result))))))