Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
15 / 15
86.67% covered (warning)
86.67%
26 / 30
9.52% covered (danger)
9.52%
4 / 42
0.00% covered (danger)
0.00%
0 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
Suggestion
100.00% covered (success)
100.00%
15 / 15
86.67% covered (warning)
86.67%
26 / 30
9.52% covered (danger)
9.52%
4 / 42
100.00% covered (success)
100.00%
2 / 2
55.40
100.00% covered (success)
100.00%
1 / 1
 find
100.00% covered (success)
100.00%
11 / 11
93.33% covered (success)
93.33%
14 / 15
4.17% covered (danger)
4.17%
1 / 24
100.00% covered (success)
100.00%
1 / 1
27.00
 distance
100.00% covered (success)
100.00%
4 / 4
80.00% covered (warning)
80.00%
12 / 15
16.67% covered (danger)
16.67%
3 / 18
100.00% covered (success)
100.00%
1 / 1
8.21
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Console;
6
7/**
8 * Finds the name a mistyped one was meant to be, for "Did you mean"
9 * hints.
10 *
11 * @internal
12 */
13final class Suggestion
14{
15    /**
16     * The candidate nearest to the typed name, if it is near enough: one
17     * typo per three characters, at least one. A single character has to
18     * match: every other one is just a typo away.
19     *
20     * @param list<string> $candidates The first one wins a tie
21     * @param string $ignore Leading characters that do not count, like
22     *     the dashes of options
23     */
24    public static function find(string $typed, array $candidates, string $ignore = ''): ?string
25    {
26        $typed = ltrim($typed, $ignore);
27        $length = strlen($typed);
28        $best = '';
29        $bestDistance = PHP_INT_MAX;
30
31        foreach ($candidates as $candidate) {
32            $distance = self::distance($typed, ltrim($candidate, $ignore));
33
34            if ($distance < $bestDistance) {
35                $bestDistance = $distance;
36                $best = $candidate;
37            }
38        }
39
40        $limit = $length > 1 ? max(1, intdiv($length, 3)) : 0;
41
42        return $bestDistance <= $limit ? $best : null;
43    }
44
45    /**
46     * The number of typos between two names. The beginning of a name and
47     * a swapped pair of neighbours count as one, where levenshtein() sees
48     * more.
49     */
50    private static function distance(string $typed, string $name): int
51    {
52        $distance = levenshtein($typed, $name);
53
54        // The XOR is zero up to the first difference.
55        $at = strspn($typed ^ $name, "\0");
56        $swapped = substr_replace($typed, strrev(substr($typed, $at, 2)), $at, 2);
57
58        return $swapped === $name || str_starts_with($name, $typed) ? min($distance, 1) : $distance;
59    }
60}