1313
1414use CodeIgniter \Autoloader \FileLocatorInterface ;
1515use CodeIgniter \Exceptions \ConfigException ;
16+ use CodeIgniter \Exceptions \InvalidArgumentException ;
1617use CodeIgniter \Exceptions \RuntimeException ;
1718use Config \Encryption ;
1819use Config \Modules ;
@@ -311,6 +312,14 @@ protected function registerProperties()
311312 }
312313
313314 foreach ($ properties as $ property => $ value ) {
315+ // Directives are recognized only at the property root.
316+ if ($ value instanceof Merge) {
317+ $ this ->{$ property } = $ this ->applyMerge ($ this ->{$ property } ?? null , $ value );
318+
319+ continue ;
320+ }
321+
322+ // Legacy behavior - unchanged, and on the hot path with no extra checks.
314323 if (isset ($ this ->{$ property }) && is_array ($ this ->{$ property }) && is_array ($ value )) {
315324 $ this ->{$ property } = array_merge ($ this ->{$ property }, $ value );
316325 } else {
@@ -319,4 +328,120 @@ protected function registerProperties()
319328 }
320329 }
321330 }
331+
332+ /**
333+ * Applies a property-root Merge directive against the current value.
334+ *
335+ * REPLACE is terminal - its payload is taken verbatim. The list strategies
336+ * (APPEND/PREPEND/BEFORE/AFTER) resolve via mergeList(). BY_KEY recurses via
337+ * mergeByKey(), honoring nested directives.
338+ */
339+ private function applyMerge (mixed $ current , Merge $ directive ): mixed
340+ {
341+ return match ($ directive ->strategy ) {
342+ Merge::REPLACE => $ directive ->value ,
343+ Merge::BY_KEY => $ this ->mergeByKey (is_array ($ current ) ? $ current : [], $ directive ->value ),
344+ Merge::APPEND , Merge::PREPEND , Merge::BEFORE , Merge::AFTER => $ this ->mergeList (is_array ($ current ) ? $ current : [], $ directive ),
345+ default => throw new InvalidArgumentException ('Unknown merge strategy: ' . $ directive ->strategy ),
346+ };
347+ }
348+
349+ /**
350+ * Resolves a list directive (APPEND, PREPEND, BEFORE, AFTER) against the
351+ * current value treated as a list.
352+ *
353+ * The directives never introduce a duplicate value: the incoming payload is
354+ * de-duplicated against itself (keeping first-seen order) and values already
355+ * in the list are not added again. Duplicates that already exist in the
356+ * current list are left untouched. Then:
357+ * - APPEND/PREPEND add only the values that are absent - already-present
358+ * values are left where they are (no relocation).
359+ * - BEFORE/AFTER move an already-present value to the anchor position, but
360+ * only when the anchor exists. If the anchor is missing they fall back to
361+ * APPEND/PREPEND respectively and do not relocate already-present values.
362+ *
363+ * The anchor is matched strictly (===) against the list elements, using the
364+ * first match. Do not use a value as both the anchor and an inserted value.
365+ *
366+ * @param array<array-key, mixed> $current
367+ *
368+ * @return list<mixed>
369+ */
370+ private function mergeList (array $ current , Merge $ directive ): array
371+ {
372+ $ current = array_values ($ current );
373+
374+ // De-duplicate the payload itself (strict, first-seen order) so a value
375+ // repeated within it is not inserted twice.
376+ $ incoming = [];
377+
378+ foreach ($ directive ->value as $ value ) {
379+ if (! in_array ($ value , $ incoming , true )) {
380+ $ incoming [] = $ value ;
381+ }
382+ }
383+
384+ $ anchored = $ directive ->strategy === Merge::BEFORE || $ directive ->strategy === Merge::AFTER ;
385+ $ anchorFound = $ anchored && in_array ($ directive ->anchor , $ current , true );
386+
387+ if ($ anchorFound ) {
388+ // Move-to-position: pull out any present copies, then insert the
389+ // whole incoming block at the (recomputed) anchor position.
390+ $ current = array_values (array_filter (
391+ $ current ,
392+ static fn ($ value ): bool => ! in_array ($ value , $ incoming , true ),
393+ ));
394+
395+ $ index = (int ) array_search ($ directive ->anchor , $ current , true );
396+ $ offset = $ directive ->strategy === Merge::AFTER ? $ index + 1 : $ index ;
397+
398+ array_splice ($ current , $ offset , 0 , $ incoming );
399+
400+ return $ current ;
401+ }
402+
403+ // APPEND/PREPEND, or BEFORE/AFTER with a missing anchor: add only the
404+ // values not already present, without relocating anything.
405+ $ incoming = array_values (array_filter (
406+ $ incoming ,
407+ static fn ($ value ): bool => ! in_array ($ value , $ current , true ),
408+ ));
409+
410+ return $ directive ->strategy === Merge::PREPEND || $ directive ->strategy === Merge::BEFORE
411+ ? array_merge ($ incoming , $ current )
412+ : array_merge ($ current , $ incoming );
413+ }
414+
415+ /**
416+ * Recursive by-key merge used by Merge::byKey(): string keys recurse, integer
417+ * keys append, scalar leaves are replaced, and nested Merge directives are
418+ * honored. A missing/non-array current child uses [] as its base, so directives
419+ * in brand-new subtrees are still resolved.
420+ *
421+ * @param array<array-key, mixed> $current
422+ * @param array<array-key, mixed> $incoming
423+ *
424+ * @return array<array-key, mixed>
425+ */
426+ private function mergeByKey (array $ current , array $ incoming ): array
427+ {
428+ foreach ($ incoming as $ key => $ value ) {
429+ if ($ value instanceof Merge) {
430+ if (is_int ($ key )) {
431+ // No stable current element at an appended position; resolve against null.
432+ $ current [] = $ this ->applyMerge (null , $ value );
433+ } else {
434+ $ current [$ key ] = $ this ->applyMerge ($ current [$ key ] ?? null , $ value );
435+ }
436+ } elseif (is_int ($ key )) {
437+ $ current [] = $ value ;
438+ } elseif (isset ($ current [$ key ]) && is_array ($ current [$ key ]) && is_array ($ value )) {
439+ $ current [$ key ] = $ this ->mergeByKey ($ current [$ key ], $ value );
440+ } else {
441+ $ current [$ key ] = $ value ;
442+ }
443+ }
444+
445+ return $ current ;
446+ }
322447}
0 commit comments