Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.35% covered (success)
95.35%
41 / 43
77.78% covered (success)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AddressList
95.35% covered (success)
95.35%
41 / 43
77.78% covered (success)
77.78%
7 / 9
28
0.00% covered (danger)
0.00%
0 / 1
 __construct
50.00% covered (warning)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 parse
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 isGroupShaped
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
10
 spliceTokens
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 addAddress
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getAddresses
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __toString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Mime\Part\Header;
16
17/**
18 * RFC 5322 address-list - a comma-separated collection of Address objects.
19 * No group support - a group-shaped segment is treated as one opaque
20 * Address rather than parsed structurally.
21 *
22 * @category   Pop
23 * @package    Pop\Mime
24 * @author     Nick Sagona, III <dev@noladev.com>
25 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    3.0.0
28 */
29final class AddressList
30{
31
32    /**
33     * @var Address[]
34     */
35    protected array $addresses = [];
36
37    /**
38     * @param Address[] $addresses
39     */
40    public function __construct(array $addresses = [])
41    {
42        foreach ($addresses as $address) {
43            $this->addAddress($address);
44        }
45    }
46
47    /**
48     * Parse a comma-separated address list. Splits only on top-level
49     * commas - a comma inside a quoted display name is part of a
50     * QUOTED_STRING token, never a top-level delimiter, so it doesn't
51     * split the list. Never throws.
52     *
53     * @param  string $text
54     * @return AddressList
55     */
56    public static function parse(string $text): AddressList
57    {
58        $tokens = (new Lexer($text))->tokenize();
59        $list   = new self();
60
61        if (self::isGroupShaped($tokens)) {
62            $trimmed = trim($text);
63            if ($trimmed !== '') {
64                $list->addAddress(new Address($trimmed));
65            }
66            return $list;
67        }
68
69        $segments = [[]];
70        foreach ($tokens as $token) {
71            if (($token->type === Lexer::DELIMITER) && ($token->value === ',')) {
72                $segments[] = [];
73            } else {
74                $segments[count($segments) - 1][] = $token;
75            }
76        }
77
78        foreach ($segments as $segmentTokens) {
79            $segmentText = trim(self::spliceTokens($text, $segmentTokens));
80            if ($segmentText !== '') {
81                $list->addAddress(Address::parse($segmentText));
82            }
83        }
84
85        return $list;
86    }
87
88    /**
89     * A group ("display-name: mailbox-list;") is out of scope for this
90     * class - detected by a top-level ":" appearing before any top-level
91     * "<", AND a top-level ";" appearing somewhere after it (the group's
92     * closing terminator), and treated as one opaque, unparsed Address
93     * rather than being split on its internal commas. Both conditions are
94     * required - a bare unquoted ":" alone (e.g. a display name like
95     * "IT: Support" or "Ratio 3:1") is common in real-world addresses and
96     * must not be misdetected as a group.
97     *
98     * @param  Token[] $tokens
99     * @return bool
100     */
101    protected static function isGroupShaped(array $tokens): bool
102    {
103        $hasColonBeforeAngle = false;
104        foreach ($tokens as $token) {
105            if (($token->type === Lexer::DELIMITER) && ($token->value === ':')) {
106                $hasColonBeforeAngle = true;
107                break;
108            }
109            if (($token->type === Lexer::DELIMITER) && ($token->value === '<')) {
110                break;
111            }
112        }
113
114        if (!$hasColonBeforeAngle) {
115            return false;
116        }
117
118        foreach ($tokens as $token) {
119            if (($token->type === Lexer::DELIMITER) && ($token->value === ';')) {
120                return true;
121            }
122        }
123
124        return false;
125    }
126
127    /**
128     * @param  string  $source
129     * @param  Token[] $tokens
130     * @return string
131     */
132    protected static function spliceTokens(string $source, array $tokens): string
133    {
134        if (empty($tokens)) {
135            return '';
136        }
137        $first = $tokens[array_key_first($tokens)];
138        $last  = $tokens[array_key_last($tokens)];
139        return substr($source, $first->start, $last->end - $first->start);
140    }
141
142    /**
143     * @param  Address|string $address
144     * @param  ?string        $name
145     * @return AddressList
146     */
147    public function addAddress(Address|string $address, ?string $name = null): AddressList
148    {
149        $this->addresses[] = ($address instanceof Address) ? $address : new Address($address, $name);
150        return $this;
151    }
152
153    /**
154     * @return Address[]
155     */
156    public function getAddresses(): array
157    {
158        return $this->addresses;
159    }
160
161    /**
162     * @return int
163     */
164    public function count(): int
165    {
166        return count($this->addresses);
167    }
168
169    /**
170     * @return string
171     */
172    public function render(): string
173    {
174        return implode(', ', array_map(fn($address) => $address->render(), $this->addresses));
175    }
176
177    /**
178     * @return string
179     */
180    public function __toString(): string
181    {
182        return $this->render();
183    }
184
185}