Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.72% covered (success)
96.72%
59 / 61
87.50% covered (success)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Lexer
96.72% covered (success)
96.72%
59 / 61
87.50% covered (success)
87.50%
7 / 8
33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 tokenize
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 readQuotedString
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 skipComment
83.33% covered (success)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
7.23
 readWhitespace
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 readAtom
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 unfold
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findTopLevelDelimiter
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
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 header token lexer
19 *
20 * Tokenizes a header name/value string into atoms, quoted-strings, structural
21 * delimiters, and folding whitespace, per RFC 5322 ยง3.2. Comments ("(...)",
22 * nesting-aware) are recognized so they don't corrupt quote/delimiter
23 * tracking, but are discarded rather than emitted as tokens. Malformed input
24 * (an unterminated quoted-string or unbalanced comment) degrades gracefully
25 * rather than throwing - this is a best-effort scanner for real-world
26 * mail/HTTP headers, some of which are slightly off-spec.
27 *
28 * @category   Pop
29 * @package    Pop\Mime
30 * @author     Nick Sagona, III <dev@noladev.com>
31 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
32 * @license    https://www.popphp.org/license     New BSD License
33 * @version    3.0.0
34 */
35final class Lexer
36{
37
38    const ATOM          = 'ATOM';
39    const QUOTED_STRING = 'QUOTED_STRING';
40    const DELIMITER     = 'DELIMITER';
41    const FWS           = 'FWS';
42
43    protected const DELIMITERS = [';' => true, ',' => true, '=' => true, ':' => true, '<' => true, '>' => true];
44    protected const WHITESPACE = [' ' => true, "\t" => true, "\r" => true, "\n" => true];
45
46    protected string $input;
47    protected int    $length;
48    protected int    $pos = 0;
49
50    public function __construct(string $input)
51    {
52        $this->input  = $input;
53        $this->length = strlen($input);
54    }
55
56    /**
57     * @return Token[]
58     */
59    public function tokenize(): array
60    {
61        $tokens = [];
62
63        while ($this->pos < $this->length) {
64            $char = $this->input[$this->pos];
65
66            if ($char === '"') {
67                $tokens[] = $this->readQuotedString();
68            } else if ($char === '(') {
69                $this->skipComment();
70            } else if (isset(self::DELIMITERS[$char])) {
71                $tokens[] = new Token(self::DELIMITER, $char, $this->pos, $this->pos + 1);
72                $this->pos++;
73            } else if (isset(self::WHITESPACE[$char])) {
74                $tokens[] = $this->readWhitespace();
75            } else {
76                $tokens[] = $this->readAtom();
77            }
78        }
79
80        return $tokens;
81    }
82
83    protected function readQuotedString(): Token
84    {
85        $start = $this->pos;
86        $this->pos++;
87        $value = '';
88
89        while ($this->pos < $this->length) {
90            $char = $this->input[$this->pos];
91            if (($char === '\\') && (($this->pos + 1) < $this->length)) {
92                $value .= $this->input[$this->pos + 1];
93                $this->pos += 2;
94                continue;
95            }
96            if ($char === '"') {
97                $this->pos++;
98                break;
99            }
100            $value .= $char;
101            $this->pos++;
102        }
103
104        return new Token(self::QUOTED_STRING, $value, $start, $this->pos);
105    }
106
107    protected function skipComment(): void
108    {
109        $this->pos++;
110        $depth = 1;
111
112        while (($this->pos < $this->length) && ($depth > 0)) {
113            $char = $this->input[$this->pos];
114            if (($char === '\\') && (($this->pos + 1) < $this->length)) {
115                $this->pos += 2;
116                continue;
117            }
118            if ($char === '(') {
119                $depth++;
120            } else if ($char === ')') {
121                $depth--;
122            }
123            $this->pos++;
124        }
125    }
126
127    protected function readWhitespace(): Token
128    {
129        $start = $this->pos;
130        while (($this->pos < $this->length) && isset(self::WHITESPACE[$this->input[$this->pos]])) {
131            $this->pos++;
132        }
133        return new Token(self::FWS, ' ', $start, $this->pos);
134    }
135
136    protected function readAtom(): Token
137    {
138        $start = $this->pos;
139        while (
140            ($this->pos < $this->length) &&
141            !isset(self::DELIMITERS[$this->input[$this->pos]]) &&
142            !isset(self::WHITESPACE[$this->input[$this->pos]]) &&
143            ($this->input[$this->pos] !== '"') &&
144            ($this->input[$this->pos] !== '(')
145        ) {
146            $this->pos++;
147        }
148        return new Token(self::ATOM, substr($this->input, $start, $this->pos - $start), $start, $this->pos);
149    }
150
151    /**
152     * Collapse obs-fold sequences (CRLF followed by one-or-more SP/TAB) into a
153     * single space, leaving other CRLFs untouched as line separators.
154     */
155    public static function unfold(string $input): string
156    {
157        return (string)preg_replace('/\r\n[ \t]+/', ' ', $input);
158    }
159
160    /**
161     * Find the first occurrence of $delimiterChar that is a top-level
162     * DELIMITER token in $input (i.e. not inside a quoted-string or comment).
163     */
164    public static function findTopLevelDelimiter(string $input, string $delimiterChar): ?Token
165    {
166        $tokens = (new self($input))->tokenize();
167
168        foreach ($tokens as $token) {
169            if (($token->type === self::DELIMITER) && ($token->value === $delimiterChar)) {
170                return $token;
171            }
172        }
173
174        return null;
175    }
176
177}