Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
88 / 88
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
AutocompletePlanBuilder
100.00% covered (success)
100.00%
88 / 88
100.00% covered (success)
100.00%
8 / 8
33
100.00% covered (success)
100.00%
1 / 1
 buildMatchContext
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 normalizeAutocompleteSettings
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 resolveTargetComponents
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 resolveCompositeTargetComponents
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 buildAutocompletePlan
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
9
 appendSeparatorCharacters
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 splitAutocompleteInput
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 mapAssoc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\name\Utility;
6
7use Drupal\Core\Field\FieldDefinitionInterface;
8
9/**
10 * Builds autocomplete plans and parses user input for name fields.
11 *
12 * @internal
13 */
14final class AutocompletePlanBuilder {
15
16  /**
17   * Name field components.
18   *
19   * @var list<string>
20   */
21  private const ALL_COMPONENTS = [
22    'given',
23    'middle',
24    'family',
25    'title',
26    'credentials',
27    'generational',
28  ];
29
30  /**
31   * Builds match context from field settings, target, and user input.
32   *
33   * @param \Drupal\Core\Field\FieldDefinitionInterface $field
34   *   The field definition.
35   * @param string $target
36   *   The autocomplete target identifier.
37   * @param string $string
38   *   The raw user input string.
39   * @param callable(string, string): (list<string>|false)|null $split_pieces
40   *   Callable that splits input; defaults to preg_split().
41   *
42   * @return array{
43   *   settings: array<string, mixed>,
44   *   plan: array{
45   *     components: array<string, string>,
46   *     source: array{
47   *       title: array<int, string>,
48   *       generational: array<int, string>,
49   *       data: array<int, string>
50   *     },
51   *     separator: string
52   *   },
53   *   base_string: string,
54   *   test_string: string
55   *   }|null
56   *   Context for match collection, or NULL when no matches are possible.
57   */
58  public static function buildMatchContext(FieldDefinitionInterface $field, string $target, string $string, ?callable $split_pieces = NULL): ?array {
59    if ($string === '') {
60      return NULL;
61    }
62
63    $settings = self::normalizeAutocompleteSettings($field->getSettings());
64    $plan     = self::buildAutocompletePlan(
65      $settings,
66      self::resolveTargetComponents($target),
67    );
68    $input    = self::splitAutocompleteInput($string, $plan['separator'], $split_pieces);
69
70    $input_missing    = $input === NULL;
71    $components_empty = empty($plan['components']);
72    $context_invalid  = $input_missing || $components_empty;
73    if ($context_invalid) {
74      return NULL;
75    }
76
77    return [
78      'settings'    => $settings,
79      'plan'        => $plan,
80      'base_string' => $input['base'],
81      'test_string' => $input['test'],
82    ];
83  }
84
85  /**
86   * Normalizes the required autocomplete settings for all components.
87   *
88   * @param array<string, mixed> $settings
89   *   The field settings.
90   *
91   * @return array<string, mixed>
92   *   The settings with normalized autocomplete source keys.
93   */
94  public static function normalizeAutocompleteSettings(array $settings): array {
95    foreach (self::ALL_COMPONENTS as $component) {
96      if (!isset($settings['autocomplete_source'][$component])) {
97        $settings['autocomplete_source'][$component] = [];
98      }
99      $settings['autocomplete_source'][$component] = array_filter($settings['autocomplete_source'][$component]);
100    }
101    return $settings;
102  }
103
104  /**
105   * Resolves an autocomplete target into an associative component map.
106   *
107   * @return array<string, string>
108   *   A map keyed by component machine name.
109   */
110  public static function resolveTargetComponents(string $target): array {
111    return match ($target) {
112      'name' => self::mapAssoc(['given', 'middle', 'family']),
113      'name-all' => self::mapAssoc(self::ALL_COMPONENTS),
114      'title', 'given', 'middle', 'family', 'credentials', 'generational' => [$target => $target],
115      default => self::resolveCompositeTargetComponents($target),
116    };
117  }
118
119  /**
120   * Resolves a hyphen-delimited target into valid core components.
121   *
122   * @return array<string, string>
123   *   A map keyed by component machine name.
124   */
125  public static function resolveCompositeTargetComponents(string $target): array {
126    $components = [];
127    $target_components = explode('-', $target);
128    foreach ($target_components as $component) {
129      if (array_key_exists($component, NameComponents::coreKeys())) {
130        $components[$component] = $component;
131      }
132    }
133    return $components;
134  }
135
136  /**
137   * Builds the executable autocomplete plan for component and source lookups.
138   *
139   * @param array<string, mixed> $settings
140   *   Normalized field settings.
141   * @param array<string, string> $components
142   *   Requested components keyed by component name.
143   *
144   * @return array{
145   *   components: array<string, string>,
146   *   source: array{
147   *     title: array<int, string>,
148   *     generational: array<int, string>,
149   *     data: array<int, string>
150   *   },
151   *   separator: string
152   *   }
153   *   The actionable plan for source resolution and input splitting.
154   */
155  public static function buildAutocompletePlan(array $settings, array $components): array {
156    $plan = [
157      'components' => $components,
158      'source' => [
159        'title' => [],
160        'generational' => [],
161        'data' => [],
162      ],
163      'separator' => '',
164    ];
165
166    foreach ($plan['components'] as $component) {
167      if (empty($settings['autocomplete_source'][$component])) {
168        unset($plan['components'][$component]);
169        continue;
170      }
171
172      $plan['separator'] = self::appendSeparatorCharacters(
173        $plan['separator'],
174        (string) ($settings['autocomplete_separator'][$component] ?? ''),
175      );
176      $found_source = FALSE;
177
178      foreach ((array) $settings['autocomplete_source'][$component] as $source) {
179        $label_source  = $source === 'title' || $source === 'generational';
180        $comp_mismatch = $component !== $source;
181        $skip_source   = $label_source && $comp_mismatch;
182        if ($skip_source) {
183          continue;
184        }
185        if (!array_key_exists($source, $plan['source'])) {
186          continue;
187        }
188        $found_source = TRUE;
189        $plan['source'][$source][] = $component;
190      }
191
192      if (!$found_source) {
193        unset($plan['components'][$component]);
194      }
195    }
196
197    return $plan;
198  }
199
200  /**
201   * Adds unique separator characters from one component into the set.
202   */
203  public static function appendSeparatorCharacters(string $separator, string $component_separator): string {
204    if ($component_separator === '') {
205      $component_separator = ' ';
206    }
207
208    for ($i = 0; $i < strlen($component_separator); $i += 1) {
209      if (strpos($separator, $component_separator[$i]) === FALSE) {
210        $separator .= $component_separator[$i];
211      }
212    }
213
214    return $separator;
215  }
216
217  /**
218   * Splits autocomplete input into base prefix and searchable token.
219   *
220   * @param string $string
221   *   The raw autocomplete input string.
222   * @param string $separator
223   *   The separator character set.
224   * @param callable(string, string): (list<string>|false)|null $split_pieces
225   *   Callable that splits input; defaults to preg_split().
226   *
227   * @return array{base: string, test: string}|null
228   *   The parsed input parts, or NULL when no usable split can be performed.
229   */
230  public static function splitAutocompleteInput(string $string, string $separator, ?callable $split_pieces = NULL): ?array {
231    if ($separator === '') {
232      return NULL;
233    }
234
235    $split  = $split_pieces ?? static fn (string $pattern, string $subject): array|false => \preg_split($pattern, $subject);
236    $pieces = $split(
237      '/[' . \preg_quote($separator, '/') . ']+/',
238      $string,
239    );
240    if (empty($pieces)) {
241      return NULL;
242    }
243
244    $test_string = mb_strtolower((string) array_pop($pieces));
245    if ($test_string === '') {
246      return NULL;
247    }
248
249    return [
250      'base' => mb_substr($string, 0, mb_strlen($string) - mb_strlen($test_string)),
251      'test' => $test_string,
252    ];
253  }
254
255  /**
256   * Combines array values into an associative array keyed by value.
257   *
258   * @param array<int, string> $values
259   *   Values to combine.
260   *
261   * @return array<string, string>
262   *   Combined values.
263   */
264  private static function mapAssoc(array $values): array {
265    return array_combine($values, $values);
266  }
267
268}