diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a29f45..7e7f75f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,13 @@ jobs: run: composer install --prefer-dist --no-progress --no-suggest - name: Run test suite with coverage + # Pin an arbitrary SEED so coverage is deterministic: PropertyTest fuzzes + # from a time-based seed by default, which makes the measured line count + # (and the Codecov delta) jitter run-to-run. The value is chosen for + # reproducibility only, not to maximize the number. The 8.1–8.6 test jobs + # stay unseeded so they keep fuzzing across the matrix. + env: + SEED: '1' run: bin/phpunit --coverage-clover=coverage.xml --coverage-text - name: Upload coverage to Codecov diff --git a/.gitignore b/.gitignore index 4e11f6b..ee01122 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ infection-summary.log # phpbench local storage (machine-specific wall-clock times; not portable) .phpbench/ +scratchpad/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index c3c3161..64c91fc 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -3,6 +3,8 @@ $finder = PhpCsFixer\Finder::create() ->in(__DIR__ . '/src') ->in(__DIR__ . '/tests') + ->in(__DIR__ . '/rector') + ->exclude('fixtures') ->name('*.php') ->ignoreDotFiles(true) ->ignoreVCS(true); diff --git a/.scrutinizer.yml b/.scrutinizer.yml index c778e4d..513bda7 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,4 +1,9 @@ # .scrutinizer.yml +# +# Scrutinizer is used for coverage reporting only. Its bundled PHP analyzer +# (php-scrutinizer-run) does not model PHP 8.1 readonly properties or enum +# ->name/->value and flags correct code as bugs; static analysis is covered +# by PHPStan (level 8) and Psalm in GitHub Actions instead. filter: paths: @@ -7,11 +12,6 @@ filter: - 'tests/*' - 'vendor/*' -checks: - php: - code_rating: true - duplication: true - build: image: default-jammy environment: @@ -20,15 +20,13 @@ build: override: - composer install --no-interaction --no-scripts --ignore-platform-reqs nodes: - analysis: - tests: - override: - - php-scrutinizer-run tests: tests: override: - - command: 'XDEBUG_MODE=coverage bin/phpunit --coverage-clover=.coverage' + # SEED pins the property-test fuzzer so the measured + # coverage is deterministic (matches the CI coverage job). + command: 'SEED=1 XDEBUG_MODE=coverage bin/phpunit --coverage-clover=.coverage' coverage: file: '.coverage' format: 'clover' diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ab563ad..8ef0bfb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,11 +10,12 @@ over per-state handlers, backed by a per-parse context object. | | | |---|---| -| Entry point | `parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array` | +| Entry points | `parseSingle()` → `ParsedEmailAddress`, `parseMultiple()` → `ParseResult`, `parseStream()` → `Generator` (plus the deprecated array-returning `parse()`) | +| Core | All entry points funnel into the private `parseInternal(string $emails, bool $multiple, string $encoding): array` | | Model | Character-by-character state machine, 12 states | -| `parse()` body | Setup + a `switch ($ctx->state)` dispatch loop (~193 lines) | +| Core body | Setup + a `switch ($ctx->state)` dispatch loop (~185 lines) | | State handlers | 7 methods (one per switch arm) | -| Working state | `ParseContext` — one object per `parse()` call, ~24 accumulator fields | +| Working state | `ParseContext` — one object per parse, ~24 accumulator fields | | Reentrancy | A fresh context per call; nothing parse-specific is stored on the `Parse` instance | ## The dispatch loop @@ -106,16 +107,17 @@ last kind is cleared between addresses in a batch: |---|---|---| | Input snapshot | Set once per parse, never reset | `chars[]`, `len`, `emails`, `multiple` | | Hoisted config | Set once per parse, never reset | `separators`, `bannedChars`, `allowedWhitespace`, `useWhitespaceAsSeparator` | -| Per-address accumulator + loop control | Cleared by `resetAddress()` | `state`, `subState`, `commentNestLevel`, `original_address`, `local_part_parsed`, `domain`, `quote_temp`, `comments[]`, `in_angle_addr`, ... (~24 total) | +| Per-address accumulator + loop control | Cleared by `resetAddress()` | `state`, `subState`, `commentNestLevel`, `originalAddress`, `localPartParsed`, `domain`, `quoteTemp`, `comments[]`, `inAngleAddr`, ... (~24 total) | -The accumulator field names deliberately mirror the historical loop-local -variable names so they thread through the validation helpers unchanged; the -rename to the codebase's `camelCase` convention is a tracked follow-up (see -[`ROADMAP.md`](ROADMAP.md)). +The input snapshot and hoisted config are `public readonly` constructor-promoted +properties, so a state handler cannot mutate configuration mid-parse; only the +accumulator is writable. `state` and `subState` are typed as the `ParserState` +backed enum (`src/ParserState.php`), which replaced the former `Parse::STATE_*` +integer constants: the context can never hold an out-of-range state. ## Per-address reset -`resetAddress(int $state, int $subState)` is the single source of truth for +`resetAddress(ParserState $state, ParserState $subState)` is the single source of truth for clearing per-address state between addresses in a batch. It zeroes the accumulator *and* the three loop-control fields — `state`, `subState`, and `commentNestLevel`. Both call sites use it: the initial setup before the loop and diff --git a/CHANGELOG.md b/CHANGELOG.md index d35fbcf..d18c1f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added +- **Rector migration config** (`rector/upgrade-4.0.php`) that auto-fixes the mechanical 3.x → 4.0 call-site changes: `Parse::getInstance()` → `new Parse()`, `ParseOptions` pass-through getters → readonly-property reads, and the removed mutating setters → their `withX()` builders where the receiver is provably locally owned. Aliased receivers (parameters, `getOptions()` results, instances already passed to a parser) are left in place and annotated with a `TODO email-parse 4.0:` comment instead of being rewritten into a silently-diverging local reassignment. Opt-in (you run it and review the diff); see [UPGRADE.md](UPGRADE.md). + +### Changed +- **`ParseOptions` state fields are now `public readonly`** — `bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`, and `allowedWhitespace` are readable directly as properties (the pass-through `getX()` accessors still work but are deprecated, see below). Every `ParseOptions` property is now readonly; configure via the constructor or the `withX()` builders. +- **BREAKING: `Parse` now implements `Psr\Log\LoggerAwareInterface`, and `Parse::setLogger()` returns `void`** (was fluent, returned `Parse`). Standard PSR-3 logger injection; frameworks can auto-inject. If you chained on `setLogger()` (`$parser->setLogger($l)->…`), split it into two statements. + +### Deprecated +- **Every deprecated method now emits a runtime `E_USER_DEPRECATED` notice** (via the new `symfony/deprecation-contracts` dependency's `trigger_deprecation()`), so you see the deprecation *when you call the old API* — not just as a docblock. Tools like `symfony/phpunit-bridge` aggregate these into a report pointing at the exact call-sites, and the shipped Rector config (see Added) can then auto-fix most of them. +- **`Parse::setOptions()`** — a parser's configuration should be immutable for the life of the instance; mutating it on a shared parser is a footgun. Pass options to the constructor (`new Parse($logger, $options)`) instead. Removed in 5.0. +- **`Parse::parse()`** (the polymorphic `$multiple`-boolean, array-returning API) is deprecated. Use `parseSingle()` / `parseMultiple()` for typed value objects, or `parseStream()` for large batches; call `->toArray()` on a result if you need the legacy array shape. `parse()` keeps working as a thin shim over the typed core and will be removed in 5.0. +- **`Parse::getInstance()`** — the default-options singleton is deprecated; use explicit instantiation (`new Parse($logger, $options)`), which also lets you pass custom options. Removed in 5.0. +- **`ParseOptions` pass-through getters** — `getBannedChars()`, `getSeparators()`, `getUseWhitespaceAsSeparator()`, `getLengthLimits()`, and `getAllowedWhitespace()` are deprecated; read the corresponding `public readonly` property instead (e.g. `$options->bannedChars`). They now duplicate the promoted properties. Removed in 5.0. The `getMax*Length()` helpers are **not** deprecated — they read into `$lengthLimits`. + +### Removed +- **BREAKING: the deprecated `ParseOptions` mutating setters** — `setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength` (deprecated since v3.0) are removed. Configure via the constructor or the `withX()` builders; the state fields are now `public readonly`. +- **BREAKING: subclass overrides of `Parse::parse()` no longer affect `parseSingle()` / `parseMultiple()` / `parseStream()`.** The typed methods now call a private `parseInternal()` directly; the deprecated `parse()` is a shim beside them rather than the trunk they route through. Overriding the entry point was never a documented extension point; pre-process input before calling the parser, or wrap the typed result. See UPGRADE.md. +- **BREAKING: `Parse::validateLocalPart()`** — the `@deprecated` (3.9) `array`-based method is removed; local-part validation is now a `private`, `ParseContext`-based method. **`Parse::validateDomainName()` is now `private`.** Both took the parser's internal accumulator and were never a supported extension point — customize validation via `ParseOptions`. Any subclass that overrode them must move to `ParseOptions`-based configuration. + ## [3.9.0] Internal refactor of the `parse()` state machine into per-state handler methods backed by a new `ParseContext` object. Behavior-preserving and fully backward compatible — no public or protected signature changed and output is byte-identical. Adds the `ParseContext` type and deprecates `Parse::validateLocalPart()` (removed in 4.0). See [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/README.md b/README.md index 9035163..f8e5b68 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Email\Parse is a batch email address parser with configurable RFC compliance lev It parses a list of 1 to n email addresses separated by comma and whitespace by default, with configurable separators (e.g. semicolon). -**Other docs:** [Cookbook (recipes)](docs/cookbook.md) · [CHANGELOG](CHANGELOG.md) · [UPGRADE guide (v2.x → v3.0)](UPGRADE.md) · [DESIGN / RFC reference](DESIGN.md) · [ARCHITECTURE](ARCHITECTURE.md) · [ROADMAP](ROADMAP.md) +**Other docs:** [Cookbook (recipes)](docs/cookbook.md) · [CHANGELOG](CHANGELOG.md) · [UPGRADE guide](UPGRADE.md) · [DESIGN / RFC reference](DESIGN.md) · [ARCHITECTURE](ARCHITECTURE.md) · [ROADMAP](ROADMAP.md) Installation: ------------- @@ -32,28 +32,26 @@ Usage: ```php use Email\Parse; -// Array-based API (v2.x-compatible) -$result = Parse::getInstance()->parse("a@aaa.com b@bbb.com"); - -// Typed value objects (v3.1+, recommended for new code) -$address = Parse::getInstance()->parseSingle('john@example.com'); +// Typed value objects — parseSingle() / parseMultiple() / parseStream(). +// (The legacy array-returning parse() is deprecated; see "Other Examples" below.) +$address = (new Parse())->parseSingle('john@example.com'); echo $address->localPart; // "john" echo $address->domain; // "example.com" if ($address->invalid) { echo $address->invalidReasonCode->value; } -$result = Parse::getInstance()->parseMultiple('a@a.com, b@b.com'); +$result = (new Parse())->parseMultiple('a@a.com, b@b.com'); foreach ($result->emailAddresses as $addr) { /* ... */ } // Streaming for large batches (v3.2+) — yields one address at a time. -foreach (Parse::getInstance()->parseStream($csvRows) as $addr) { +foreach ((new Parse())->parseStream($csvRows) as $addr) { if ($addr->invalid) continue; // ... } // Serialization (v3.3+) -$parsed = Parse::getInstance()->parseSingle('"J Doe" '); +$parsed = (new Parse())->parseSingle('"J Doe" '); (string) $parsed; // "j@example.com" — Stringable returns simple_address $parsed->canonical(); // 'J Doe ' — minimal RFC 5322 quoting $parsed->toArray(); // legacy array shape, for mixed-API code @@ -71,18 +69,18 @@ use Email\ParseOptions; // Example 1: Use comma and semicolon as separators (default behavior includes whitespace) $options = new ParseOptions([], [',', ';']); $parser = new Parse(null, $options); -$result = $parser->parse("a@aaa.com; b@bbb.com, c@ccc.com"); +$result = $parser->parseMultiple("a@aaa.com; b@bbb.com, c@ccc.com"); // Example 2: Disable whitespace as separator (only comma and semicolon work) $options = new ParseOptions([], [',', ';'], false); $parser = new Parse(null, $options); -$result = $parser->parse("a@aaa.com; b@bbb.com"); // Works - uses semicolon -$result = $parser->parse("a@aaa.com b@bbb.com"); // Won't split - whitespace not a separator +$result = $parser->parseMultiple("a@aaa.com; b@bbb.com"); // Works - uses semicolon +$result = $parser->parseMultiple("a@aaa.com b@bbb.com"); // Won't split - whitespace not a separator // Example 3: Names with spaces always work regardless of whitespace separator setting $options = new ParseOptions([], [',', ';'], false); $parser = new Parse(null, $options); -$result = $parser->parse("John Doe , Jane Smith "); +$result = $parser->parseMultiple("John Doe , Jane Smith "); // Returns 2 valid emails with names preserved ``` @@ -265,19 +263,19 @@ RFC 5322 allows comments in email addresses using parentheses. The parser automa use Email\Parse; // Single comment -$result = Parse::getInstance()->parseSingle('john@example.com (home address)'); +$result = (new Parse())->parseSingle('john@example.com (home address)'); // $result->comments === ['home address'] // Multiple comments -$result = Parse::getInstance()->parseSingle('test(comment1)(comment2)@example.com'); +$result = (new Parse())->parseSingle('test(comment1)(comment2)@example.com'); // $result->comments === ['comment1', 'comment2'] // Nested comments -$result = Parse::getInstance()->parseSingle('test@example.com (comment with (nested) parens)'); +$result = (new Parse())->parseSingle('test@example.com (comment with (nested) parens)'); // $result->comments === ['comment with (nested) parens'] // No comments -$result = Parse::getInstance()->parseSingle('test@example.com'); +$result = (new Parse())->parseSingle('test@example.com'); // $result->comments === [] ``` @@ -293,7 +291,7 @@ See [UPGRADE.md](UPGRADE.md) for the complete list of breaking changes, deprecat ```php // v2.x default (legacy behavior — still works in v3.0) -$parser = Parse::getInstance(); +$parser = new Parse(); // v3.0 recommended default $options = ParseOptions::rfc5322(); @@ -366,11 +364,11 @@ $result = $parser->parseSingle('müller@münchen.de'); Other Examples: --------------- -The following examples use the legacy array-returning `parse()` method to document its full output shape. New code should prefer `parseSingle()` / `parseMultiple()` (see Basic Usage) for typed return values; both APIs expose the same underlying fields. +New code uses `parseSingle()` / `parseMultiple()` (see Basic Usage) for typed value objects; call `->toArray()` on either result for the array form. The examples below illustrate the principal fields — see [`ParsedEmailAddress`](src/ParsedEmailAddress.php) for the complete, canonical set. (The old `parse()` method is deprecated and will be removed in 5.0.) ```php $email = '"J Doe" '; - $result = Email\Parse::getInstance()->parse($email, false); + $result = (new Email\Parse())->parseSingle($email)->toArray(); $result == array( 'address' => '"J Doe" ', @@ -389,7 +387,7 @@ The following examples use the legacy array-returning `parse()` method to docume 'comments' => []); $emails = 'testing@[8.8.8.8] testing@xyz.com, "test.2"@xyz.com (comment)'; - $result = Email\Parse::getInstance()->parse($emails); + $result = (new Email\Parse())->parseMultiple($emails)->toArray(); $result == array( 'success' => true, 'reason' => null, diff --git a/ROADMAP.md b/ROADMAP.md index 21af298..6328ca4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,8 +30,13 @@ below as a record; planned work follows. ### Deprecations -- **v3.0:** `LengthLimits` moved to readonly constructor promotion (getters/setters removed — see [UPGRADE.md](UPGRADE.md)). The `ParseOptions` setters (`setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength`) are marked `@deprecated` and still functional; removal is targeted for v4.0. -- **v3.9:** `protected Parse::validateLocalPart(array $emailAddress)` marked `@deprecated`. Still functional and still a live extension point (subclass overrides are invoked), but customizing validation via `ParseOptions` is the supported path; removal is targeted for v4.0 (see Planned below). +- **v3.0 → removed v4.0:** the `ParseOptions` mutating setters (`setBannedChars`, `setSeparators`, `setUseWhitespaceAsSeparator`, `setLengthLimits`, `setMaxLocalPartLength`, `setMaxTotalLength`, `setMaxDomainLabelLength`) were deprecated in v3.0 and are removed in v4.0. The state fields are `public readonly`; configure via the constructor or `withX()` builders. +- **v3.0:** `LengthLimits` moved to readonly constructor promotion (getters/setters removed — see [UPGRADE.md](UPGRADE.md)). +- **v3.9 → removed v4.0:** `protected Parse::validateLocalPart(array $emailAddress)` was deprecated in 3.9 and is removed in 4.0 (now a `private` `ParseContext`-based method). Validation is customized via `ParseOptions`. +- **v4.0:** `Parse::parse()` (the polymorphic array API) marked `@deprecated` — kept as a working shim over the typed methods; removal targeted for v5.0. +- **v4.0:** `Parse::getInstance()` (default-options singleton) marked `@deprecated` — use `new Parse($logger, $options)`; removal targeted for v5.0. +- **v4.0:** `ParseOptions` pass-through getters (`getBannedChars`, `getSeparators`, `getUseWhitespaceAsSeparator`, `getLengthLimits`, `getAllowedWhitespace`) marked `@deprecated` — read the `public readonly` property instead; removal targeted for v5.0. (The `getMax*Length()` helpers stay — they read into `$lengthLimits`.) +- **v4.0:** `Parse::setOptions()` marked `@deprecated` — pass options to the constructor; a parser's configuration should be immutable for the life of the instance. Removal targeted for v5.0. (`Parse::setLogger()` now returns `void` via `LoggerAwareInterface` — a 4.0 breaking change, not a deprecation.) - `RfcMode` never shipped (existed only on a feature branch). ### Community & documentation @@ -66,30 +71,78 @@ Continuous work, not tied to a specific release. - [x] Hot-path fix — per-character `mb_substr` (O(n²) for multi-byte encodings) replaced with a single `mb_str_split` pass and array indexing. ~10–27% faster across the suite. **Maintainability:** -- [x] **`parse()` decomposition** (delivered; unreleased). The ~772-line state-machine loop is now a ~185-line dispatch loop over per-state handler methods, backed by a typed, per-parse `ParseContext` (a fresh instance per call keeps the parser reentrant). Behavior-preserving — same logic, conditions, ordering, and output. See [ARCHITECTURE.md](ARCHITECTURE.md). Follow-ups in the backlog below. +- [x] **`parse()` decomposition** (shipped in 3.9.0). The ~772-line state-machine loop is now a ~185-line dispatch loop over per-state handler methods, backed by a typed, per-parse `ParseContext` (a fresh instance per call keeps the parser reentrant). Behavior-preserving — same logic, conditions, ordering, and output. See [ARCHITECTURE.md](ARCHITECTURE.md). Follow-ups in the backlog below. + +## Strategic direction (North Star) + +Three longer-horizon goals — **PHP framework integration** (Symfony/Laravel), **localized error messages**, and **ports to other languages** (JS/Python/…) — all converge on one architectural principle: + +> **Decouple error _identity_ from error _presentation_.** An error is a `ParseErrorCode` plus named parameters (the language-agnostic identity); the human string is _rendered_ from that (the localizable presentation). + +This single decoupling serves all three: **i18n** = swap the message catalog; **ports** = the spec asserts on `code + parameters`, message text is non-normative and rendered per implementation; **framework hooks** = the framework's translator is the renderer. `ParseErrorCode` already supplies the identity; the 4.1 keystone adds the parameters + a swappable `MessageProvider`. + +Assets that make this credible — protect them as first-class: +- **`testspec.yml`** — a language-agnostic conformance suite; the canonical spec for ports (assert on `invalid_reason_code` + parameters; treat message text as non-normative). +- **`ARCHITECTURE.md`** — the portable algorithm reference for ports. +- **`ParseErrorCode`** — the stable error contract that i18n and ports bind to. + +Framework packages (`email-parse-symfony`, `email-parse-laravel`) live in separate repos on their own track, wrapping the frozen 4.0 typed API and wiring the framework Translator in as the `MessageProvider`. ## Planned ### v4.0 — Breaking modernization **API cleanup:** -- [ ] Remove the `@deprecated` `ParseOptions` setters (deprecated in v3.0). -- [ ] Promote the `ParseOptions` state fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`) to public `readonly` via constructor promotion with named arguments. -- [ ] Remove the polymorphic `parse()` in favor of `parseSingle()` / `parseMultiple()` with typed returns — drops the `$multiple` boolean parameter. -- [ ] Deprecate or remove the `getInstance()` singleton (recommend explicit instantiation). -- [ ] Remove the deprecated `Parse::validateLocalPart(array)` extension point (deprecated when the `parse()` decomposition landed) and fold local-part validation into a `private`, `ParseContext`-based method; likewise make `validateDomainName()` `private`. They take the parser's internal accumulator and were never a supported extension point — validation is customized through `ParseOptions`. +- [x] Removed the `@deprecated` `ParseOptions` setters (deprecated in v3.0). +- [x] Promoted the `ParseOptions` state fields (`bannedChars`, `separators`, `useWhitespaceAsSeparator`, `lengthLimits`, `allowedWhitespace`) to `public readonly`. Configured via the constructor or `withX()` builders; the `getX()` accessors remain. +- [x] **Deprecate** the polymorphic `parse()` (the `$multiple`-boolean array API) in favor of `parseSingle()` / `parseMultiple()` / `parseStream()`. Kept as a thin `@deprecated` shim over the private `parseInternal()` core, so it still works; **removal deferred to 5.0** (see below). +- [x] **Deprecate** the `getInstance()` singleton (recommend explicit instantiation — the static singleton carries process-global state and is pinned to the LEGACY preset). Kept working; **removal deferred to 5.0**. +- [x] Removed the deprecated `Parse::validateLocalPart(array)` extension point (deprecated in 3.9) — local-part validation is now a `private`, `ParseContext`-based method, and `validateDomainName()` is `private` too. They took the parser's internal accumulator and were never a supported extension point — validation is customized through `ParseOptions`. + +_v4.0 is deliberately **lean**: breaking API cleanup + internal modernization only (see [UPGRADE.md](UPGRADE.md)), so it ships fast and upgrades mechanically. The larger new features once slated here are additive and move to later minors; only the genuinely breaking one (group syntax) moves to 5.0. Internal modernization (`ParseContext` camelCase + readonly, the `ParserState` enum) is tracked under the Backlog below._ + +### v4.1 — Structured errors (the keystone) + +The highest-leverage post-4.0 work: it unlocks i18n, framework-native localization, and clean ports at once (see North Star above). Additive / non-breaking — `invalid_reason` keeps returning today's English strings by default. + +- [ ] Capture **named message parameters** at each error site and expose them as `ParsedEmailAddress::$messageParameters` (`array`; `[]` when valid). +- [ ] Introduce a `MessageProvider` interface with a built-in `EnglishMessageProvider` default that renders `invalid_reason` from `code + parameters`: + ```php + interface MessageProvider + { + /** @param array $parameters e.g. ['char' => ':'] or ['limit' => 64] */ + public function message(ParseErrorCode $code, array $parameters = [], ?string $locale = null): string; + } + ``` + **Named** (not positional) parameters — word order varies across languages. Frameworks re-render from `code + messageParameters` via their own translator; injecting a custom `MessageProvider` into `Parse` is the optional path for localized `invalid_reason` at the source. +- [ ] Restructure `testspec.yml` so `invalid_reason_code` (+ parameters) is the normative assertion and message text is non-normative — making the spec port-ready. + +### v4.2 — planned + +- [ ] DNS/MX validation via a `DnsValidator` callback interface. Additive and opt-in (off by default): the `Parse` constructor gains a parameter, and synchronous lookups change performance characteristics, so callers choose it explicitly. + +### v4.3 — planned + +- [ ] Confusable-against-a-target-list matching — compare the domain's Unicode skeleton against a caller-supplied brand/skeleton set (`Spoofchecker::areConfusable()`), following on from the v3.8 single-string check. Additive; deferred until the caller-provided target-list API is designed. +- [ ] **RFC 6854 group syntax** (`Group Name: addr1, addr2;`; empty groups `Name:;`) — parse the group construct (RFC 5322 §3.4 / RFC 6854) **additively**: group members flatten into `emailAddresses` unchanged (each gaining an optional `->group` name), plus a `ParseResult::groups()` typed view (`ParsedGroup { string $name; ParsedEmailAddress[] $addresses }`) for callers who want structure, including empty groups. Non-breaking, so it stays a minor — only a *structural* redesign that changed `emailAddresses`' type would force a major. + +### v5.0 — planned + +- [ ] Remove the deprecated `parse()` method (deprecated in 4.0). `parseSingle()` / `parseMultiple()` / `parseStream()` are the entry points; the private `parseInternal()` core stays. +- [ ] Remove the deprecated `Parse::getInstance()` singleton (deprecated in 4.0). Use `new Parse($logger, $options)`. +- [ ] Remove the deprecated `ParseOptions` pass-through getters (deprecated in 4.0). Read the `public readonly` properties instead. +- [ ] Remove the deprecated `Parse::setOptions()` (deprecated in 4.0) and make `Parse`'s `$options`/`$logger` `readonly` — a parser instance's configuration becomes immutable after construction, completing the immutability/DI direction begun in 4.0. -**New capabilities (breaking or late-binding):** -- [ ] DNS/MX validation via a `DnsValidator` callback interface — breaking because the `Parse` constructor grows, and synchronous lookups change performance characteristics. -- [ ] Group syntax (RFC 6854: `Group Name: addr1, addr2;`) — introduces a new output-container shape for grouped results. -- [ ] Confusable-against-a-target-list matching — compare the domain's Unicode skeleton against a caller-supplied brand/skeleton set (`Spoofchecker::areConfusable()`), following on from the v3.8 single-string check. Deferred until the caller-provided target list is designed. +_(RFC 6854 group syntax moved to 4.2/4.3 — it can be added additively, see above; only a structural redesign of `emailAddresses` would make it a 5.0 break.)_ ### Backlog (unversioned) +- [ ] **`ParseOptions` has a 27-parameter constructor** (principal-review finding). Usable today via named arguments + preset factories + `withX()` builders, but at the edge of maintainability. Group related flags into cohesive value objects (e.g. `LocalPartRules` / `DomainRules`) — breaking, so a v5.0 candidate. +- [ ] **`Parse` is a ~1,700-LOC god-class** (principal-review finding) carrying the state machine, all validation, IDN/punycode conversion, NFC normalization, and output assembly. Extract the validation and IDN/normalization concerns into collaborators; the per-state handler split (below) is the first step. - [ ] **`parse()` refactor & modernization follow-ups** (from review; non-blocking, each behavior-preserving and test-gated): - - [ ] Rename `ParseContext`'s accumulator fields `snake_case` → `camelCase` to match the codebase. Output-array keys stay `snake_case` (public API); only the internal properties change. Kept as-is during extraction so the diff was a pure move. - - [ ] **Encode `ParseContext`'s three concerns structurally.** The immutable input snapshot (`chars`/`len`/`emails`), the read-only hoisted config (`separators`, `bannedChars`, …), and the mutable per-address accumulator are all plain public fields today, so nothing stops a handler writing config. Promote the snapshot + config to `readonly` (constructor-promoted) so only the accumulator stays mutable — the clearest SOTA/correctness win, but it needs `parse()`'s construction reworked (the snapshot is currently assigned after `new`). - - [ ] **Consider a `ParserState: int` backed enum** in place of the 13 `STATE_*` int constants. Gives type-safety on `$ctx->state`/`$subState` and would likely retire the Psalm state-narrowing baseline entries. Gate on a benchmark: the dispatch is a hot loop, so measure enum-vs-int comparison/array-key overhead before committing (the no-regression constraint still applies). + - [x] Renamed `ParseContext`'s accumulator fields `snake_case` → `camelCase` to match the codebase. Output-array keys stay `snake_case` (public API, string literals in `addAddress()`); only the internal properties changed. + - [x] **Encoded `ParseContext`'s three concerns structurally.** The input snapshot (`chars`/`len`/`emails`) and hoisted config (`separators`, `bannedChars`, …) are now `public readonly` constructor-promoted properties, so only the per-address accumulator stays mutable — a handler can no longer write config. `parseInternal()`'s setup was reordered to build the config before constructing the context. + - [x] **Introduced a `ParserState: int` backed enum** (`src/ParserState.php`) in place of the 13 `Parse::STATE_*` int constants. `ParseContext::$state`/`$subState` are now typed `ParserState`, so the parser can never hold an out-of-range state (this also retires the old "misleading 0 default" caveat). Enum `===` is identity comparison, so no measurable hot-loop cost is expected; the perf no-regression constraint is verified by the CI **Benchmarks (vs base)** job (`bench:compare`, ≤1.5× base) — local benchmarking is unreliable in the dev sandbox (Xdebug + `opcache.enable_cli=0`). - [ ] Drop the `chars`/`len` double source of truth (loop locals vs context properties — kept for hot-loop locality; measure before changing). - [ ] Decompose the two remaining large methods — `handleStateAddress` (~209 lines; CFWS/`@`/non-atext already peeled off, the rest is inherent to the addr-spec sub-machine) and `addAddress` (~221 lines, pre-existing; splits into IP-literal detection, validation, and output-array assembly). Both diminishing-returns polish. - [ ] **Ecosystem bridges:** `mmucklo/email-parse-symfony` (`Constraint` + `ConstraintValidator`), `mmucklo/email-parse-laravel` (validation rule + service provider), PSR-14 `ParsedAddressEvent` for observability. diff --git a/UPGRADE.md b/UPGRADE.md index 55f89c7..c4c24b6 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,157 @@ # Upgrade Guide +## v3.x → v4.0 + +v4.0 is a **breaking-modernization** release. Parsing behavior is unchanged — no changes to parsing logic, error codes, or the output shape — so a valid address parses identically. The breaks are all API-surface cleanup: the long-deprecated mutating setters are gone, `ParseOptions` config is now fully immutable, two internal methods became `private`, `setLogger()` follows PSR-3 `LoggerAwareInterface`, and the typed entry points no longer route through `parse()`. Several public methods are newly deprecated: they still work, but each one now **emits a runtime `E_USER_DEPRECATED` notice** when called (see *Deprecated* below), so a test suite that promotes deprecations to failures will flag them. + +For most callers the upgrade is a mechanical find-and-replace. If you only ever call `parseSingle()` / `parseMultiple()` / `parseStream()` and configure options with the constructor or `withX()` builders, **no changes are required.** + +### Automated migration (Rector) + +The mechanical call-site changes can be auto-fixed with the [Rector](https://getrector.com) config shipped in the package. Install Rector if you don't have it, then run the config against your source: + +```bash +composer require --dev rector/rector +vendor/bin/rector process src --config vendor/mmucklo/email-parse/rector/upgrade-4.0.php --dry-run +``` + +Drop `--dry-run` to apply, then **review the diff and commit** (Rector never runs on its own — it's opt-in, and a dependency update will not modify your code). It rewrites: + +- `Parse::getInstance()` → `new Parse()` +- `$options->getBannedChars()` (and the other four pass-through getters, including nullsafe `$options?->getX()`) → the `public readonly` property read +- `$options->setBannedChars($v)` (and `setSeparators` / `setUseWhitespaceAsSeparator` / `setLengthLimits`) → `$options = $options->withX($v)` — **only where `$options` is provably the sole holder**: it was created in the same scope (`new ParseOptions(...)`, a `ParseOptions::rfc*()` preset, or a `withX()` chain) and has not yet been passed anywhere, copied, or captured. A `$this->options->setX($v)` call on the object's own property becomes `$this->options = $this->options->withX($v)`. + +Where the receiver is shared — a parameter, a `$parser->getOptions()` result, or a variable that was already handed to `new Parse(null, $o)` — a local reassignment would silently stop affecting the other holder, so Rector **leaves the call in place** (it fails loudly on 4.0, since the setter no longer exists) and inserts a `// TODO email-parse 4.0:` comment above it explaining the manual fix: build the configured `ParseOptions` where it is created and pass it in. + +It deliberately leaves the other **semantic** changes for you to do by hand (they can't be rewritten safely): the `parse()` → `parseSingle()`/`parseMultiple()` migration (the return *shape* changes from array to object), `setMaxLocalPartLength()` etc. (rebuild a `LengthLimits`), `setOptions()` → constructor, and chained `setLogger()`. Those are covered below. + +### Breaking Changes + +#### 1. `ParseOptions` mutating setters removed + +The seven setters that were `@deprecated` since v3.0 are removed. `ParseOptions` is now a fully immutable value object: every property is `readonly`, and you configure a new instance via the constructor or the `withX()` fluent builders. + +The one behavioral difference to watch: `withX()` returns a **new** instance, so you must **reassign** — the old setters mutated in place. + +| Removed setter | Replacement | +|---|---| +| `$o->setBannedChars($a)` | `$o = $o->withBannedChars($a)` | +| `$o->setSeparators($a)` | `$o = $o->withSeparators($a)` | +| `$o->setUseWhitespaceAsSeparator($b)` | `$o = $o->withUseWhitespaceAsSeparator($b)` | +| `$o->setLengthLimits($l)` | `$o = $o->withLengthLimits($l)` | +| `$o->setMaxLocalPartLength($n)` | `$o = $o->withLengthLimits(new LengthLimits($n, $o->getMaxTotalLength(), $o->getMaxDomainLabelLength()))` | +| `$o->setMaxTotalLength($n)` | `$o = $o->withLengthLimits(new LengthLimits($o->getMaxLocalPartLength(), $n, $o->getMaxDomainLabelLength()))` | +| `$o->setMaxDomainLabelLength($n)` | `$o = $o->withLengthLimits(new LengthLimits($o->getMaxLocalPartLength(), $o->getMaxTotalLength(), $n))` | + +```php +// Before (v3.x) +$options = new ParseOptions(); +$options->setBannedChars(['%', '!']); +$options->setSeparators([',', ';']); + +// After (v4.0) — reassign; each withX() returns a new instance +$options = (new ParseOptions()) + ->withBannedChars(['%', '!']) + ->withSeparators([',', ';']); +``` + +The state fields are now readable directly as `public readonly` properties (`$options->bannedChars`, `$options->separators`, `$options->lengthLimits`, …). The pass-through `getX()` accessors still work but are deprecated in favour of the properties (see *Deprecated #3*); `getMaxLocalPartLength()` / `getMaxTotalLength()` / `getMaxDomainLabelLength()` are unchanged. + +#### 2. `Parse::validateLocalPart()` and `validateDomainName()` are now `private` + +These took the parser's internal accumulator and were never a documented extension point. If you subclassed `Parse` to override either, move that logic to `ParseOptions` configuration (rule properties, or the `withLocalPartNormalizer()` callback). `validateLocalPart()`'s brief `array`-signature deprecation window in 3.9 is now closed. + +#### 3. `Parse::setLogger()` returns `void` + +`Parse` now implements `Psr\Log\LoggerAwareInterface`, so `setLogger()` returns `void` instead of `$this`. This is the standard PSR-3 pattern (DI containers can auto-inject the logger). Only affects you if you *chained* on it: + +```php +// Before (chained) +$parser->setLogger($logger)->parseSingle($email); + +// After — two statements +$parser->setLogger($logger); +$parser->parseSingle($email); + +// Or inject at construction (preferred) +$parser = new Parse($logger, $options); +``` + +#### 4. Subclass overrides of `parse()` no longer affect the typed methods + +In 3.x, `parseSingle()`, `parseMultiple()` and `parseStream()` all dispatched through the public `parse()`, so a subclass that overrode `parse()` (say, to pre-process input) sat on every code path. In 4.0 the parser core lives in a private `parseInternal()`; the typed methods call it directly and the deprecated `parse()` is a thin shim beside them. An override of `parse()` therefore only sees callers of `parse()` itself: + +``` +3.x parseSingle()/parseMultiple()/parseStream() ─► parse() ─► core +4.0 parseSingle()/parseMultiple()/parseStream() ─► parseInternal() ─► core + parse() [deprecated] ─► parseInternal() +``` + +Overriding the entry point was never a documented extension point. If you did it to rewrite input, do the rewrite before calling the parser; if you did it to post-process results, wrap the typed result instead. (`Parse` remains non-final so PSR-3 logger injection and decoration keep working.) + +### Deprecated (Still Functional) + +Everything in this section keeps working for the whole 4.x line and is removed in **5.0**. Each call now emits a runtime `E_USER_DEPRECATED` notice via `trigger_deprecation()` (from `symfony/deprecation-contracts`), so tools such as `symfony/phpunit-bridge` will list the exact call-sites; the shipped Rector config (above) fixes most of them. + +#### 1. `Parse::parse()` + +The polymorphic `$multiple`-boolean, array-returning method is deprecated in favor of the typed API: + +```php +// Before +$batch = $parser->parse($input, true); // ['success' => bool, 'reason' => ?string, 'email_addresses' => [...]] +$row = $parser->parse($input, false); // single address array + +// After +$result = $parser->parseMultiple($input); // ParseResult (typed) +$addr = $parser->parseSingle($input); // ParsedEmailAddress (typed) + +// Need the legacy array shape? ->toArray() reproduces it exactly, envelope included: +$batch = $parser->parseMultiple($input)->toArray(); // same success/reason/email_addresses keys +$row = $parser->parseSingle($input)->toArray(); +``` + +#### 2. `Parse::getInstance()` + +The default-options singleton is deprecated — it carries process-global state and is pinned to the LEGACY preset (permissive v2.x behavior). Instantiate explicitly, which also lets you pass a logger and custom options: + +```php +// Before +$parser = Parse::getInstance(); + +// After +$parser = new Parse(); // default options +$parser = new Parse(null, ParseOptions::rfc5322()); // configured +``` + +#### 3. `ParseOptions` pass-through getters + +Now that the state fields are `public readonly`, `getBannedChars()`, `getSeparators()`, `getUseWhitespaceAsSeparator()`, `getLengthLimits()`, and `getAllowedWhitespace()` just duplicate the properties and are deprecated — read the property instead. (`getMaxLocalPartLength()` etc. are **not** deprecated; they read into `$lengthLimits`.) + +```php +$options->getBannedChars(); // → $options->bannedChars +$options->getLengthLimits(); // → $options->lengthLimits +``` + +#### 4. `Parse::setOptions()` + +Deprecated — a parser's configuration should be fixed for the life of the instance (mutating it on a shared parser is a footgun). Pass options to the constructor instead: + +```php +// Before +$parser->setOptions(ParseOptions::rfc5322()); +// After +$parser = new Parse(null, ParseOptions::rfc5322()); +``` + +### Internal Changes (No Action Needed) + +These are implementation details behind `@internal` and do not affect callers: the `Parse::STATE_*` constants became a `ParserState` enum, and the internal `ParseContext` accumulator was modernized (camelCase fields, readonly input snapshot/config). They are listed only for completeness — if your code reached into these, it was relying on unsupported internals. + +### Minimum Requirements (Unchanged) + +PHP **8.1+**, with the `mbstring` and `intl` extensions. + ## v3.2 → v3.3 v3.3 is fully additive — no breaking changes, no behavior changes for existing callers. Everything listed here is opt-in. @@ -167,9 +319,9 @@ Recommended: match on the `invalid` boolean, not error text. A typed `ParseError Each parsed address now includes a `domain_ascii` field. It is `null` unless `ParseOptions::$includeDomainAscii` is `true` (the default in the `rfc6531()` preset only). Existing code that reads other fields is unaffected. ```php -$result = $parser->parse('user@bücher.de', false); -$result['domain']; // 'bücher.de' -$result['domain_ascii']; // 'xn--bcher-kva.de' (when includeDomainAscii=true), else null +$addr = $parser->parseSingle('user@bücher.de'); +$addr->domain; // 'bücher.de' +$addr->domainAscii; // 'xn--bcher-kva.de' (when includeDomainAscii=true), else null ``` #### New factory presets on `ParseOptions` diff --git a/composer.json b/composer.json index c4e96d3..9fd94eb 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,11 @@ "email": "mmucklo@gmail.com" } ], + "support": { + "issues": "https://github.com/mmucklo/email-parse/issues", + "source": "https://github.com/mmucklo/email-parse", + "docs": "https://github.com/mmucklo/email-parse/blob/master/UPGRADE.md" + }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.65", "phpstan/phpstan": "^2.0", @@ -29,13 +34,15 @@ "symfony/yaml": "^6.4|^7.2", "infection/infection": "^0.29.8", "phpbench/phpbench": "^1.4", - "vimeo/psalm": "^6.0" + "vimeo/psalm": "^6.0", + "rector/rector": "^2.0" }, "require": { "php": "^8.1", "ext-mbstring": "*", "psr/log": "^3.0", - "symfony/polyfill-intl-idn": "^1.31" + "symfony/polyfill-intl-idn": "^1.31", + "symfony/deprecation-contracts": "^3.0" }, "autoload": { "psr-4": { diff --git a/docs/cookbook.md b/docs/cookbook.md index f99a0b3..2110c71 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -203,14 +203,14 @@ foreach ($addresses as $raw) { } ``` -Prefer explicit instantiation; `Parse::getInstance()` (a singleton with default options) exists for convenience and backward compatibility. +Prefer explicit instantiation — `new Parse()`. `Parse::getInstance()` (a default-options singleton) is **deprecated** and will be removed in 5.0. -## The legacy array API +## The array shape -`parse()` returns the original array shape — `parse($input, multiple: false)` for one address, `true` for many. Typed objects expose the same data via `->toArray()`. +Need the original array shape rather than a value object? Call `->toArray()` on any `parseSingle()` / `parseMultiple()` result — it exposes the same fields. (The old array-returning `parse()` method is deprecated and will be removed in 5.0.) ```php -$arr = (new Parse())->parse('john@example.com', false); +$arr = (new Parse())->parseSingle('john@example.com')->toArray(); $arr['local_part']; // 'john' $arr['domain']; // 'example.com' $arr['invalid']; // false diff --git a/phpstan.neon b/phpstan.neon index 9cf31c4..d1029c8 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,6 +6,8 @@ parameters: paths: - src - tests + - rector excludePaths: + - tests/fixtures - vendor reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml index 966df8e..aea0da9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,5 @@ - + ./src/ diff --git a/psalm-baseline.xml b/psalm-baseline.xml index cf9501c..c67b56a 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,79 +1,2 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/psalm.xml b/psalm.xml index 9ca84db..cfc99a0 100644 --- a/psalm.xml +++ b/psalm.xml @@ -15,4 +15,16 @@ + + + + + + + diff --git a/rector/rules/GetInstanceToNewParseRector.php b/rector/rules/GetInstanceToNewParseRector.php new file mode 100644 index 0000000..311749e --- /dev/null +++ b/rector/rules/GetInstanceToNewParseRector.php @@ -0,0 +1,59 @@ +parseSingle($email);', + '(new Parse())->parseSingle($email);', + ), + ], + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [StaticCall::class]; + } + + public function refactor(Node $node): ?Node + { + /** @var StaticCall $node */ + if (!$this->isName($node->class, 'Email\Parse')) { + return null; + } + + if (!$this->isName($node->name, 'getInstance')) { + return null; + } + + return new New_(new FullyQualified('Email\Parse')); + } +} diff --git a/rector/rules/ParseOptionsGetterToPropertyRector.php b/rector/rules/ParseOptionsGetterToPropertyRector.php new file mode 100644 index 0000000..ff14db9 --- /dev/null +++ b/rector/rules/ParseOptionsGetterToPropertyRector.php @@ -0,0 +1,80 @@ +getX()`) become nullsafe property reads. + * + * The getMax*Length() helpers are intentionally excluded — they read into + * $lengthLimits and are not deprecated. + */ +final class ParseOptionsGetterToPropertyRector extends AbstractRector +{ + /** @var array getter method => readonly property */ + private const GETTER_TO_PROPERTY = [ + 'getBannedChars' => 'bannedChars', + 'getSeparators' => 'separators', + 'getUseWhitespaceAsSeparator' => 'useWhitespaceAsSeparator', + 'getLengthLimits' => 'lengthLimits', + 'getAllowedWhitespace' => 'allowedWhitespace', + ]; + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Read the ParseOptions public readonly property instead of its deprecated getter', + [ + new CodeSample( + '$options->getBannedChars();', + '$options->bannedChars;', + ), + ], + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [MethodCall::class, NullsafeMethodCall::class]; + } + + public function refactor(Node $node): ?Node + { + /** @var MethodCall|NullsafeMethodCall $node */ + if ($node->isFirstClassCallable() || $node->getArgs() !== []) { + return null; + } + + $method = $this->getName($node->name); + if ($method === null || !isset(self::GETTER_TO_PROPERTY[$method])) { + return null; + } + + if (!$this->isObjectType($node->var, new ObjectType('Email\ParseOptions'))) { + return null; + } + + $property = self::GETTER_TO_PROPERTY[$method]; + + return $node instanceof NullsafeMethodCall + ? new NullsafePropertyFetch($node->var, $property) + : new PropertyFetch($node->var, $property); + } +} diff --git a/rector/rules/ParseOptionsSetterToWithRector.php b/rector/rules/ParseOptionsSetterToWithRector.php new file mode 100644 index 0000000..3647e22 --- /dev/null +++ b/rector/rules/ParseOptionsSetterToWithRector.php @@ -0,0 +1,288 @@ +withX(...)` only reproduces that when `$o` is the sole + * holder. The rule therefore tracks ownership per function scope, in source + * order: a variable is OWNED once it is assigned from `new ParseOptions(...)`, + * a `ParseOptions::rfc*()` preset, or a `withX()` chain, and STOPS being owned + * the moment it escapes — passed as an argument (e.g. `new Parse(null, $o)`), + * copied to another variable, captured by a closure, or reassigned from an + * unknown source such as `$parser->getOptions()`. Parameters are never owned. + * + * owned receiver $o->setX($v) => $o = $o->withX($v) + * own property $this->opts->setX($v) => $this->opts = $this->opts->withX($v) + * anything else left as-is (it fails loudly on 4.0, the setter no longer + * exists) and annotated with a TODO comment explaining the + * manual migration, so the site stays visible in the diff. + * + * setMax*Length() is intentionally excluded: its replacement rebuilds a + * LengthLimits from the other two limits, which needs human context. + */ +final class ParseOptionsSetterToWithRector extends AbstractRector +{ + private const OPTIONS_CLASS = 'Email\ParseOptions'; + + /** Attribute set on each setter-call Expression by the ownership pre-pass. */ + private const OWNED_ATTR = 'emailParse.ownedReceiver'; + + private const TODO_MARKER = 'TODO email-parse 4.0:'; + + /** @var array mutating setter => immutable builder */ + private const SETTER_TO_WITH = [ + 'setBannedChars' => 'withBannedChars', + 'setSeparators' => 'withSeparators', + 'setUseWhitespaceAsSeparator' => 'withUseWhitespaceAsSeparator', + 'setLengthLimits' => 'withLengthLimits', + ]; + + /** File path the ownership pre-pass last ran for. */ + private ?string $taggedFile = null; + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition( + 'Replace a removed ParseOptions setter with its withX() builder where the receiver is locally owned; annotate aliased receivers for manual migration', + [ + new CodeSample( + <<<'PHP' + $options = new ParseOptions(); + $options->setBannedChars($chars); + PHP, + <<<'PHP' + $options = new ParseOptions(); + $options = $options->withBannedChars($chars); + PHP, + ), + ], + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [Expression::class]; + } + + public function refactor(Node $node): ?Node + { + /** @var Expression $node */ + $call = $node->expr; + if (!$call instanceof MethodCall && !$call instanceof NullsafeMethodCall) { + return null; + } + + $method = $this->getName($call->name); + if ($method === null || !isset(self::SETTER_TO_WITH[$method])) { + return null; + } + + if (!$this->isObjectType($call->var, new ObjectType(self::OPTIONS_CLASS))) { + return null; + } + + $this->tagOwnershipOnce(); + + if ($node->getAttribute(self::OWNED_ATTR) === true) { + $with = new MethodCall($call->var, self::SETTER_TO_WITH[$method], $call->getArgs()); + $node->expr = new Assign($call->var, $with); + + return $node; + } + + return $this->annotateForManualMigration($node, $method); + } + + /** + * Walk the whole file once, in source order, and tag every setter-call + * statement with whether its receiver is owned at that point. + */ + private function tagOwnershipOnce(): void + { + $path = $this->file->getFilePath(); + if ($this->taggedFile === $path) { + return; + } + $this->taggedFile = $path; + + $rule = $this; + $visitor = new class ($rule) extends NodeVisitorAbstract { + /** @var list, params: array}> */ + private array $scopes = []; + + public function __construct(private readonly ParseOptionsSetterToWithRector $rule) + { + $this->scopes[] = ['owned' => [], 'params' => []]; + } + + public function enterNode(Node $node): ?int + { + if ($node instanceof ClassMethod || $node instanceof Function_ || $node instanceof Closure) { + $params = []; + foreach ($node->params as $param) { + if ($param->var instanceof Variable && \is_string($param->var->name)) { + $params[$param->var->name] = true; + } + } + $this->scopes[] = ['owned' => [], 'params' => $params]; + + return null; + } + if ($node instanceof Class_) { + $this->scopes[] = ['owned' => [], 'params' => []]; + + return null; + } + + // Escapes: once another holder can observe the instance, a local + // reassignment no longer reproduces the 3.x shared mutation. + if ($node instanceof Arg && $node->value instanceof Variable) { + $this->disown($node->value); + } + if ($node instanceof Assign && $node->expr instanceof Variable) { + $this->disown($node->expr); + } + + // Tag setter-call statements with the ownership state *before* them. + if ($node instanceof Expression) { + $call = $node->expr; + if ($call instanceof MethodCall || $call instanceof NullsafeMethodCall) { + $node->setAttribute( + ParseOptionsSetterToWithRector::ownedAttr(), + $this->isOwnedReceiver($call->var), + ); + } + } + + return null; + } + + public function leaveNode(Node $node): ?int + { + if ($node instanceof Assign && $node->var instanceof Variable && \is_string($node->var->name)) { + if ($this->rule->isOwningExpression($node->expr)) { + $this->scopes[\count($this->scopes) - 1]['owned'][$node->var->name] = true; + } else { + $this->disown($node->var); + } + } + if ($node instanceof ClassMethod || $node instanceof Function_ || $node instanceof Closure || $node instanceof Class_) { + array_pop($this->scopes); + } + + return null; + } + + private function isOwnedReceiver(Expr $receiver): bool + { + if ($receiver instanceof PropertyFetch) { + // Only the object's own property: `$this->options = $this->options->withX()`. + return $receiver->var instanceof Variable && $receiver->var->name === 'this'; + } + if (!$receiver instanceof Variable || !\is_string($receiver->name)) { + return false; + } + $scope = $this->scopes[\count($this->scopes) - 1]; + + return isset($scope['owned'][$receiver->name]) && !isset($scope['params'][$receiver->name]); + } + + private function disown(Variable $variable): void + { + if (\is_string($variable->name)) { + unset($this->scopes[\count($this->scopes) - 1]['owned'][$variable->name]); + } + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($this->file->getNewStmts()); + } + + /** + * Whether an expression produces a fresh, unshared ParseOptions instance. + * + * @internal Called from the ownership visitor. + */ + public function isOwningExpression(Expr $expr): bool + { + if ($expr instanceof New_) { + return $this->isName($expr->class, self::OPTIONS_CLASS); + } + if ($expr instanceof StaticCall) { + return $this->isName($expr->class, self::OPTIONS_CLASS); + } + if ($expr instanceof MethodCall) { + $name = $this->getName($expr->name); + + return $name !== null + && str_starts_with($name, 'with') + && $this->isObjectType($expr->var, new ObjectType(self::OPTIONS_CLASS)); + } + + return false; + } + + /** @internal Called from the ownership visitor. */ + public static function ownedAttr(): string + { + return self::OWNED_ATTR; + } + + private function annotateForManualMigration(Expression $stmt, string $setter): ?Expression + { + $comments = $stmt->getAttribute(AttributeKey::COMMENTS) ?? []; + foreach ($comments as $comment) { + if ($comment instanceof Comment && str_contains($comment->getText(), self::TODO_MARKER)) { + return null; // already annotated on a previous run + } + } + + $with = self::SETTER_TO_WITH[$setter]; + $comments[] = new Comment(sprintf( + "// %s %s() was removed and this ParseOptions is not owned here (a parameter,\n" + . "// a ->getOptions() result, or already shared). ParseOptions is immutable: configure it where\n" + . "// it is created, e.g. new Parse(\$logger, ParseOptions::rfc5322()->%s(...)). See UPGRADE.md.", + self::TODO_MARKER, + $setter, + $with, + )); + $stmt->setAttribute(AttributeKey::COMMENTS, $comments); + + return $stmt; + } +} diff --git a/rector/upgrade-4.0.php b/rector/upgrade-4.0.php new file mode 100644 index 0000000..4535ea5 --- /dev/null +++ b/rector/upgrade-4.0.php @@ -0,0 +1,37 @@ + 4.0. + * + * Auto-fixes the mechanical call-site changes. Run it against YOUR source: + * + * vendor/bin/rector process src --config vendor/mmucklo/email-parse/rector/upgrade-4.0.php --dry-run + * + * (drop --dry-run to apply, then review the diff and commit). + * + * Covered: + * - Parse::getInstance() -> new Parse() + * - ParseOptions::getX() -> ParseOptions readonly property read + * - ParseOptions::setX($v) -> $o = $o->withX($v) (banned/separators/whitespace/lengthLimits) + * + * NOT covered (semantic changes — migrate by hand, see UPGRADE.md): + * - parse($x, true|false) -> parseMultiple()/parseSingle() (return SHAPE changes: array -> object) + * - setMaxLocalPartLength() etc. -> withLengthLimits(new LengthLimits(...)) (needs the other two limits) + * - Parse::setOptions() -> constructor injection + * - Parse::setLogger()->chain(...) -> split (setLogger() now returns void) + */ + +use Rector\Config\RectorConfig; + +require_once __DIR__ . '/rules/GetInstanceToNewParseRector.php'; +require_once __DIR__ . '/rules/ParseOptionsGetterToPropertyRector.php'; +require_once __DIR__ . '/rules/ParseOptionsSetterToWithRector.php'; + +return RectorConfig::configure() + ->withRules([ + \Email\Rector\GetInstanceToNewParseRector::class, + \Email\Rector\ParseOptionsGetterToPropertyRector::class, + \Email\Rector\ParseOptionsSetterToWithRector::class, + ]); diff --git a/src/Parse.php b/src/Parse.php index e205782..a4da9e8 100644 --- a/src/Parse.php +++ b/src/Parse.php @@ -3,38 +3,20 @@ namespace Email; use Email\ParseErrorCode as Err; +use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; /** * Class Parse. */ -class Parse +class Parse implements LoggerAwareInterface { - // Constants for the state-machine of the parser - private const STATE_TRIM = 0; - private const STATE_QUOTE = 1; - private const STATE_ADDRESS = 2; - private const STATE_COMMENT = 3; - private const STATE_NAME = 4; - private const STATE_LOCAL_PART = 5; - private const STATE_DOMAIN = 6; - private const STATE_AFTER_DOMAIN = 7; - private const STATE_SQUARE_BRACKET = 8; - private const STATE_SKIP_AHEAD = 9; - private const STATE_END_ADDRESS = 10; - private const STATE_START = 11; + // The state-machine states are the {@see ParserState} enum (formerly + // Parse::STATE_* constants). /** The full set of whitespace characters, as a lookup map (RFC 5234 WSP + CR/LF). */ private const WHITESPACE = [' ' => true, "\t" => true, "\r" => true, "\n" => true]; - /** - * Absorbs the obsolete source-route prefix inside angle-addr - * (RFC 5322 §4.4 obs-route: `"<" obs-domain-list ":" addr-spec ">"`). - * Consumes characters from the leading `@` up to the `:` terminator, - * then resumes normal addr-spec parsing. - */ - private const STATE_OBS_ROUTE = 12; - /** * @var ?Parse */ @@ -54,12 +36,18 @@ class Parse private ?\Spoofchecker $spoofchecker = null; /** - * Allow Parse to be instantiated as a singleton. + * Return a shared singleton instance configured with default options. + * + * @deprecated 4.0 Prefer explicit instantiation — `new Parse($logger, $options)`. + * The singleton carries process-global state and cannot take custom + * options; it will be removed in 5.0. * * @return Parse The instance */ public static function getInstance(): Parse { + trigger_deprecation('mmucklo/email-parse', '4.0', 'Parse::getInstance() is deprecated, use "new Parse($logger, $options)" instead. It is removed in 5.0.'); + if (!self::$instance) { return self::$instance = new self(); } @@ -82,19 +70,23 @@ public function __construct( } /** - * Allows for post-construct injection of a logger. - * - * @param LoggerInterface $logger PSR-3 compliant logger + * Inject a PSR-3 logger post-construction, per {@see LoggerAwareInterface}. + * (A logger may also be passed to the constructor.) */ - public function setLogger(LoggerInterface $logger): Parse + public function setLogger(LoggerInterface $logger): void { $this->logger = $logger; - - return $this; } + /** + * @deprecated 4.0 Pass options to the constructor — a parser's configuration + * should be immutable for the life of the instance; mutating it on + * a shared parser is a footgun. Removed in 5.0. + */ public function setOptions(ParseOptions $options): Parse { + trigger_deprecation('mmucklo/email-parse', '4.0', 'Parse::setOptions() is deprecated, pass options to the constructor instead — a parser\'s configuration should be immutable. It is removed in 5.0.'); + $this->options = $options; return $this; @@ -160,6 +152,12 @@ private function validateIpGlobalRange(string $ip, int $ipType): bool /** * Parses a list of 1 to n email addresses separated by space or comma. * + * @deprecated 4.0 Use {@see parseSingle()} for one address or + * {@see parseMultiple()} for a list — both return typed value + * objects (or {@see parseStream()} for large batches). This + * polymorphic array-returning method is retained for backward + * compatibility and will be removed in 5.0. + * * Compliance level is controlled by the ParseOptions passed to the constructor: * - ParseOptions::rfc5321() — RFC 5321 Mailbox (strict ASCII, SMTP-compatible) * - ParseOptions::rfc6531() — RFC 6531/6532 (full UTF-8, NFC normalization) @@ -215,28 +213,32 @@ private function validateIpGlobalRange(string $ip, int $ipType): bool * 'invalid_reason_code' => ParseErrorCode|null, 'comments' => array) * endif; */ + public function parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array + { + trigger_deprecation('mmucklo/email-parse', '4.0', 'Parse::parse() is deprecated, use parseSingle()/parseMultiple() (or parseStream()) and ->toArray() if you need the array shape. It is removed in 5.0.'); + + return $this->parseInternal($emails, $multiple, $encoding); + } + /** - * Parse a single email address and return a typed value object. - * - * Recommended over {@see parse()} when you want IDE autocomplete and - * static-analysis friendly access to the parsed fields. + * Parse a single email address and return a typed {@see ParsedEmailAddress} + * value object with IDE autocomplete and static-analysis-friendly fields. */ public function parseSingle(string $email, string $encoding = 'UTF-8'): ParsedEmailAddress { - return ParsedEmailAddress::fromArray($this->parse($email, false, $encoding)); + return ParsedEmailAddress::fromArray($this->parseInternal($email, false, $encoding)); } /** - * Parse a list of email addresses and return a typed result. + * Parse a list of email addresses and return a typed {@see ParseResult}. * - * Recommended over {@see parse()} in multi-address mode for the same reasons as - * {@see parseSingle()}. Separator handling and per-address rules are configured - * via {@see ParseOptions}. + * Separator handling and per-address rules are configured via + * {@see ParseOptions}. */ public function parseMultiple(string $emails, string $encoding = 'UTF-8'): ParseResult { /** @var array{success: bool, reason: ?string, email_addresses: array>} $raw */ - $raw = $this->parse($emails, true, $encoding); + $raw = $this->parseInternal($emails, true, $encoding); return ParseResult::fromArray($raw); } @@ -263,24 +265,24 @@ public function parseMultiple(string $emails, string $encoding = 'UTF-8'): Parse public function parseStream(iterable $input, string $encoding = 'UTF-8'): \Generator { foreach ($input as $emails) { - $result = $this->parse((string) $emails, true, $encoding); + $result = $this->parseInternal((string) $emails, true, $encoding); foreach ($result['email_addresses'] as $address) { yield ParsedEmailAddress::fromArray($address); } } } - public function parse(string $emails, bool $multiple = true, string $encoding = 'UTF-8'): array + /** + * Core parser shared by parseSingle()/parseMultiple()/parseStream() and the + * deprecated {@see parse()} shim. Returns the raw array documented on parse(): + * the multi-address envelope when $multiple is true, or a single-address hash + * otherwise. + * + * @return array + */ + private function parseInternal(string $emails, bool $multiple, string $encoding): array { $emailAddresses = []; - - // Per-parse accumulator. A fresh instance (never an instance property) - // keeps parse() reentrant across a localPartNormalizer callback. The - // constructor requires the initial state (STATE_TRIM) and sub-state - // (STATE_START, for when we reach the xyz@somewhere.com address itself), - // so the context is fully initialized before its first use. - $ctx = new ParseContext(self::STATE_TRIM, self::STATE_START); - $success = true; $reason = null; @@ -297,71 +299,84 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // Whitespace treated as insignificant (folding/separators; trimmable). In // single-address mode CR and LF are excluded — a lone addr-spec has no line // endings — unless trimSingleAddressWhitespace opts back into liberal trimming. - $allowedWhitespace = $this->options->getAllowedWhitespace(); + $allowedWhitespace = $this->options->allowedWhitespace; if (!$multiple && !$this->options->trimSingleAddressWhitespace) { unset($allowedWhitespace["\r"], $allowedWhitespace["\n"]); } - // Publish the input snapshot and hoisted config onto the context so the - // per-state handlers can read them without long parameter lists. $chars - // and $len are also kept as locals below for the tight loop counter. - $ctx->chars = $chars; - $ctx->len = $len; - $ctx->multiple = $multiple; - $ctx->emails = $emails; - $ctx->separators = $this->options->getSeparators(); - $ctx->bannedChars = $this->options->getBannedChars(); - $ctx->useWhitespaceAsSeparator = $this->options->getUseWhitespaceAsSeparator(); - $ctx->allowedWhitespace = $allowedWhitespace; + // Per-parse accumulator. A fresh instance (never an instance property) + // keeps the parser reentrant across a localPartNormalizer callback. The + // constructor takes the initial state (ParserState::TRIM) and sub-state + // (ParserState::START) plus the immutable input snapshot + hoisted config, which + // it exposes as readonly properties — so no handler can mutate config, and + // the context is fully initialized before first use. $chars/$len are also + // kept as locals below for the tight loop counter. + $ctx = new ParseContext( + ParserState::TRIM, + ParserState::START, + $chars, + $len, + $multiple, + $emails, + $this->options->separators, + $this->options->bannedChars, + $this->options->useWhitespaceAsSeparator, + $allowedWhitespace, + ); $curChar = null; for ($i = 0; $i < $len; ++$i) { $prevChar = $curChar; // Previous Character $curChar = $chars[$i]; // Current Character switch ($ctx->state) { - case self::STATE_SKIP_AHEAD: + case ParserState::SKIP_AHEAD: $this->handleStateSkipAhead($ctx, $curChar); break; - /* @noinspection PhpMissingBreakStatementInspection — STATE_TRIM falls through to STATE_ADDRESS */ - case self::STATE_TRIM: + /* @noinspection PhpMissingBreakStatementInspection — ParserState::TRIM falls through to ParserState::ADDRESS */ + case ParserState::TRIM: if (!$this->handleStateTrim($ctx, $curChar)) { break; } - // no break — a plain character falls through to STATE_ADDRESS - case self::STATE_ADDRESS: + // no break — a plain character falls through to ParserState::ADDRESS + case ParserState::ADDRESS: $this->handleStateAddress($ctx, $curChar, $prevChar, $i); break; - case self::STATE_SQUARE_BRACKET: + case ParserState::SQUARE_BRACKET: $this->handleStateSquareBracket($ctx, $curChar); break; - case self::STATE_OBS_ROUTE: + case ParserState::OBS_ROUTE: $this->handleStateObsRoute($ctx, $curChar); break; - case self::STATE_QUOTE: + case ParserState::QUOTE: $this->handleStateQuote($ctx, $curChar, $i); break; - case self::STATE_COMMENT: + case ParserState::COMMENT: $this->handleStateComment($ctx, $curChar); break; + // @codeCoverageIgnoreStart default: - // Shouldn't ever get here - what is $ctx->state? - $ctx->original_address .= $curChar; + // Defensive: the outer loop only ever assigns the seven states + // handled above; the other ParserState cases are sub-states that + // belong in $ctx->subState. Reaching here means a handler mixed + // the two up, so fail the address rather than loop silently. + $ctx->originalAddress .= $curChar; $ctx->invalid = true; - $ctx->invalid_reason = 'Error during parsing'; - $ctx->invalid_reason_code = Err::ParseError; - $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$ctx->state}\n\$subState: {$ctx->subState}\n\$i: {$i}\n\$curChar: {$curChar}"); + $ctx->invalidReason = 'Error during parsing'; + $ctx->invalidReasonCode = Err::ParseError; + $this->log('error', "Email\\Parse->parse - error during parsing - \$state: {$ctx->state->name}\n\$subState: {$ctx->subState->name}\n\$i: {$i}\n\$curChar: {$curChar}"); break; + // @codeCoverageIgnoreEnd } - // if there's a $ctx->original_address and the state is set to STATE_END_ADDRESS - if (self::STATE_END_ADDRESS == $ctx->state && strlen($ctx->original_address) > 0) { + // if there's a $ctx->originalAddress and the state is set to ParserState::END_ADDRESS + if (ParserState::END_ADDRESS == $ctx->state && strlen($ctx->originalAddress) > 0) { $invalid = $this->addAddress( $emailAddresses, $ctx, @@ -378,38 +393,38 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } // Reset all per-address state before the next address in the batch. - $ctx->resetAddress(self::STATE_TRIM, self::STATE_START); + $ctx->resetAddress(ParserState::TRIM, ParserState::START); } - // Fire once, on the transition into invalid: STATE_SKIP_AHEAD does not clear + // Fire once, on the transition into invalid: ParserState::SKIP_AHEAD does not clear // the flag, so without the state guard this block would re-run every remaining // character — and interpolating the full $emails / original_address each time // (even under a NullLogger, the argument is still built) makes malformed input // O(n^2). See the DoS regression benchmark. - if ($ctx->invalid && self::STATE_SKIP_AHEAD !== $ctx->state) { - $this->log('debug', "Email\\Parse->parse - invalid - {$ctx->invalid_reason}\n\$ctx->original_address {$ctx->original_address}\n\$emails: {$emails}"); - $ctx->state = self::STATE_SKIP_AHEAD; + if ($ctx->invalid && ParserState::SKIP_AHEAD !== $ctx->state) { + $this->log('debug', "Email\\Parse->parse - invalid - {$ctx->invalidReason}\n\$ctx->originalAddress {$ctx->originalAddress}\n\$emails: {$emails}"); + $ctx->state = ParserState::SKIP_AHEAD; } } // End-of-input reached still inside a delimiter (quote, comment, domain // literal, or obs-route) — the construct was never closed. Keyed on the // parser state rather than quote_temp, since bracket/comment content is - // buffered elsewhere (a closed delimiter always returns to STATE_ADDRESS). - if (!$ctx->invalid && in_array($ctx->state, [self::STATE_QUOTE, self::STATE_COMMENT, self::STATE_SQUARE_BRACKET, self::STATE_OBS_ROUTE], true)) { + // buffered elsewhere (a closed delimiter always returns to ParserState::ADDRESS). + if (!$ctx->invalid && in_array($ctx->state, [ParserState::QUOTE, ParserState::COMMENT, ParserState::SQUARE_BRACKET, ParserState::OBS_ROUTE], true)) { $ctx->invalid = true; - [$ctx->invalid_reason, $ctx->invalid_reason_code] = match ($ctx->state) { - self::STATE_QUOTE => ['No ending quote: \'"\'', Err::UnterminatedQuote], - self::STATE_COMMENT => ['No closing parenthesis: \')\'', Err::UnterminatedComment], - self::STATE_SQUARE_BRACKET => ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket], - self::STATE_OBS_ROUTE => ['Incomplete obs-route: missing colon before end of input', Err::IncompleteAddress], + [$ctx->invalidReason, $ctx->invalidReasonCode] = match ($ctx->state) { + ParserState::QUOTE => ['No ending quote: \'"\'', Err::UnterminatedQuote], + ParserState::COMMENT => ['No closing parenthesis: \')\'', Err::UnterminatedComment], + ParserState::SQUARE_BRACKET => ['No closing square bracket: \']\'', Err::UnterminatedSquareBracket], + ParserState::OBS_ROUTE => ['Incomplete obs-route: missing colon before end of input', Err::IncompleteAddress], }; } - if (!$ctx->invalid && ($ctx->address_temp || $ctx->quote_temp)) { - $this->log('error', "Email\\Parse->parse - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->address_temp: {$ctx->address_temp}\n\$ctx->quote_temp: {$ctx->quote_temp}\nEmails: {$emails}"); + if (!$ctx->invalid && ($ctx->addressTemp || $ctx->quoteTemp)) { + $this->log('error', "Email\\Parse->parse - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->addressTemp: {$ctx->addressTemp}\n\$ctx->quoteTemp: {$ctx->quoteTemp}\nEmails: {$emails}"); $ctx->invalid = true; - $ctx->invalid_reason = 'Incomplete address'; - $ctx->invalid_reason_code = Err::IncompleteAddress; + $ctx->invalidReason = 'Incomplete address'; + $ctx->invalidReasonCode = Err::IncompleteAddress; if (!$success) { $reason = 'Invalid email addresses'; } else { @@ -421,20 +436,20 @@ public function parse(string $emails, bool $multiple = true, string $encoding = // Did we find no email addresses at all? An empty local-part only counts as // "no address" when it is unquoted; `""@domain` is a legitimately-empty quoted // local-part whose acceptance is decided later by rejectEmptyQuotedLocalPart. - if (!$ctx->invalid && !count($emailAddresses) && (!$ctx->original_address || (!$ctx->local_part_parsed && !$ctx->local_part_quoted))) { + if (!$ctx->invalid && !count($emailAddresses) && (!$ctx->originalAddress || (!$ctx->localPartParsed && !$ctx->localPartQuoted))) { $success = false; $reason = 'No email addresses found'; if (!$multiple) { $ctx->invalid = true; - $ctx->invalid_reason = 'No email address found'; - $ctx->invalid_reason_code = Err::IncompleteAddress; + $ctx->invalidReason = 'No email address found'; + $ctx->invalidReasonCode = Err::IncompleteAddress; $this->addAddress( $emailAddresses, $ctx, $i ); } - } elseif ($ctx->original_address) { + } elseif ($ctx->originalAddress) { $invalid = $this->addAddress( $emailAddresses, $ctx, @@ -457,108 +472,108 @@ public function parse(string $emails, bool $multiple = true, string $encoding = } /** - * STATE_SKIP_AHEAD: a bad address was seen; discard characters until the next - * separator, then let the main loop transition to STATE_END_ADDRESS. + * ParserState::SKIP_AHEAD: a bad address was seen; discard characters until the next + * separator, then let the main loop transition to ParserState::END_ADDRESS. */ private function handleStateSkipAhead(ParseContext $ctx, string $curChar): void { $isWhitespaceSeparator = $ctx->useWhitespaceAsSeparator && isset($ctx->allowedWhitespace[$curChar]); if ($ctx->multiple && ($isWhitespaceSeparator || isset($ctx->separators[$curChar]))) { - $ctx->state = self::STATE_END_ADDRESS; + $ctx->state = ParserState::END_ADDRESS; } else { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; } } /** - * STATE_TRIM: skip leading whitespace and detect a leading quote/comment. + * ParserState::TRIM: skip leading whitespace and detect a leading quote/comment. * * @return bool true when the character is ordinary and parsing should fall - * through to STATE_ADDRESS; false when it was consumed here + * through to ParserState::ADDRESS; false when it was consumed here */ private function handleStateTrim(ParseContext $ctx, string $curChar): bool { if (isset($ctx->allowedWhitespace[$curChar])) { return false; } - $ctx->state = self::STATE_ADDRESS; + $ctx->state = ParserState::ADDRESS; if ('"' == $curChar) { - $ctx->original_address .= $curChar; - $ctx->state = self::STATE_QUOTE; + $ctx->originalAddress .= $curChar; + $ctx->state = ParserState::QUOTE; return false; } if ('(' == $curChar) { - $ctx->original_address .= $curChar; - $ctx->state = self::STATE_COMMENT; + $ctx->originalAddress .= $curChar; + $ctx->state = ParserState::COMMENT; // A leading comment opens at nest level 1 (matches the - // STATE_ADDRESS entry); without this an unbalanced nested + // ParserState::ADDRESS entry); without this an unbalanced nested // comment like "((x)" would appear closed after one ")". $ctx->commentNestLevel = 1; return false; } - // Non-whitespace, non-special char: fall through to STATE_ADDRESS processing. + // Non-whitespace, non-special char: fall through to ParserState::ADDRESS processing. return true; } /** - * STATE_ADDRESS: the main dispatch on the current character. Small structural + * ParserState::ADDRESS: the main dispatch on the current character. Small structural * branches are handled inline; the heavier ones (CFWS, '@', '.', atext and * non-atext runs) delegate to dedicated helpers below. */ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string $prevChar, int $i): void { if (!isset($ctx->separators[$curChar]) || !$ctx->multiple) { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; } - if ($ctx->after_closing_quote) { - $ctx->after_closing_quote = false; + if ($ctx->afterClosingQuote) { + $ctx->afterClosingQuote = false; // RFC 5322 §3.2.4: a quoted-string is a whole word. Only a dot // (obs word.word), '@', angle brackets, CFWS, or a separator may // follow it — atext or a second quote directly abutting it is invalid. if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { $ctx->invalid = true; - $ctx->invalid_reason = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; - $ctx->invalid_reason_code = Err::AtextAfterQuotedString; + $ctx->invalidReason = 'A quoted string in the local part must be followed by a dot, "@", or the end — text or a second quote cannot immediately follow it'; + $ctx->invalidReasonCode = Err::AtextAfterQuotedString; } } - if ($ctx->comment_after_local_atext) { - $ctx->comment_after_local_atext = false; + if ($ctx->commentAfterLocalAtext) { + $ctx->commentAfterLocalAtext = false; // atext or a second quoted-string resuming the word after a comment. // Defer the verdict: it is only an error if this turns out to be an // addr-spec local part (resolved at '@'); in a display-name phrase // "word CFWS word" is legal and is cleared at '<'. if ('"' === $curChar || $curChar > "\x7f" || preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { - $ctx->local_atom_split_by_comment = true; + $ctx->localAtomSplitByComment = true; } } if ('(' == $curChar) { // Handle comment - $ctx->state = self::STATE_COMMENT; + $ctx->state = ParserState::COMMENT; $ctx->commentNestLevel = 1; return; } elseif (isset($ctx->separators[$curChar])) { // Handle separator (comma, semicolon, etc.) - if ($ctx->multiple && (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState)) { + if ($ctx->multiple && (ParserState::DOMAIN == $ctx->subState || ParserState::AFTER_DOMAIN == $ctx->subState)) { // If we're already in the domain part, this should be the end of the address - $ctx->state = self::STATE_END_ADDRESS; + $ctx->state = ParserState::END_ADDRESS; return; } else { $ctx->invalid = true; if ($ctx->multiple || ($i + 5) >= $ctx->len) { - $ctx->invalid_reason = 'Misplaced separator or missing "@" symbol'; - $ctx->invalid_reason_code = Err::MisplacedSeparator; + $ctx->invalidReason = 'Misplaced separator or missing "@" symbol'; + $ctx->invalidReasonCode = Err::MisplacedSeparator; } else { - $ctx->invalid_reason = 'Separator not permitted - only one email address allowed'; - $ctx->invalid_reason_code = Err::SeparatorNotPermitted; + $ctx->invalidReason = 'Separator not permitted - only one email address allowed'; + $ctx->invalidReasonCode = Err::SeparatorNotPermitted; } } } elseif (isset($ctx->allowedWhitespace[$curChar])) { @@ -567,48 +582,48 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string } } elseif ('<' == $curChar) { // Start of the local part - if (self::STATE_LOCAL_PART == $ctx->subState || self::STATE_DOMAIN == $ctx->subState) { + if (ParserState::LOCAL_PART == $ctx->subState || ParserState::DOMAIN == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; - $ctx->invalid_reason_code = Err::MultipleOpeningAngle; + $ctx->invalidReason = 'Email address contains multiple opening "<" (either a typo or multiple emails that need to be separated by a comma or space)'; + $ctx->invalidReasonCode = Err::MultipleOpeningAngle; } else { // Here should be the start of the local part for sure everything else then is part of the name - $ctx->subState = self::STATE_LOCAL_PART; - $ctx->special_char_in_substate = null; - $ctx->in_angle_addr = true; + $ctx->subState = ParserState::LOCAL_PART; + $ctx->specialCharInSubstate = null; + $ctx->inAngleAddr = true; // Any quote before `<` was the display name, not the local part; // clear the quoted flag the closing-quote handler set so the real // local-part inside the angle-addr starts unquoted. Likewise any // comment before `<` sat in the display-name phrase (legal there), // not an addr-spec local part — clear the deferred split marker. - $ctx->local_part_quoted = false; - $ctx->local_atom_split_by_comment = false; + $ctx->localPartQuoted = false; + $ctx->localAtomSplitByComment = false; $this->handleQuote($ctx); } } elseif ('>' == $curChar) { - // Should be the end of the domain part. Accept STATE_DOMAIN - // (normal dot-atom domain) and also STATE_AFTER_DOMAIN, which a + // Should be the end of the domain part. Accept ParserState::DOMAIN + // (normal dot-atom domain) and also ParserState::AFTER_DOMAIN, which a // domain-literal (``, `]` transitions to AFTER_DOMAIN) // or trailing CFWS reaches — but only when a domain or IP is actually // present, so `` / `` still fail. - if (self::STATE_DOMAIN == $ctx->subState - || (self::STATE_AFTER_DOMAIN == $ctx->subState + if (ParserState::DOMAIN == $ctx->subState + || (ParserState::AFTER_DOMAIN == $ctx->subState && ('' !== $ctx->domain || '' !== $ctx->ip))) { - $ctx->subState = self::STATE_AFTER_DOMAIN; - $ctx->in_angle_addr = false; + $ctx->subState = ParserState::AFTER_DOMAIN; + $ctx->inAngleAddr = false; } else { $ctx->invalid = true; - $ctx->invalid_reason = "Did not find domain name before a closing '>'"; - $ctx->invalid_reason_code = Err::MissingDomainBeforeClosingAngle; + $ctx->invalidReason = "Did not find domain name before a closing '>'"; + $ctx->invalidReasonCode = Err::MissingDomainBeforeClosingAngle; } } elseif ('"' == $curChar) { // If we hit a quote - change to the quote state, unless it's in the domain, in which case it's error - if (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState) { + if (ParserState::DOMAIN == $ctx->subState || ParserState::AFTER_DOMAIN == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = 'Quote \'"\' found where it shouldn\'t be'; - $ctx->invalid_reason_code = Err::MisplacedQuote; + $ctx->invalidReason = 'Quote \'"\' found where it shouldn\'t be'; + $ctx->invalidReasonCode = Err::MisplacedQuote; } else { - $ctx->state = self::STATE_QUOTE; + $ctx->state = ParserState::QUOTE; } } elseif ('@' == $curChar) { $this->handleAddressAt($ctx); @@ -618,53 +633,53 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string // part, and not after domain characters or a first literal. Accepting // it mid-domain used to set both domain and ip and surface as an // internal "parser confusion" error. - if (self::STATE_DOMAIN != $ctx->subState) { + if (ParserState::DOMAIN != $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = "Invalid character '[' in email address"; - $ctx->invalid_reason_code = Err::InvalidOpeningBracket; + $ctx->invalidReason = "Invalid character '[' in email address"; + $ctx->invalidReasonCode = Err::InvalidOpeningBracket; } elseif ('' !== $ctx->domain || '' !== $ctx->ip) { $ctx->invalid = true; - $ctx->invalid_reason = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; - $ctx->invalid_reason_code = Err::InvalidOpeningBracket; + $ctx->invalidReason = "A domain literal '[...]' must be the entire domain, not combined with other domain characters"; + $ctx->invalidReasonCode = Err::InvalidOpeningBracket; } else { - $ctx->state = self::STATE_SQUARE_BRACKET; + $ctx->state = ParserState::SQUARE_BRACKET; } } elseif ('.' == $curChar) { // Period placement (RFC 5322 §3.4) — inlined as it is per-character hot. if ('.' == $prevChar && !$this->options->allowObsLocalPart) { // Consecutive dots only allowed when obs-local-part is enabled $ctx->invalid = true; - $ctx->invalid_reason = "Email address should not contain two dots '.' in a row"; - $ctx->invalid_reason_code = Err::ConsecutiveDots; - } elseif (self::STATE_LOCAL_PART == $ctx->subState) { - if (!$ctx->local_part_parsed && !$this->options->allowObsLocalPart) { + $ctx->invalidReason = "Email address should not contain two dots '.' in a row"; + $ctx->invalidReasonCode = Err::ConsecutiveDots; + } elseif (ParserState::LOCAL_PART == $ctx->subState) { + if (!$ctx->localPartParsed && !$this->options->allowObsLocalPart) { // Leading dots only allowed when obs-local-part is enabled $ctx->invalid = true; - $ctx->invalid_reason = "Email address can not start with '.'"; - $ctx->invalid_reason_code = Err::LeadingDot; + $ctx->invalidReason = "Email address can not start with '.'"; + $ctx->invalidReasonCode = Err::LeadingDot; } else { - $ctx->local_part_parsed .= $curChar; + $ctx->localPartParsed .= $curChar; } - } elseif (self::STATE_DOMAIN == $ctx->subState) { + } elseif (ParserState::DOMAIN == $ctx->subState) { $ctx->domain .= $curChar; - } elseif (self::STATE_AFTER_DOMAIN == $ctx->subState) { + } elseif (ParserState::AFTER_DOMAIN == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = "Stray period '.' found after domain of email address"; - $ctx->invalid_reason_code = Err::StrayPeriodAfterDomain; - } elseif (self::STATE_START == $ctx->subState) { - if ($ctx->quote_temp) { - $ctx->address_temp .= $ctx->quote_temp; - $ctx->address_temp_quoted = true; - $ctx->quote_temp = ''; + $ctx->invalidReason = "Stray period '.' found after domain of email address"; + $ctx->invalidReasonCode = Err::StrayPeriodAfterDomain; + } elseif (ParserState::START == $ctx->subState) { + if ($ctx->quoteTemp) { + $ctx->addressTemp .= $ctx->quoteTemp; + $ctx->addressTempQuoted = true; + $ctx->quoteTemp = ''; } - $ctx->address_temp .= $curChar; - ++$ctx->address_temp_period; + $ctx->addressTemp .= $curChar; + ++$ctx->addressTempPeriod; } else { // RFC 5322 §3.4: a period is not an atext character and is not // valid in an unquoted display name or at the start of an address. $ctx->invalid = true; - $ctx->invalid_reason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; - $ctx->invalid_reason_code = Err::StrayPeriod; + $ctx->invalidReason = 'Stray period found in email address. If the period is part of a person\'s name, it must appear in double quotes - e.g. "John Q. Public". Otherwise, an email address shouldn\'t begin with a period.'; + $ctx->invalidReasonCode = Err::StrayPeriod; } } elseif (preg_match('/[A-Za-z0-9_\-!#$%&\'*+\/=?^`{|}~]/', $curChar)) { // atext (RFC 5322 §3.2.3) — the per-character hot path; inlined to keep @@ -672,38 +687,38 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string // domain or pending word per the sub-state. if (isset($ctx->bannedChars[$curChar])) { $ctx->invalid = true; - $ctx->invalid_reason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; - $ctx->invalid_reason_code = Err::CharacterNotAllowed; + $ctx->invalidReason = "This character is not allowed in email addresses submitted (please put in quotes if needed): '{$curChar}'"; + $ctx->invalidReasonCode = Err::CharacterNotAllowed; } elseif (('/' == $curChar || '|' == $curChar) && - !$ctx->local_part_parsed && !$ctx->address_temp && !$ctx->quote_temp && !$ctx->name_parsed) { + !$ctx->localPartParsed && !$ctx->addressTemp && !$ctx->quoteTemp && !$ctx->nameParsed) { $ctx->invalid = true; - $ctx->invalid_reason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterAtStart; - } elseif (self::STATE_LOCAL_PART == $ctx->subState) { + $ctx->invalidReason = "This character is not allowed at the beginning of an email address (please put in quotes if needed): '{$curChar}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterAtStart; + } elseif (ParserState::LOCAL_PART == $ctx->subState) { // Legitimate character - Determine where to append based on the current 'substate' - if ($ctx->quote_temp) { - $ctx->local_part_parsed .= $ctx->quote_temp; - $ctx->quote_temp = ''; - $ctx->local_part_quoted = true; + if ($ctx->quoteTemp) { + $ctx->localPartParsed .= $ctx->quoteTemp; + $ctx->quoteTemp = ''; + $ctx->localPartQuoted = true; } - $ctx->local_part_parsed .= $curChar; - } elseif (self::STATE_NAME == $ctx->subState) { - if ($ctx->quote_temp) { - $ctx->name_parsed .= $ctx->quote_temp; - $ctx->quote_temp = ''; - $ctx->name_quoted = true; + $ctx->localPartParsed .= $curChar; + } elseif (ParserState::NAME == $ctx->subState) { + if ($ctx->quoteTemp) { + $ctx->nameParsed .= $ctx->quoteTemp; + $ctx->quoteTemp = ''; + $ctx->nameQuoted = true; } - $ctx->name_parsed .= $curChar; - } elseif (self::STATE_DOMAIN == $ctx->subState) { + $ctx->nameParsed .= $curChar; + } elseif (ParserState::DOMAIN == $ctx->subState) { $ctx->domain .= $curChar; } else { - if ($ctx->quote_temp) { - $ctx->address_temp .= $ctx->quote_temp; - $ctx->address_temp_quoted = true; - $ctx->quote_temp = ''; + if ($ctx->quoteTemp) { + $ctx->addressTemp .= $ctx->quoteTemp; + $ctx->addressTempQuoted = true; + $ctx->quoteTemp = ''; } - $ctx->address_temp .= $curChar; + $ctx->addressTemp .= $curChar; } } else { $this->handleAddressNonAtext($ctx, $curChar); @@ -711,12 +726,12 @@ private function handleStateAddress(ParseContext $ctx, string $curChar, ?string } /** - * STATE_ADDRESS whitespace (RFC 5322 §3.2.2 CFWS). Looks ahead past the WSP + * ParserState::ADDRESS whitespace (RFC 5322 §3.2.2 CFWS). Looks ahead past the WSP * run to classify the fold and decide whether it is absorbed, ends the * address, or is an error. * * @return bool true when the address is complete and the caller should stop - * processing this character (STATE_END_ADDRESS was set) + * processing this character (ParserState::END_ADDRESS was set) */ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int $i): bool { @@ -748,28 +763,28 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int // rejected per-address (whitespace still separates addresses). $cfwsAbsorbed = false; if (!$foundComment && $lookAheadChar !== null && !($ctx->multiple && $this->options->strictMultiWhitespace)) { - if (self::STATE_LOCAL_PART === $ctx->subState) { + if (ParserState::LOCAL_PART === $ctx->subState) { if ('@' === $lookAheadChar) { // Trailing CFWS of the local-part dot-atom: "local @domain". $cfwsAbsorbed = true; } elseif ( - $ctx->in_angle_addr - && $ctx->local_part_parsed === '' - && $ctx->address_temp === '' - && $ctx->quote_temp === '' + $ctx->inAngleAddr + && $ctx->localPartParsed === '' + && $ctx->addressTemp === '' + && $ctx->quoteTemp === '' ) { // Leading CFWS inside angle-addr: "< local@domain>". $cfwsAbsorbed = true; } - } elseif (self::STATE_DOMAIN === $ctx->subState) { + } elseif (ParserState::DOMAIN === $ctx->subState) { if ($ctx->domain === '' && $ctx->ip === '') { // Leading CFWS of the domain dot-atom: "local@ domain". $cfwsAbsorbed = true; } } elseif ( - self::STATE_START === $ctx->subState + ParserState::START === $ctx->subState && '@' === $lookAheadChar - && $ctx->address_temp !== '' + && $ctx->addressTemp !== '' ) { // Top-level addr-spec with no angle-addr: "local @domain". // The accumulated address_temp IS the local-part; absorb the @@ -781,34 +796,34 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int if ($cfwsAbsorbed) { // Silently skip the whitespace character; state unchanged. } elseif ($foundComment) { - if (self::STATE_DOMAIN == $ctx->subState) { - $ctx->subState = self::STATE_AFTER_DOMAIN; - } elseif (self::STATE_LOCAL_PART == $ctx->subState) { + if (ParserState::DOMAIN == $ctx->subState) { + $ctx->subState = ParserState::AFTER_DOMAIN; + } elseif (ParserState::LOCAL_PART == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = 'Email address contains whitespace'; - $ctx->invalid_reason_code = Err::WhitespaceInAddress; + $ctx->invalidReason = 'Email address contains whitespace'; + $ctx->invalidReasonCode = Err::WhitespaceInAddress; } } elseif ( - $ctx->in_angle_addr - && self::STATE_DOMAIN == $ctx->subState + $ctx->inAngleAddr + && ParserState::DOMAIN == $ctx->subState && $lookAheadChar === '>' ) { // Trailing CFWS inside angle-addr before `>`: "". // Absorb and transition as if we saw `>` next. - $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->subState = ParserState::AFTER_DOMAIN; } elseif ( $ctx->multiple && $lookAheadChar !== null && isset($ctx->separators[$lookAheadChar]) - && (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState) + && (ParserState::DOMAIN == $ctx->subState || ParserState::AFTER_DOMAIN == $ctx->subState) ) { // Whitespace between the domain and a following separator // ("a@b.com , c@d.com"): absorb it and let the separator terminate // the address, rather than ending here and leaving the separator to // open an empty next address (a "misplaced separator" error). - $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->subState = ParserState::AFTER_DOMAIN; } elseif ($ctx->useWhitespaceAsSeparator && - (self::STATE_DOMAIN == $ctx->subState || self::STATE_AFTER_DOMAIN == $ctx->subState)) { + (ParserState::DOMAIN == $ctx->subState || ParserState::AFTER_DOMAIN == $ctx->subState)) { // Already past `@` and whitespace-as-separator: end address. // Single mode has no next address to separate; if the trailing // whitespace run contains a whitespace char excluded from the @@ -818,25 +833,25 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int for ($k = $i; $k < $ctx->len && isset(self::WHITESPACE[$ctx->chars[$k]]); ++$k) { if (!isset($ctx->allowedWhitespace[$ctx->chars[$k]])) { $ctx->invalid = true; - $ctx->invalid_reason = 'Disallowed whitespace after address'; - $ctx->invalid_reason_code = Err::WhitespaceInAddress; + $ctx->invalidReason = 'Disallowed whitespace after address'; + $ctx->invalidReasonCode = Err::WhitespaceInAddress; break; } } } - $ctx->state = self::STATE_END_ADDRESS; + $ctx->state = ParserState::END_ADDRESS; return true; } else { - if (self::STATE_LOCAL_PART == $ctx->subState) { + if (ParserState::LOCAL_PART == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = 'Email address contains whitespace'; - $ctx->invalid_reason_code = Err::WhitespaceInAddress; + $ctx->invalidReason = 'Email address contains whitespace'; + $ctx->invalidReasonCode = Err::WhitespaceInAddress; } else { // Display-name phrase: absorb into name_parsed. $this->handleQuote($ctx); - $ctx->name_parsed .= $curChar; + $ctx->nameParsed .= $curChar; } } @@ -844,79 +859,79 @@ private function handleAddressWhitespace(ParseContext $ctx, string $curChar, int } /** - * STATE_ADDRESS '@' handling: reject a misplaced '@', start an obs-route, or + * ParserState::ADDRESS '@' handling: reject a misplaced '@', start an obs-route, or * flush the accumulated word(s) into the local-part and enter the domain. */ private function handleAddressAt(ParseContext $ctx): void { - if (self::STATE_DOMAIN == $ctx->subState) { + if (ParserState::DOMAIN == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = "Multiple at '@' symbols in email address"; - $ctx->invalid_reason_code = Err::MultipleAtSymbols; - } elseif (self::STATE_AFTER_DOMAIN == $ctx->subState) { + $ctx->invalidReason = "Multiple at '@' symbols in email address"; + $ctx->invalidReasonCode = Err::MultipleAtSymbols; + } elseif (ParserState::AFTER_DOMAIN == $ctx->subState) { $ctx->invalid = true; - $ctx->invalid_reason = "Stray at '@' symbol found after domain name"; - $ctx->invalid_reason_code = Err::StrayAtAfterDomain; - } elseif (null !== $ctx->special_char_in_substate) { + $ctx->invalidReason = "Stray at '@' symbol found after domain name"; + $ctx->invalidReasonCode = Err::StrayAtAfterDomain; + } elseif (null !== $ctx->specialCharInSubstate) { $ctx->invalid = true; - $ctx->invalid_reason = "Invalid character found in email address local part: '{$ctx->special_char_in_substate}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterInLocalPart; - } elseif ($ctx->local_atom_split_by_comment) { + $ctx->invalidReason = "Invalid character found in email address local part: '{$ctx->specialCharInSubstate}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterInLocalPart; + } elseif ($ctx->localAtomSplitByComment) { // The `@` confirms this was an addr-spec local part, so the comment // that split its atext (RFC 5322 §3.2.3) is invalid here. $ctx->invalid = true; - $ctx->invalid_reason = 'A comment cannot appear between characters of an unquoted local part; separate with a dot or quote the local part'; - $ctx->invalid_reason_code = Err::AtextAfterComment; + $ctx->invalidReason = 'A comment cannot appear between characters of an unquoted local part; separate with a dot or quote the local part'; + $ctx->invalidReasonCode = Err::AtextAfterComment; } elseif ( $this->options->allowObsRoute - && $ctx->in_angle_addr - && $ctx->obs_route === '' - && $ctx->local_part_parsed === '' - && $ctx->quote_temp === '' - && $ctx->address_temp === '' + && $ctx->inAngleAddr + && $ctx->obsRoute === '' + && $ctx->localPartParsed === '' + && $ctx->quoteTemp === '' + && $ctx->addressTemp === '' // An empty *quoted* local part (`<""@host>`) is a real local // part, not the "no local part" that starts an obs-route. - && !$ctx->local_part_quoted + && !$ctx->localPartQuoted ) { // RFC 5322 §4.4 obs-route: first `@` seen inside `<...>` with no // preceding local-part starts the source-route prefix. Consume - // the remainder until `:` via STATE_OBS_ROUTE, then resume + // the remainder until `:` via ParserState::OBS_ROUTE, then resume // addr-spec parsing with local-part reset. - $ctx->state = self::STATE_OBS_ROUTE; - $ctx->obs_route = '@'; + $ctx->state = ParserState::OBS_ROUTE; + $ctx->obsRoute = '@'; } else { - $ctx->subState = self::STATE_DOMAIN; + $ctx->subState = ParserState::DOMAIN; // A trailing quoted word after earlier words ("x"."y", x."y") // is the final word of an obs-local-part (RFC 5322 §3.4.1: // word *("." word), word = atom / quoted-string). Flush it onto // the accumulated local part, exactly as the dot handler flushes // earlier words — not a parser error. - if ($ctx->address_temp && $ctx->quote_temp) { - $ctx->address_temp .= $ctx->quote_temp; - $ctx->address_temp_quoted = true; - $ctx->quote_temp = ''; + if ($ctx->addressTemp && $ctx->quoteTemp) { + $ctx->addressTemp .= $ctx->quoteTemp; + $ctx->addressTempQuoted = true; + $ctx->quoteTemp = ''; } - if ($ctx->quote_temp) { - $ctx->local_part_parsed = $ctx->quote_temp; - $ctx->quote_temp = ''; - $ctx->local_part_quoted = true; - } elseif ($ctx->address_temp) { - $ctx->local_part_parsed = $ctx->address_temp; - $ctx->address_temp = ''; - $ctx->local_part_quoted = $ctx->address_temp_quoted; - $ctx->address_temp_quoted = false; - $ctx->address_temp_period = 0; + if ($ctx->quoteTemp) { + $ctx->localPartParsed = $ctx->quoteTemp; + $ctx->quoteTemp = ''; + $ctx->localPartQuoted = true; + } elseif ($ctx->addressTemp) { + $ctx->localPartParsed = $ctx->addressTemp; + $ctx->addressTemp = ''; + $ctx->localPartQuoted = $ctx->addressTempQuoted; + $ctx->addressTempQuoted = false; + $ctx->addressTempPeriod = 0; } } } /** - * STATE_ADDRESS non-atext handling — UTF-8 domain/local-part characters + * ParserState::ADDRESS non-atext handling — UTF-8 domain/local-part characters * (punycode-tested for the domain) plus rejection of other stray bytes. */ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void { - if (self::STATE_DOMAIN == $ctx->subState) { + if (ParserState::DOMAIN == $ctx->subState) { if ($this->isUtf8Char($curChar)) { $ctx->domain .= $curChar; } else { @@ -930,119 +945,119 @@ private function handleAddressNonAtext(ParseContext $ctx, string $curChar): void $ctx->invalid = true; } } catch (\Exception $e) { - $this->log('warning', "Email\\Parse->parse - exception trying to convert character '{$curChar}' to punycode\n\$ctx->original_address: {$ctx->original_address}\n\$emails: {$ctx->emails}"); + $this->log('warning', "Email\\Parse->parse - exception trying to convert character '{$curChar}' to punycode\n\$ctx->originalAddress: {$ctx->originalAddress}\n\$emails: {$ctx->emails}"); $ctx->invalid = true; } if ($ctx->invalid) { - $ctx->invalid_reason = "Invalid character found in domain of email address (please put in quotes if needed): '{$curChar}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterInDomain; + $ctx->invalidReason = "Invalid character found in domain of email address (please put in quotes if needed): '{$curChar}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterInDomain; } } - } elseif (self::STATE_START === $ctx->subState || self::STATE_LOCAL_PART === $ctx->subState) { - // Handle non-atext characters in both STATE_START and STATE_LOCAL_PART consistently - if ($ctx->subState === self::STATE_START && $ctx->quote_temp) { - $ctx->address_temp .= $ctx->quote_temp; - $ctx->address_temp_quoted = true; - $ctx->quote_temp = ''; - } elseif ($ctx->subState === self::STATE_LOCAL_PART && $ctx->quote_temp) { - $ctx->local_part_parsed .= $ctx->quote_temp; - $ctx->quote_temp = ''; - $ctx->local_part_quoted = true; + } elseif (ParserState::START === $ctx->subState || ParserState::LOCAL_PART === $ctx->subState) { + // Handle non-atext characters in both ParserState::START and ParserState::LOCAL_PART consistently + if ($ctx->subState === ParserState::START && $ctx->quoteTemp) { + $ctx->addressTemp .= $ctx->quoteTemp; + $ctx->addressTempQuoted = true; + $ctx->quoteTemp = ''; + } elseif ($ctx->subState === ParserState::LOCAL_PART && $ctx->quoteTemp) { + $ctx->localPartParsed .= $ctx->quoteTemp; + $ctx->quoteTemp = ''; + $ctx->localPartQuoted = true; } $isUtf8 = $this->isUtf8Char($curChar); if ($isUtf8 && $this->options->allowUtf8LocalPart) { // UTF-8 character allowed - if ($ctx->subState === self::STATE_START) { - $ctx->address_temp .= $curChar; + if ($ctx->subState === ParserState::START) { + $ctx->addressTemp .= $curChar; } else { - $ctx->local_part_parsed .= $curChar; + $ctx->localPartParsed .= $curChar; } } elseif ($isUtf8) { // UTF-8 present but not allowed by rules — collect and reject in validateLocalPart() - if ($ctx->subState === self::STATE_START) { - $ctx->address_temp .= $curChar; + if ($ctx->subState === ParserState::START) { + $ctx->addressTemp .= $curChar; // ??= preserves the first invalid character seen; later chars must not overwrite it - $ctx->special_char_in_substate ??= $curChar; + $ctx->specialCharInSubstate ??= $curChar; } else { $ctx->invalid = true; - $ctx->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterInLocalPart; + $ctx->invalidReason = "Invalid character found in email address local part: '{$curChar}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterInLocalPart; } } else { // Non-UTF-8, non-atext character - if ($ctx->subState === self::STATE_START) { + if ($ctx->subState === ParserState::START) { // ??= preserves the first invalid character seen; later chars must not overwrite it - $ctx->special_char_in_substate ??= $curChar; - $ctx->address_temp .= $curChar; + $ctx->specialCharInSubstate ??= $curChar; + $ctx->addressTemp .= $curChar; } else { $ctx->invalid = true; - $ctx->invalid_reason = "Invalid character found in email address local part: '{$curChar}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterInLocalPart; + $ctx->invalidReason = "Invalid character found in email address local part: '{$curChar}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterInLocalPart; } } - } elseif (self::STATE_NAME === $ctx->subState) { - if ($ctx->quote_temp) { - $ctx->name_parsed .= $ctx->quote_temp; - $ctx->quote_temp = ''; - $ctx->name_quoted = true; + } elseif (ParserState::NAME === $ctx->subState) { + if ($ctx->quoteTemp) { + $ctx->nameParsed .= $ctx->quoteTemp; + $ctx->quoteTemp = ''; + $ctx->nameQuoted = true; } - $ctx->special_char_in_substate = $curChar; - $ctx->name_parsed .= $curChar; + $ctx->specialCharInSubstate = $curChar; + $ctx->nameParsed .= $curChar; } else { $ctx->invalid = true; - $ctx->invalid_reason = "Invalid character found in email address (please put in quotes if needed): '{$curChar}'"; - $ctx->invalid_reason_code = Err::InvalidCharacterInAddress; + $ctx->invalidReason = "Invalid character found in email address (please put in quotes if needed): '{$curChar}'"; + $ctx->invalidReasonCode = Err::InvalidCharacterInAddress; } } /** - * STATE_SQUARE_BRACKET: accumulate a domain-literal IP until the closing ']'. + * ParserState::SQUARE_BRACKET: accumulate a domain-literal IP until the closing ']'. */ private function handleStateSquareBracket(ParseContext $ctx, string $curChar): void { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; if (']' == $curChar) { - $ctx->subState = self::STATE_AFTER_DOMAIN; - $ctx->state = self::STATE_ADDRESS; + $ctx->subState = ParserState::AFTER_DOMAIN; + $ctx->state = ParserState::ADDRESS; } else { $ctx->ip .= $curChar; } } /** - * STATE_OBS_ROUTE (RFC 5322 §4.4): consume the `@host1,@host2:` source-route + * ParserState::OBS_ROUTE (RFC 5322 §4.4): consume the `@host1,@host2:` source-route * prefix inside angle-addr. On `:` resume addr-spec parsing; an unterminated * route (`>` or end of input before `:`) is invalid. */ private function handleStateObsRoute(ParseContext $ctx, string $curChar): void { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; if (':' == $curChar) { - $ctx->state = self::STATE_ADDRESS; - $ctx->subState = self::STATE_LOCAL_PART; + $ctx->state = ParserState::ADDRESS; + $ctx->subState = ParserState::LOCAL_PART; } elseif ('>' == $curChar) { // `<@host>` without a colon — incomplete obs-route. $ctx->invalid = true; - $ctx->invalid_reason = 'Incomplete obs-route: missing colon before closing angle-bracket'; - $ctx->invalid_reason_code = Err::IncompleteAddress; - $ctx->in_angle_addr = false; - $ctx->state = self::STATE_ADDRESS; - $ctx->subState = self::STATE_AFTER_DOMAIN; + $ctx->invalidReason = 'Incomplete obs-route: missing colon before closing angle-bracket'; + $ctx->invalidReasonCode = Err::IncompleteAddress; + $ctx->inAngleAddr = false; + $ctx->state = ParserState::ADDRESS; + $ctx->subState = ParserState::AFTER_DOMAIN; } else { - $ctx->obs_route .= $curChar; + $ctx->obsRoute .= $curChar; } } /** - * STATE_QUOTE: accumulate a quoted-string, honouring backslash escapes and + * ParserState::QUOTE: accumulate a quoted-string, honouring backslash escapes and * rejecting bare C0 controls, until the real closing quote returns to - * STATE_ADDRESS. + * ParserState::ADDRESS. */ private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): void { - $ctx->original_address .= $curChar; + $ctx->originalAddress .= $curChar; if ('"' == $curChar) { // RFC 5322 §3.2.4 / RFC 5321 §4.1.2: detect escaped quote by counting // consecutive backslashes immediately before this position. An odd count @@ -1058,7 +1073,7 @@ private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): v } if ($backslashCount && 1 == $backslashCount % 2) { // Odd number of backslashes = this quote is escaped - $ctx->quote_temp .= $curChar; + $ctx->quoteTemp .= $curChar; } else { // Even backslashes (or zero) = this is the real closing quote. // Record that a quote was seen so an *empty* quoted local-part @@ -1066,79 +1081,79 @@ private function handleStateQuote(ParseContext $ctx, string $curChar, int $i): v // empty in that case, so the '@' handler below can't tell. A // display-name quote self-corrects: the real local-part resets // this flag from address_temp_quoted when '@' is reached. - $ctx->state = self::STATE_ADDRESS; - $ctx->local_part_quoted = true; - $ctx->after_closing_quote = true; + $ctx->state = ParserState::ADDRESS; + $ctx->localPartQuoted = true; + $ctx->afterClosingQuote = true; } } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { // qtext (RFC 5322 §3.2.4) excludes C0 controls; a bare CR or LF // inside a quoted-string is not valid (only a CRLF fold with WSP is). $ctx->invalid = true; - $ctx->invalid_reason = 'Control character in quoted string'; - $ctx->invalid_reason_code = Err::InvalidCharInQuotedString; + $ctx->invalidReason = 'Control character in quoted string'; + $ctx->invalidReasonCode = Err::InvalidCharInQuotedString; } else { - $ctx->quote_temp .= $curChar; + $ctx->quoteTemp .= $curChar; } } /** - * STATE_COMMENT (RFC 5322 §3.2.2): accumulate comment text, tracking nesting + * ParserState::COMMENT (RFC 5322 §3.2.2): accumulate comment text, tracking nesting * and quoted-pairs, and on close flag a comment that split a local-part atom. */ private function handleStateComment(ParseContext $ctx, string $curChar): void { - $ctx->original_address .= $curChar; - if ($ctx->comment_escaped) { + $ctx->originalAddress .= $curChar; + if ($ctx->commentEscaped) { // Target of a quoted-pair — literal, never structural. - $ctx->comment_escaped = false; - $ctx->comment_temp .= $curChar; + $ctx->commentEscaped = false; + $ctx->commentTemp .= $curChar; } elseif ('\\' == $curChar) { // RFC 5322 §3.2.1: backslash starts a quoted-pair; the next // character is escaped (so "\)" does not close the comment). - $ctx->comment_escaped = true; + $ctx->commentEscaped = true; } elseif (')' == $curChar) { --$ctx->commentNestLevel; if ($ctx->commentNestLevel <= 0) { // End of comment - save it - if ($ctx->comment_temp) { - $ctx->comments[] = $ctx->comment_temp; - $ctx->comment_temp = ''; + if ($ctx->commentTemp) { + $ctx->comments[] = $ctx->commentTemp; + $ctx->commentTemp = ''; } - $ctx->state = self::STATE_ADDRESS; + $ctx->state = ParserState::ADDRESS; // Flag a comment that closed mid-word in the local part (before // `@`), so a token resuming the word can be rejected. Covers a // preceding atext run (address_temp/local_part_parsed) or a // preceding quoted-string (local_part_quoted) — "x"(c)y is as // invalid as x(c)y. Domain and display-name comments are excluded. - if ((self::STATE_LOCAL_PART === $ctx->subState || self::STATE_START === $ctx->subState) - && ('' !== $ctx->address_temp || '' !== $ctx->local_part_parsed || $ctx->local_part_quoted)) { - $ctx->comment_after_local_atext = true; + if ((ParserState::LOCAL_PART === $ctx->subState || ParserState::START === $ctx->subState) + && ('' !== $ctx->addressTemp || '' !== $ctx->localPartParsed || $ctx->localPartQuoted)) { + $ctx->commentAfterLocalAtext = true; } } else { // Nested comment closing parenthesis - $ctx->comment_temp .= $curChar; + $ctx->commentTemp .= $curChar; } } elseif ('(' == $curChar) { ++$ctx->commentNestLevel; if ($ctx->commentNestLevel > 1) { // Nested comment opening parenthesis - $ctx->comment_temp .= $curChar; + $ctx->commentTemp .= $curChar; } } elseif ($this->options->rejectC0Controls && 1 === strlen($curChar) && "\t" !== $curChar && (ord($curChar) < 32 || "\x7f" === $curChar)) { // ctext (RFC 5322 §3.2.3) excludes C0 controls; a bare CR or LF // inside a comment is not part of valid folding. $ctx->invalid = true; - $ctx->invalid_reason = 'Control character in comment'; - $ctx->invalid_reason_code = Err::ControlCharInComment; + $ctx->invalidReason = 'Control character in comment'; + $ctx->invalidReasonCode = Err::ControlCharInComment; } elseif ($this->options->rejectC1Controls && preg_match('/[\x{0080}-\x{009F}]/u', $curChar)) { // RFC 6532 §3.1: C1 controls (2-byte UTF-8) are prohibited in // internationalized content, comments included. $ctx->invalid = true; - $ctx->invalid_reason = 'Control character in comment'; - $ctx->invalid_reason_code = Err::ControlCharInComment; + $ctx->invalidReason = 'Control character in comment'; + $ctx->invalidReasonCode = Err::ControlCharInComment; } else { // Regular comment character - $ctx->comment_temp .= $curChar; + $ctx->commentTemp .= $curChar; } } @@ -1151,19 +1166,19 @@ private function handleStateComment(ParseContext $ctx, string $curChar): void */ private function handleQuote(ParseContext $ctx): void { - if ($ctx->quote_temp) { - $ctx->name_parsed .= $ctx->quote_temp; - $ctx->name_quoted = true; - $ctx->quote_temp = ''; - } elseif ($ctx->address_temp) { - $ctx->name_parsed .= $ctx->address_temp; - $ctx->name_quoted = $ctx->address_temp_quoted; - $ctx->address_temp_quoted = false; - $ctx->address_temp = ''; - if ($ctx->address_temp_period > 0) { + if ($ctx->quoteTemp) { + $ctx->nameParsed .= $ctx->quoteTemp; + $ctx->nameQuoted = true; + $ctx->quoteTemp = ''; + } elseif ($ctx->addressTemp) { + $ctx->nameParsed .= $ctx->addressTemp; + $ctx->nameQuoted = $ctx->addressTempQuoted; + $ctx->addressTempQuoted = false; + $ctx->addressTemp = ''; + if ($ctx->addressTempPeriod > 0) { $ctx->invalid = true; - $ctx->invalid_reason = 'Periods within the display name of an email address must appear in quotes, such as "John Q. Public" according to RFC 5322'; - $ctx->invalid_reason_code = Err::UnquotedPeriodInDisplayName; + $ctx->invalidReason = 'Periods within the display name of an email address must appear in quotes, such as "John Q. Public" according to RFC 5322'; + $ctx->invalidReasonCode = Err::UnquotedPeriodInDisplayName; } } } @@ -1191,41 +1206,45 @@ private function addAddress( $ctx->ip = $ctx->domain; $ctx->domain = ''; } - if ($ctx->address_temp || $ctx->quote_temp) { + if ($ctx->addressTemp || $ctx->quoteTemp) { $ctx->invalid = true; - $ctx->invalid_reason = 'Incomplete address'; - $ctx->invalid_reason_code = Err::IncompleteAddress; - $this->log('error', "Email\\Parse->addAddress - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->address_temp : {$ctx->address_temp}\n\$ctx->quote_temp: {$ctx->quote_temp}\n"); + $ctx->invalidReason = 'Incomplete address'; + $ctx->invalidReasonCode = Err::IncompleteAddress; + $this->log('error', "Email\\Parse->addAddress - corruption during parsing - leftovers:\n\$i: {$i}\n\$ctx->addressTemp : {$ctx->addressTemp}\n\$ctx->quoteTemp: {$ctx->quoteTemp}\n"); + // @codeCoverageIgnoreStart } elseif ($ctx->ip && $ctx->domain) { - // Error - this should never occur + // Unreachable: an address cannot end with both an IP literal and a + // domain set. A 500k-input fuzz confirmed this branch is dead; + // kept as defensive depth. $ctx->invalid = true; - $ctx->invalid_reason = 'Confusion during parsing'; - $ctx->invalid_reason_code = Err::ParserConfusion; - $this->log('error', "Email\\Parse->addAddress - both an IP address '{$ctx->ip}' and a domain '{$ctx->domain}' found for the email address '{$ctx->original_address}'\n"); + $ctx->invalidReason = 'Confusion during parsing'; + $ctx->invalidReasonCode = Err::ParserConfusion; + $this->log('error', "Email\\Parse->addAddress - both an IP address '{$ctx->ip}' and a domain '{$ctx->domain}' found for the email address '{$ctx->originalAddress}'\n"); + // @codeCoverageIgnoreEnd } elseif ($ctx->ip) { if (filter_var($ctx->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($ctx->ip, FILTER_FLAG_IPV4)) { $ctx->invalid = true; - $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address in the global range'; - $ctx->invalid_reason_code = Err::IpNotInGlobalRange; + $ctx->invalidReason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address in the global range'; + $ctx->invalidReasonCode = Err::IpNotInGlobalRange; } } elseif (str_starts_with($ctx->ip, 'IPv6:')) { $tempIp = str_replace('IPv6:', '', $ctx->ip); if (filter_var($tempIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { if ($this->options->validateIpGlobalRange && !$this->validateIpGlobalRange($tempIp, FILTER_FLAG_IPV6)) { $ctx->invalid = true; - $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IPv6 address in the global range'; - $ctx->invalid_reason_code = Err::Ipv6NotInGlobalRange; + $ctx->invalidReason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IPv6 address in the global range'; + $ctx->invalidReasonCode = Err::Ipv6NotInGlobalRange; } } else { $ctx->invalid = true; - $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address'; - $ctx->invalid_reason_code = Err::InvalidIpAddress; + $ctx->invalidReason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address'; + $ctx->invalidReasonCode = Err::InvalidIpAddress; } } else { $ctx->invalid = true; - $ctx->invalid_reason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address'; - $ctx->invalid_reason_code = Err::InvalidIpAddress; + $ctx->invalidReason = 'IP address invalid: \'' . $ctx->ip . '\' does not appear to be a valid IP address'; + $ctx->invalidReasonCode = Err::InvalidIpAddress; } } elseif ($ctx->domain) { // Optional FQDN root-label dot (RFC 5321 §2.3.5 allows "example.com."). @@ -1233,8 +1252,8 @@ private function addAddress( if (str_ends_with($ctx->domain, '.')) { if ($this->options->rejectTrailingDot) { $ctx->invalid = true; - $ctx->invalid_reason = 'Domain must not end with a trailing dot'; - $ctx->invalid_reason_code = Err::TrailingDotNotAllowed; + $ctx->invalidReason = 'Domain must not end with a trailing dot'; + $ctx->invalidReasonCode = Err::TrailingDotNotAllowed; } else { $ctx->domain = substr($ctx->domain, 0, -1); } @@ -1253,34 +1272,34 @@ private function addAddress( $domainAscii = $this->normalizeDomainAscii($ctx->domain); if ($domainAscii === null) { $ctx->invalid = true; - $ctx->invalid_reason = "Can't convert domain {$ctx->domain} to punycode"; - $ctx->invalid_reason_code = Err::PunycodeConversionFailed; + $ctx->invalidReason = "Can't convert domain {$ctx->domain} to punycode"; + $ctx->invalidReasonCode = Err::PunycodeConversionFailed; } else { if ($domainAscii !== $ctx->domain) { - $ctx->domain_ascii = $domainAscii; + $ctx->domainAscii = $domainAscii; } $result = $this->validateDomainName($domainAscii); if (!$result['valid']) { $ctx->invalid = true; - $ctx->invalid_reason = isset($result['reason']) ? 'Domain invalid: '.$result['reason'] : 'Domain invalid for some unknown reason'; - $ctx->invalid_reason_code = $result['code'] ?? Err::DomainInvalid; + $ctx->invalidReason = isset($result['reason']) ? 'Domain invalid: '.$result['reason'] : 'Domain invalid for some unknown reason'; + $ctx->invalidReasonCode = $result['code'] ?? Err::DomainInvalid; } } } } // Prepare some of the fields needed - $ctx->name_parsed = rtrim($ctx->name_parsed); - $ctx->original_address = rtrim($ctx->original_address); - $name = $ctx->name_quoted ? "\"{$ctx->name_parsed}\"" : $ctx->name_parsed; - $localPart = $ctx->local_part_quoted ? "\"{$ctx->local_part_parsed}\"" : $ctx->local_part_parsed; + $ctx->nameParsed = rtrim($ctx->nameParsed); + $ctx->originalAddress = rtrim($ctx->originalAddress); + $name = $ctx->nameQuoted ? "\"{$ctx->nameParsed}\"" : $ctx->nameParsed; + $localPart = $ctx->localPartQuoted ? "\"{$ctx->localPartParsed}\"" : $ctx->localPartParsed; $domainPart = $ctx->ip ? '['.$ctx->ip.']' : $ctx->domain; if (!$ctx->invalid) { if (0 == strlen($domainPart)) { $ctx->invalid = true; - $ctx->invalid_reason = 'Email address needs a domain after the \'@\''; - $ctx->invalid_reason_code = Err::MissingDomain; + $ctx->invalidReason = 'Email address needs a domain after the \'@\''; + $ctx->invalidReasonCode = Err::MissingDomain; } } @@ -1292,34 +1311,28 @@ private function addAddress( // rejection of non-atext bytes such as stray UTF-8 in an unquoted name. if (!$ctx->invalid && $this->options->validateDisplayNamePhrase - && !$ctx->name_quoted - && $ctx->name_parsed !== '' - && !preg_match('#^[A-Za-z0-9!\#$%&\'*+\-/=?^_`{|}~ \t]+$#', $ctx->name_parsed) + && !$ctx->nameQuoted + && $ctx->nameParsed !== '' + && !preg_match('#^[A-Za-z0-9!\#$%&\'*+\-/=?^_`{|}~ \t]+$#', $ctx->nameParsed) ) { $ctx->invalid = true; - $ctx->invalid_reason = "Display name '{$ctx->name_parsed}' must be a quoted-string or atext-only phrase per RFC 5322 §3.2.5"; - $ctx->invalid_reason_code = Err::InvalidDisplayNamePhrase; + $ctx->invalidReason = "Display name '{$ctx->nameParsed}' must be a quoted-string or atext-only phrase per RFC 5322 §3.2.5"; + $ctx->invalidReasonCode = Err::InvalidDisplayNamePhrase; } - // Unified local-part validation. Dispatched through validateLocalPart(), - // a deprecated but backward-compatible extension point (removed in 4.0), - // so it still receives the legacy accumulator-array shape it always took. + // Unified local-part validation. if (!$ctx->invalid) { - /** @psalm-suppress DeprecatedMethod Intentional BC hook so subclass overrides still fire; see validateLocalPart(). */ - $result = $this->validateLocalPart([ - 'local_part_parsed' => $ctx->local_part_parsed, - 'local_part_quoted' => $ctx->local_part_quoted, - ]); + $result = $this->validateLocalPart($ctx); if (!$result['valid']) { $ctx->invalid = true; - $ctx->invalid_reason = $result['reason']; - $ctx->invalid_reason_code = $result['code'] ?? null; + $ctx->invalidReason = $result['reason']; + $ctx->invalidReasonCode = $result['code'] ?? null; } elseif ($result['normalized'] !== null) { // Apply NFC normalization result to the parsed local-part and re-derive display form - $ctx->local_part_parsed = $result['normalized']; - $localPart = $ctx->local_part_quoted - ? "\"{$ctx->local_part_parsed}\"" - : $ctx->local_part_parsed; + $ctx->localPartParsed = $result['normalized']; + $localPart = $ctx->localPartQuoted + ? "\"{$ctx->localPartParsed}\"" + : $ctx->localPartParsed; } // Optional caller-supplied local-part normalizer — invoked after structural @@ -1331,12 +1344,12 @@ private function addAddress( // still preserves the verbatim input. if (!$ctx->invalid && $this->options->localPartNormalizer !== null) { $normalizer = $this->options->localPartNormalizer; - $normalized = $normalizer($ctx->local_part_parsed, $ctx->domain); - if ($normalized !== $ctx->local_part_parsed) { - $ctx->local_part_parsed = $normalized; - $localPart = $ctx->local_part_quoted - ? "\"{$ctx->local_part_parsed}\"" - : $ctx->local_part_parsed; + $normalized = $normalizer($ctx->localPartParsed, $ctx->domain); + if ($normalized !== $ctx->localPartParsed) { + $ctx->localPartParsed = $normalized; + $localPart = $ctx->localPartQuoted + ? "\"{$ctx->localPartParsed}\"" + : $ctx->localPartParsed; } } } @@ -1346,48 +1359,48 @@ private function addAddress( $dotPos = strpos($ctx->domain, '.'); if ($dotPos === false || $dotPos === 0 || $dotPos === strlen($ctx->domain) - 1) { $ctx->invalid = true; - $ctx->invalid_reason = 'Domain must be a fully-qualified domain name'; - $ctx->invalid_reason_code = Err::FqdnRequired; + $ctx->invalidReason = 'Domain must be a fully-qualified domain name'; + $ctx->invalidReasonCode = Err::FqdnRequired; } } // RFC 5321 §4.5.3.1: all limits are in octets (bytes), not characters. // For quoted local-parts the wire form adds 2 DQUOTE bytes to the length. if (!$ctx->invalid && $this->options->enforceLengthLimits) { - $limits = $this->options->getLengthLimits(); + $limits = $this->options->lengthLimits; // RFC 5321 §4.5.3.1.1: local-part max 64 octets (wire form includes DQUOTE for quoted strings) - $localPartWireLen = $ctx->local_part_quoted - ? strlen($ctx->local_part_parsed) + 2 - : strlen($ctx->local_part_parsed); + $localPartWireLen = $ctx->localPartQuoted + ? strlen($ctx->localPartParsed) + 2 + : strlen($ctx->localPartParsed); if ($localPartWireLen > $limits->maxLocalPartLength) { $ctx->invalid = true; - $ctx->invalid_reason = "Email address before the '@' can not be greater than {$limits->maxLocalPartLength} octets per RFC 5321"; - $ctx->invalid_reason_code = Err::LocalPartTooLong; + $ctx->invalidReason = "Email address before the '@' can not be greater than {$limits->maxLocalPartLength} octets per RFC 5321"; + $ctx->invalidReasonCode = Err::LocalPartTooLong; } elseif (($localPartWireLen + 1 + strlen($domainPart)) > $limits->maxTotalLength) { $ctx->invalid = true; - $ctx->invalid_reason = "Email addresses can not be greater than {$limits->maxTotalLength} octets per RFC 3696 EID 1690"; - $ctx->invalid_reason_code = Err::TotalLengthExceeded; + $ctx->invalidReason = "Email addresses can not be greater than {$limits->maxTotalLength} octets per RFC 3696 EID 1690"; + $ctx->invalidReasonCode = Err::TotalLengthExceeded; } } // Build the email address hash $emailAddrDef = ['address' => '', 'simple_address' => '', - 'original_address' => rtrim($ctx->original_address), + 'original_address' => rtrim($ctx->originalAddress), 'name' => $name, - 'name_parsed' => $ctx->name_parsed, + 'name_parsed' => $ctx->nameParsed, 'local_part' => $localPart, - 'local_part_parsed' => $ctx->local_part_parsed, + 'local_part_parsed' => $ctx->localPartParsed, 'domain_part' => $domainPart, 'domain' => $ctx->domain, - 'domain_ascii' => $this->options->includeDomainAscii ? ($ctx->domain_ascii ?? null) : null, + 'domain_ascii' => $this->options->includeDomainAscii ? ($ctx->domainAscii ?? null) : null, 'ip' => $ctx->ip, 'invalid' => $ctx->invalid, - 'invalid_reason' => $ctx->invalid_reason, - 'invalid_reason_code' => $ctx->invalid_reason_code, + 'invalid_reason' => $ctx->invalidReason, + 'invalid_reason_code' => $ctx->invalidReasonCode, 'comments' => $ctx->comments, - 'obs_route' => $ctx->obs_route !== '' ? $ctx->obs_route : null, + 'obs_route' => $ctx->obsRoute !== '' ? $ctx->obsRoute : null, 'domain_is_suspicious' => $this->isDomainConfusable($ctx->domain), ]; // Build the proper address by hand (has comments stripped out and should have quotes in the proper places) @@ -1435,20 +1448,13 @@ private function isDomainConfusable(string $domain): bool /** * Unified local-part validation based on ParseOptions rule properties. * - * @deprecated 3.9.0 Not a supported extension point going forward — customize - * validation through ParseOptions, not by overriding this. Kept - * with its original array signature for backward compatibility - * and removed in 4.0. Receives the accumulator keys it reads: - * `local_part_parsed` (string) and `local_part_quoted` (bool). - * - * @param array{local_part_parsed: string, local_part_quoted: bool} $emailAddress * @return array{valid: bool, reason: ?string, code: ?ParseErrorCode, normalized: ?string} */ - protected function validateLocalPart(array $emailAddress): array + private function validateLocalPart(ParseContext $ctx): array { $opts = $this->options; - $localPart = $emailAddress['local_part_parsed']; - $quoted = $emailAddress['local_part_quoted']; + $localPart = $ctx->localPartParsed; + $quoted = $ctx->localPartQuoted; // RFC 6531 §3.3 / RFC 6532 §3.2: gate UTF-8 presence before other checks // (allowUtf8LocalPart is false in rfc5321() and rfc5322() presets) @@ -1644,7 +1650,7 @@ protected function normalizeDomainAscii(string $domain): ?string * * @return array{valid: bool, reason?: string, code?: ParseErrorCode} */ - protected function validateDomainName(string $domain): array + private function validateDomainName(string $domain): array { // RFC 5321 §4.5.3.1.2: total domain length limit is in octets if (strlen($domain) > 255) { @@ -1657,7 +1663,7 @@ protected function validateDomainName(string $domain): array // Labels are guaranteed non-empty: the state machine rejects consecutive // and edge dots (ConsecutiveDots) before the domain validator runs. $parts = explode('.', $domain); - $maxLabelLen = $this->options->getLengthLimits()->maxDomainLabelLength; + $maxLabelLen = $this->options->lengthLimits->maxDomainLabelLength; foreach ($parts as $part) { if (strlen($part) > $maxLabelLen) { return ['valid' => false, 'reason' => "Domain name part '{$part}' must be less than {$maxLabelLen} octets", 'code' => Err::DomainLabelTooLong]; diff --git a/src/ParseContext.php b/src/ParseContext.php index 5565ada..8ee8876 100644 --- a/src/ParseContext.php +++ b/src/ParseContext.php @@ -14,8 +14,7 @@ * localPartNormalizer closure may call back into parse() mid-parse without * clobbering the outer parse's state. * - * The accumulator property names mirror the historical $emailAddress array keys - * so they thread through the validation helpers unchanged; the public output + * The accumulator uses camelCase property names; the public snake_case output * array shape is built separately in {@see Parse::addAddress()} and is * unaffected by this object. * @@ -24,45 +23,18 @@ */ final class ParseContext { - // --- Per-parse input snapshot and hoisted config (set once in parse(), - // never reset between addresses). --- - - /** @var array The input split into characters (see parse()). */ - public array $chars = []; - - /** Number of characters in $chars. */ - public int $len = 0; - - /** Whether multiple addresses are being parsed. */ - public bool $multiple = true; - - /** The original input string, retained for diagnostic logging. */ - public string $emails = ''; - - /** @var array Separator characters, as a lookup map. */ - public array $separators = []; - - /** @var array Banned characters, as a lookup map. */ - public array $bannedChars = []; - - /** Whether whitespace acts as an address separator. */ - public bool $useWhitespaceAsSeparator = false; - - /** @var array Insignificant (foldable/trimmable) whitespace, as a lookup map. */ - public array $allowedWhitespace = []; + // The per-parse input snapshot and hoisted config (chars, len, multiple, + // emails, separators, bannedChars, useWhitespaceAsSeparator, + // allowedWhitespace) are immutable for the whole parse — they are declared as + // `public readonly` constructor-promoted properties (see __construct below). // --- Loop control state (state/subState reset per address by parse()). --- - /** Current parser state (one of Parse::STATE_*). */ - public int $state = 0; + /** Current parser state. Initialized by the constructor / resetAddress(). */ + public ParserState $state; - /** - * Current parser sub-state within an addr-spec (one of Parse::STATE_*). - * Initialized by the constructor / resetAddress(); the literal 0 default is - * STATE_TRIM, not a valid starting sub-state (which is STATE_START), so it - * must never be relied on un-initialized. - */ - public int $subState = 0; + /** Current parser sub-state within an addr-spec. Set by the constructor / resetAddress(). */ + public ParserState $subState; /** Current comment nesting depth. */ public int $commentNestLevel = 0; @@ -70,62 +42,62 @@ final class ParseContext // --- Accumulator fields (reset per address via resetAddress()). --- /** Raw address as given, comments included. */ - public string $original_address = ''; + public string $originalAddress = ''; /** Display name without quotes. */ - public string $name_parsed = ''; + public string $nameParsed = ''; /** Local-part without quotes. */ - public string $local_part_parsed = ''; + public string $localPartParsed = ''; /** Domain after '@' (may be Unicode/U-label). */ public string $domain = ''; /** Punycode A-label domain, populated when it differs from $domain. */ - public ?string $domain_ascii = null; + public ?string $domainAscii = null; /** IP address if a domain-literal was used. */ public string $ip = ''; public bool $invalid = false; - public ?string $invalid_reason = null; + public ?string $invalidReason = null; - public ?ParseErrorCode $invalid_reason_code = null; + public ?ParseErrorCode $invalidReasonCode = null; - public bool $local_part_quoted = false; + public bool $localPartQuoted = false; - public bool $name_quoted = false; + public bool $nameQuoted = false; - public bool $address_temp_quoted = false; + public bool $addressTempQuoted = false; /** * True for exactly the character after a closing quote, so atext / a second * quote directly abutting a quoted-string can be rejected. */ - public bool $after_closing_quote = false; + public bool $afterClosingQuote = false; - public string $quote_temp = ''; + public string $quoteTemp = ''; - public string $address_temp = ''; + public string $addressTemp = ''; - public int $address_temp_period = 0; + public int $addressTempPeriod = 0; - public ?string $special_char_in_substate = null; + public ?string $specialCharInSubstate = null; - public string $comment_temp = ''; + public string $commentTemp = ''; /** * True for the character following an unescaped backslash inside a comment * (RFC 5322 §3.2.1 quoted-pair: "\)" and "\(" are literal, not structural). */ - public bool $comment_escaped = false; + public bool $commentEscaped = false; /** * True just after a comment closes mid-atom in the local part (atext already * accumulated), so the very next character can be inspected. */ - public bool $comment_after_local_atext = false; + public bool $commentAfterLocalAtext = false; /** * Set when atext resumes the atom after such a comment. Whether that is an @@ -133,7 +105,7 @@ final class ParseContext * addr-spec (resolved at '@' → reject, RFC 5322 §3.2.3) or a display-name * phrase where "word CFWS word" is legal (resolved at '<' → clear). */ - public bool $local_atom_split_by_comment = false; + public bool $localAtomSplitByComment = false; /** @var array Extracted RFC 5322 comments. */ public array $comments = []; @@ -142,24 +114,42 @@ final class ParseContext * True while the parser is inside angle-addr (between `<` and `>`). * Used to gate obs-route detection per RFC 5322 §4.4. */ - public bool $in_angle_addr = false; + public bool $inAngleAddr = false; /** * Accumulates the obs-route prefix (everything between `<` and the * terminating `:`) when ParseOptions::$allowObsRoute is true. * Empty string when no obs-route was seen. */ - public string $obs_route = ''; + public string $obsRoute = ''; /** - * @param int $state Initial parser state (a Parse::STATE_* value). - * @param int $subState Initial addr-spec sub-state (a Parse::STATE_* value). + * @param ParserState $state Initial parser state. + * @param ParserState $subState Initial addr-spec sub-state. + * @param array $chars Input split into characters. + * @param int $len Number of characters in $chars. + * @param bool $multiple Whether multiple addresses are being parsed. + * @param string $emails Original input string (retained for diagnostic logging). + * @param array $separators Separator characters, as a lookup map. + * @param array $bannedChars Banned characters, as a lookup map. + * @param bool $useWhitespaceAsSeparator Whether whitespace acts as an address separator. + * @param array $allowedWhitespace Insignificant (foldable/trimmable) whitespace, as a lookup map. */ - public function __construct(int $state, int $subState) - { - // Requiring the initial states makes an un-initialized context - // unrepresentable: every instance is reset before its first use, so no - // caller can start parsing from the misleading zero-value field defaults. + public function __construct( + ParserState $state, + ParserState $subState, + public readonly array $chars, + public readonly int $len, + public readonly bool $multiple, + public readonly string $emails, + public readonly array $separators, + public readonly array $bannedChars, + public readonly bool $useWhitespaceAsSeparator, + public readonly array $allowedWhitespace, + ) { + // Requiring the initial states + immutable snapshot makes an + // un-initialized context unrepresentable: config cannot be mutated by a + // handler, and every instance is reset before its first use. $this->resetAddress($state, $subState); } @@ -168,10 +158,10 @@ public function __construct(int $state, int $subState) * for the next address in a multi-address parse (matches the historical * "rebuild the $emailAddress array" behaviour). * - * @param int $state Parser state to start the next address in (Parse::STATE_*). - * @param int $subState Addr-spec sub-state to start it in (Parse::STATE_*). + * @param ParserState $state Parser state to start the next address in. + * @param ParserState $subState Addr-spec sub-state to start it in. */ - public function resetAddress(int $state, int $subState): void + public function resetAddress(ParserState $state, ParserState $subState): void { // Loop-control state, reset here so every per-address field has a single // source of truth. commentNestLevel in particular has no other reset: @@ -181,29 +171,29 @@ public function resetAddress(int $state, int $subState): void $this->subState = $subState; $this->commentNestLevel = 0; - $this->original_address = ''; - $this->name_parsed = ''; - $this->local_part_parsed = ''; + $this->originalAddress = ''; + $this->nameParsed = ''; + $this->localPartParsed = ''; $this->domain = ''; - $this->domain_ascii = null; + $this->domainAscii = null; $this->ip = ''; $this->invalid = false; - $this->invalid_reason = null; - $this->invalid_reason_code = null; - $this->local_part_quoted = false; - $this->name_quoted = false; - $this->address_temp_quoted = false; - $this->after_closing_quote = false; - $this->quote_temp = ''; - $this->address_temp = ''; - $this->address_temp_period = 0; - $this->special_char_in_substate = null; - $this->comment_temp = ''; - $this->comment_escaped = false; - $this->comment_after_local_atext = false; - $this->local_atom_split_by_comment = false; + $this->invalidReason = null; + $this->invalidReasonCode = null; + $this->localPartQuoted = false; + $this->nameQuoted = false; + $this->addressTempQuoted = false; + $this->afterClosingQuote = false; + $this->quoteTemp = ''; + $this->addressTemp = ''; + $this->addressTempPeriod = 0; + $this->specialCharInSubstate = null; + $this->commentTemp = ''; + $this->commentEscaped = false; + $this->commentAfterLocalAtext = false; + $this->localAtomSplitByComment = false; $this->comments = []; - $this->in_angle_addr = false; - $this->obs_route = ''; + $this->inAngleAddr = false; + $this->obsRoute = ''; } } diff --git a/src/ParseOptions.php b/src/ParseOptions.php index 93b840f..9cd3394 100644 --- a/src/ParseOptions.php +++ b/src/ParseOptions.php @@ -5,11 +5,11 @@ class ParseOptions { /** @var array */ - private array $bannedChars = []; + public readonly array $bannedChars; /** @var array */ - private array $separators = []; - private bool $useWhitespaceAsSeparator; - private LengthLimits $lengthLimits; + public readonly array $separators; + public readonly bool $useWhitespaceAsSeparator; + public readonly LengthLimits $lengthLimits; /** * Whitespace characters treated as insignificant (folding/separators in * multi-address mode; trimmable). A whitespace character outside this set is @@ -18,15 +18,15 @@ class ParseOptions * * @var array */ - private array $allowedWhitespace = []; + public readonly array $allowedWhitespace; /** * Construct a parser configuration. * - * The first four positional parameters preserve the v2.x / v3.0 signature for - * backward compatibility. The 15 rule properties following them are readonly - * (PHP 8.1) — mutate via the `withX()` fluent builders, which return new - * instances with the change applied. + * The first five positional parameters preserve the v2.x / v3.0 signature for + * backward compatibility. Every property is `readonly` (PHP 8.1) — configure a + * new instance via the `withX()` fluent builders, which return a copy with the + * change applied. (The deprecated mutating setters were removed in 4.0.) * * Default values match legacy (v2.x) parser behavior so `new ParseOptions()` * preserves existing call sites. @@ -85,22 +85,38 @@ public function __construct( public readonly bool $detectConfusableDomain = false, public readonly ?\Closure $localPartNormalizer = null, ) { + // Build the character lookup maps once and assign to the readonly + // properties (readonly forbids the incremental $this->x[$k] = ... writes). + $bannedMap = []; foreach ($bannedChars as $char) { - $this->bannedChars[$char] = true; + $bannedMap[$char] = true; } + $this->bannedChars = $bannedMap; + + $separatorMap = []; foreach ($separators as $sep) { - $this->separators[$sep] = true; + $separatorMap[$sep] = true; } + $this->separators = $separatorMap; + $this->useWhitespaceAsSeparator = $useWhitespaceAsSeparator; $this->lengthLimits = $lengthLimits ?? LengthLimits::createDefault(); + + $whitespaceMap = []; foreach ($allowedWhitespace as $ws) { - $this->allowedWhitespace[$ws] = true; + $whitespaceMap[$ws] = true; } + $this->allowedWhitespace = $whitespaceMap; } - /** @return array */ + /** + * @deprecated 4.0 Read the public readonly `$allowedWhitespace` property directly. Removed in 5.0. + * @return array + */ public function getAllowedWhitespace(): array { + trigger_deprecation('mmucklo/email-parse', '4.0', 'ParseOptions::getAllowedWhitespace() is deprecated, read the $allowedWhitespace property instead. It is removed in 5.0.'); + return $this->allowedWhitespace; } @@ -221,11 +237,10 @@ public static function rfc2822(): self // ===== Fluent builders ===== // - // The readonly rule properties cannot be reassigned. Each `withX()` method - // returns a new ParseOptions instance with the single field replaced and - // every other field preserved. The four non-readonly state fields - // (bannedChars, separators, useWhitespaceAsSeparator, lengthLimits) also - // have `withX()` builders for symmetry; they will become readonly in v4.0. + // Every property is readonly, so nothing can be reassigned in place. Each + // `withX()` method returns a new ParseOptions instance with the single field + // replaced and every other field preserved; this is the only way to derive + // a differently-configured instance. /** @param array $bannedChars */ public function withBannedChars(array $bannedChars): self @@ -431,77 +446,53 @@ private function cloneWith(array $overrides): self ); } - // ===== Legacy deprecated setters ===== + // ===== Accessors for the state fields ===== // - // These remain as mutating setters for the four non-readonly state fields - // only. They continue to work for v2.x callers; they will be removed in v4.0. + // The four getters below duplicate the public readonly properties they + // return; they are @deprecated in 4.0 (read the property) and removed in 5.0. + // The getMax*Length() helpers further down are not duplicates — they reach + // into $lengthLimits — and remain supported. /** - * @deprecated v3.0 — Use constructor param or withBannedChars(). Removed in v4.0. - * @param array $bannedChars + * @deprecated 4.0 Read the public readonly `$bannedChars` property directly. Removed in 5.0. + * @return array */ - public function setBannedChars(array $bannedChars): void - { - $this->bannedChars = []; - foreach ($bannedChars as $char) { - $this->bannedChars[$char] = true; - } - } - - /** @return array */ public function getBannedChars(): array { + trigger_deprecation('mmucklo/email-parse', '4.0', 'ParseOptions::getBannedChars() is deprecated, read the $bannedChars property instead. It is removed in 5.0.'); + return $this->bannedChars; } /** - * @deprecated v3.0 — Use constructor param or withSeparators(). Removed in v4.0. - * @param array $separators + * @deprecated 4.0 Read the public readonly `$separators` property directly. Removed in 5.0. + * @return array */ - public function setSeparators(array $separators): void - { - $this->separators = []; - foreach ($separators as $sep) { - $this->separators[$sep] = true; - } - } - - /** @return array */ public function getSeparators(): array { - return $this->separators; - } + trigger_deprecation('mmucklo/email-parse', '4.0', 'ParseOptions::getSeparators() is deprecated, read the $separators property instead. It is removed in 5.0.'); - /** @deprecated v3.0 — Use constructor param or withUseWhitespaceAsSeparator(). Removed in v4.0. */ - public function setUseWhitespaceAsSeparator(bool $value): void - { - $this->useWhitespaceAsSeparator = $value; + return $this->separators; } + /** + * @deprecated 4.0 Read the public readonly `$useWhitespaceAsSeparator` property directly. Removed in 5.0. + */ public function getUseWhitespaceAsSeparator(): bool { - return $this->useWhitespaceAsSeparator; - } + trigger_deprecation('mmucklo/email-parse', '4.0', 'ParseOptions::getUseWhitespaceAsSeparator() is deprecated, read the $useWhitespaceAsSeparator property instead. It is removed in 5.0.'); - /** @deprecated v3.0 — Use constructor param or withLengthLimits(). Removed in v4.0. */ - public function setLengthLimits(LengthLimits $limits): void - { - $this->lengthLimits = $limits; + return $this->useWhitespaceAsSeparator; } + /** + * @deprecated 4.0 Read the public readonly `$lengthLimits` property directly. Removed in 5.0. + */ public function getLengthLimits(): LengthLimits { - return $this->lengthLimits; - } + trigger_deprecation('mmucklo/email-parse', '4.0', 'ParseOptions::getLengthLimits() is deprecated, read the $lengthLimits property instead. It is removed in 5.0.'); - /** @deprecated v3.0 — Construct a new LengthLimits and pass it. Removed in v4.0. */ - public function setMaxLocalPartLength(int $value): void - { - $this->lengthLimits = new LengthLimits( - $value, - $this->lengthLimits->maxTotalLength, - $this->lengthLimits->maxDomainLabelLength, - ); + return $this->lengthLimits; } public function getMaxLocalPartLength(): int @@ -509,31 +500,11 @@ public function getMaxLocalPartLength(): int return $this->lengthLimits->maxLocalPartLength; } - /** @deprecated v3.0 — Construct a new LengthLimits and pass it. Removed in v4.0. */ - public function setMaxTotalLength(int $value): void - { - $this->lengthLimits = new LengthLimits( - $this->lengthLimits->maxLocalPartLength, - $value, - $this->lengthLimits->maxDomainLabelLength, - ); - } - public function getMaxTotalLength(): int { return $this->lengthLimits->maxTotalLength; } - /** @deprecated v3.0 — Construct a new LengthLimits and pass it. Removed in v4.0. */ - public function setMaxDomainLabelLength(int $value): void - { - $this->lengthLimits = new LengthLimits( - $this->lengthLimits->maxLocalPartLength, - $this->lengthLimits->maxTotalLength, - $value, - ); - } - public function getMaxDomainLabelLength(): int { return $this->lengthLimits->maxDomainLabelLength; diff --git a/src/ParserState.php b/src/ParserState.php new file mode 100644 index 0000000..aabdd5c --- /dev/null +++ b/src/ParserState.php @@ -0,0 +1,37 @@ +"`). + * Consumes characters from the leading `@` up to the `:` terminator, + * then resumes normal addr-spec parsing. + */ + case OBS_ROUTE = 12; +} diff --git a/tests/ParseTest.php b/tests/ParseTest.php index 0aa3186..960cc22 100644 --- a/tests/ParseTest.php +++ b/tests/ParseTest.php @@ -118,7 +118,11 @@ public function testParseEmailAddresses() $options = $this->buildOptions($test); $parser = new Parse(null, $options); - $actual = $parser->parse($emails, $multiple); + // Drive the typed API (not the deprecated parse()); toArray() reproduces + // the legacy array shape the fixtures assert against. + $actual = $multiple + ? $parser->parseMultiple($emails)->toArray() + : $parser->parseSingle($emails)->toArray(); // YAML tests written before ParseErrorCode landed omit `invalid_reason_code`. // Reconcile: where the expected entry doesn't mention the key, strip it from @@ -184,7 +188,7 @@ private function alignReasonCodeOne(array $expected, array $actual): array public function testParseSingleReturnsTypedObject(): void { - $result = Parse::getInstance()->parseSingle('john@example.com'); + $result = (new Parse())->parseSingle('john@example.com'); $this->assertInstanceOf(\Email\ParsedEmailAddress::class, $result); $this->assertSame('john', $result->localPart); $this->assertSame('example.com', $result->domain); @@ -195,14 +199,14 @@ public function testParseSingleReturnsTypedObject(): void public function testParseSingleInvalidCarriesErrorCode(): void { - $result = Parse::getInstance()->parseSingle('foo@bar@baz.com'); + $result = (new Parse())->parseSingle('foo@bar@baz.com'); $this->assertTrue($result->invalid); $this->assertSame(\Email\ParseErrorCode::MultipleAtSymbols, $result->invalidReasonCode); } public function testParseMultipleReturnsTypedResult(): void { - $result = Parse::getInstance()->parseMultiple('a@a.com, b@b.com'); + $result = (new Parse())->parseMultiple('a@a.com, b@b.com'); $this->assertInstanceOf(\Email\ParseResult::class, $result); $this->assertTrue($result->success); $this->assertNull($result->reason); @@ -214,7 +218,7 @@ public function testParseMultipleReturnsTypedResult(): void public function testParseMultipleFailureCarriesReason(): void { - $result = Parse::getInstance()->parseMultiple('a@a.com, not-an-email'); + $result = (new Parse())->parseMultiple('a@a.com, not-an-email'); $this->assertFalse($result->success); $this->assertNotNull($result->reason); $this->assertTrue($result->emailAddresses[1]->invalid); @@ -222,7 +226,7 @@ public function testParseMultipleFailureCarriesReason(): void public function testParsedEmailAddressCommentsAreExtracted(): void { - $result = Parse::getInstance()->parseSingle('user@example.com (home)'); + $result = (new Parse())->parseSingle('user@example.com (home)'); $this->assertSame(['home'], $result->comments); } @@ -636,7 +640,7 @@ public function testNonUtf8EncodingIsHonoredByTokenizer(): void { // "Jörg " with ö as the single ISO-8859-1 byte 0xF6. $input = "J\xF6rg "; - $result = Parse::getInstance()->parseSingle($input, 'ISO-8859-1'); + $result = (new Parse())->parseSingle($input, 'ISO-8859-1'); $this->assertFalse($result->invalid); $this->assertSame('j', $result->localPart); @@ -653,7 +657,7 @@ public function testNonUtf8EncodingIsHonoredByTokenizer(): void public function testVariableWidthEncodingIsHonoredByTokenizer(): void { $input = mb_convert_encoding('日本 ', 'SJIS', 'UTF-8'); - $result = Parse::getInstance()->parseSingle($input, 'SJIS'); + $result = (new Parse())->parseSingle($input, 'SJIS'); $this->assertFalse($result->invalid); $this->assertSame('j', $result->localPart); @@ -675,14 +679,14 @@ public function testMalformedUtf8LocalPartHandling(): void { $loneByte = pack('C', 0x80); // a lone UTF-8 continuation byte $input = 'us'.$loneByte.'er@example.com'; - $result = Parse::getInstance()->parseSingle($input); + $result = (new Parse())->parseSingle($input); if (mb_substr($loneByte, 0, 1, 'UTF-8') === $loneByte) { $this->assertTrue($result->invalid); $this->assertSame(\Email\ParseErrorCode::InvalidUtf8Encoding, $result->invalidReasonCode); } else { // Byte sanitized before validation; result must be deterministic. - $this->assertSame($result->invalid, Parse::getInstance()->parseSingle($input)->invalid); + $this->assertSame($result->invalid, (new Parse())->parseSingle($input)->invalid); } } @@ -693,7 +697,7 @@ public function testMalformedUtf8LocalPartHandling(): void */ public function testLengthBoundariesAcceptMaxAndRejectOneOver(): void { - $p = Parse::getInstance(); + $p = new Parse(); $Err = \Email\ParseErrorCode::class; // Local part: 64 octets is the maximum; 65 is over. @@ -719,7 +723,7 @@ public function testLengthBoundariesAcceptMaxAndRejectOneOver(): void */ public function testPunycodeConversionFailureIsReported(): void { - $result = Parse::getInstance()->parseSingle('user@'.str_repeat('ä', 70).'.de'); + $result = (new Parse())->parseSingle('user@'.str_repeat('ä', 70).'.de'); $this->assertTrue($result->invalid); $this->assertSame(\Email\ParseErrorCode::PunycodeConversionFailed, $result->invalidReasonCode); } @@ -732,7 +736,7 @@ public function testPunycodeConversionFailureIsReported(): void public function testMismatchedEncodingDomainFailsGracefully(): void { $input = mb_convert_encoding('user@日本.com', 'SJIS', 'UTF-8'); - $result = Parse::getInstance()->parseSingle($input, 'SJIS'); + $result = (new Parse())->parseSingle($input, 'SJIS'); $this->assertTrue($result->invalid); $this->assertNotNull($result->invalidReasonCode); } @@ -784,6 +788,11 @@ public function testAllFluentBuildersToggleTheTargetedField(): void ['withIncludeDomainAscii', true, 'includeDomainAscii'], ['withValidateDisplayNamePhrase', true, 'validateDisplayNamePhrase'], ['withStrictIdna', true, 'strictIdna'], + ['withAllowObsRoute', true, 'allowObsRoute'], + ['withTrimSingleAddressWhitespace', true, 'trimSingleAddressWhitespace'], + ['withStrictMultiWhitespace', true, 'strictMultiWhitespace'], + ['withRejectTrailingDot', true, 'rejectTrailingDot'], + ['withDetectConfusableDomain', true, 'detectConfusableDomain'], ['withUseWhitespaceAsSeparator', false, null], ]; foreach ($cases as [$method, $value, $property]) { @@ -806,52 +815,50 @@ public function testAllFluentBuildersToggleTheTargetedField(): void } /** - * Exercises the deprecated setters — they continue to work in v3.1 and - * will be removed in v4.0. Coverage-only; assertions verify round-trips. + * The state fields (bannedChars / separators / useWhitespaceAsSeparator / + * lengthLimits) are `public readonly`, configured via the constructor or the + * withX() builders. The deprecated mutating setters were removed in 4.0. */ - public function testDeprecatedSettersStillFunction(): void + public function testStateFieldsArePublicReadonlyAndConfigurable(): void { - $opts = new ParseOptions(); - $opts->setBannedChars(['%']); + $opts = (new ParseOptions()) + ->withBannedChars(['%']) + ->withSeparators([';']) + ->withUseWhitespaceAsSeparator(false) + ->withLengthLimits(new \Email\LengthLimits(10, 20, 5)); + + // Readable via the public readonly properties... + $this->assertSame(['%' => true], $opts->bannedChars); + $this->assertSame([';' => true], $opts->separators); + $this->assertFalse($opts->useWhitespaceAsSeparator); + $this->assertSame(10, $opts->lengthLimits->maxLocalPartLength); + + // ...and via the retained getters. $this->assertSame(['%' => true], $opts->getBannedChars()); - - $opts->setSeparators([';']); $this->assertSame([';' => true], $opts->getSeparators()); - - $opts->setUseWhitespaceAsSeparator(false); $this->assertFalse($opts->getUseWhitespaceAsSeparator()); - - $opts->setLengthLimits(new \Email\LengthLimits(10, 20, 5)); $this->assertSame(10, $opts->getMaxLocalPartLength()); $this->assertSame(20, $opts->getMaxTotalLength()); $this->assertSame(5, $opts->getMaxDomainLabelLength()); - - $opts->setMaxLocalPartLength(64); - $this->assertSame(64, $opts->getMaxLocalPartLength()); - // Other two limits preserved. - $this->assertSame(20, $opts->getMaxTotalLength()); - $this->assertSame(5, $opts->getMaxDomainLabelLength()); - - $opts->setMaxTotalLength(254); - $this->assertSame(254, $opts->getMaxTotalLength()); - - $opts->setMaxDomainLabelLength(63); - $this->assertSame(63, $opts->getMaxDomainLabelLength()); } /** - * Exercises the fluent and deprecated mutators on the Parse class itself. - * Pre-existing public API covered here for the first time. + * The mutators on the Parse class itself: setOptions() is deprecated but + * still fluent (removed in 5.0); setLogger() implements PSR-3 + * LoggerAwareInterface (returns void). */ - public function testParseSetLoggerAndSetOptionsAreFluent(): void + public function testParseSetOptionsFluentAndSetLoggerIsLoggerAware(): void { $parser = new Parse(); + $this->assertInstanceOf(\Psr\Log\LoggerAwareInterface::class, $parser); + $opts = ParseOptions::rfc5322(); $this->assertSame($parser, $parser->setOptions($opts), 'setOptions() is fluent'); $this->assertSame($opts, $parser->getOptions()); - $logger = new \Psr\Log\NullLogger(); - $this->assertSame($parser, $parser->setLogger($logger), 'setLogger() is fluent'); + // LoggerAwareInterface::setLogger() returns void; the injected logger is used. + $parser->setLogger(new \Psr\Log\NullLogger()); + $this->assertFalse($parser->parseSingle('a@b.com')->invalid); } /** @@ -869,7 +876,7 @@ public function testStructuralParseErrorsCarryExpectedCode(): void ]; foreach ($cases as [$input, $expected]) { - $result = Parse::getInstance()->parseSingle($input); + $result = (new Parse())->parseSingle($input); $this->assertTrue($result->invalid, "{$input} should be invalid"); $this->assertSame($expected, $result->invalidReasonCode, "{$input} wrong code"); } @@ -1009,7 +1016,7 @@ public function testMultipleInvalidAddressesReasonIsPlural(): void // When a batch contains two or more invalid addresses, the top-level // $reason becomes "Invalid email addresses" (plural) — the second-error // branch on line ~844 of Parse.php flips $reason from the singular form. - $result = Parse::getInstance()->parseMultiple('first-bad@, second-bad@'); + $result = (new Parse())->parseMultiple('first-bad@, second-bad@'); $this->assertFalse($result->success); $this->assertSame('Invalid email addresses', $result->reason); } @@ -1101,7 +1108,7 @@ public function testQuotedStringContentValidation(): void public function testValidAddressHasNullInvalidSeverity(): void { - $result = Parse::getInstance()->parseSingle('user@example.com'); + $result = (new Parse())->parseSingle('user@example.com'); $this->assertFalse($result->invalid); $this->assertNull($result->invalidSeverity()); } @@ -1109,7 +1116,7 @@ public function testValidAddressHasNullInvalidSeverity(): void public function testStructuralFailureIsCriticalSeverity(): void { // Missing '@' — structural failure, unparseable. - $result = Parse::getInstance()->parseSingle('not-an-email'); + $result = (new Parse())->parseSingle('not-an-email'); $this->assertTrue($result->invalid); $this->assertSame(\Email\ValidationSeverity::Critical, $result->invalidSeverity()); } @@ -1123,7 +1130,7 @@ public function testPolicyFailureIsWarningSeverity(): void $this->assertSame(\Email\ValidationSeverity::Warning, $result->invalidSeverity()); // Private-range IP literal is syntactically valid but rejected by the global-range rule. - $result = Parse::getInstance()->parseSingle('user@[192.168.0.1]'); + $result = (new Parse())->parseSingle('user@[192.168.0.1]'); $this->assertTrue($result->invalid); $this->assertSame(\Email\ValidationSeverity::Warning, $result->invalidSeverity()); } @@ -1290,7 +1297,7 @@ public function testEveryErrorCodeHasASeverity(): void public function testParseStreamYieldsTypedObjects(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); $gen = $parser->parseStream(['a@a.com', 'b@b.com']); $this->assertInstanceOf(\Generator::class, $gen); $results = iterator_to_array($gen, false); @@ -1304,7 +1311,7 @@ public function testParseStreamSplitsMultiAddressItems(): void { // Each input item may itself contain several comma-separated addresses; // parseStream yields one ParsedEmailAddress per address regardless. - $parser = Parse::getInstance(); + $parser = new Parse(); $results = iterator_to_array( $parser->parseStream(['a@a.com, b@b.com', 'c@c.com']), false, @@ -1321,7 +1328,7 @@ public function testParseStreamAcceptsGeneratorInput(): void yield 'two@example.com'; })(); - $results = iterator_to_array(Parse::getInstance()->parseStream($input), false); + $results = iterator_to_array((new Parse())->parseStream($input), false); $this->assertCount(2, $results); $this->assertSame('one', $results[0]->localPart); $this->assertSame('two', $results[1]->localPart); @@ -1331,7 +1338,7 @@ public function testParseStreamEmitsInvalidEntries(): void { // Invalid addresses still appear in the stream — callers filter by $addr->invalid. $results = iterator_to_array( - Parse::getInstance()->parseStream(['valid@ok.com', 'not-an-email']), + (new Parse())->parseStream(['valid@ok.com', 'not-an-email']), false, ); $this->assertCount(2, $results); @@ -1419,7 +1426,7 @@ public function testObsRouteWithEmptyAddrSpecIsInvalid(): void public function testValidAddressHasNullObsRoute(): void { // A normal address produces obsRoute=null (not empty string). - $result = Parse::getInstance()->parseSingle('user@example.com'); + $result = (new Parse())->parseSingle('user@example.com'); $this->assertNull($result->obsRoute); } @@ -1432,7 +1439,7 @@ public function testValidAddressHasNullObsRoute(): void public function testCfwsTrailingLocalPart(): void { // "local @domain" — trailing CFWS on local-part dot-atom. - $result = Parse::getInstance()->parseSingle('local @domain.com'); + $result = (new Parse())->parseSingle('local @domain.com'); $this->assertFalse($result->invalid); $this->assertSame('local', $result->localPart); $this->assertSame('domain.com', $result->domain); @@ -1441,7 +1448,7 @@ public function testCfwsTrailingLocalPart(): void public function testCfwsLeadingDomain(): void { // "local@ domain" — leading CFWS on domain dot-atom. - $result = Parse::getInstance()->parseSingle('local@ domain.com'); + $result = (new Parse())->parseSingle('local@ domain.com'); $this->assertFalse($result->invalid); $this->assertSame('local', $result->localPart); $this->assertSame('domain.com', $result->domain); @@ -1449,7 +1456,7 @@ public function testCfwsLeadingDomain(): void public function testCfwsAroundAtSymbol(): void { - $result = Parse::getInstance()->parseSingle('local @ domain.com'); + $result = (new Parse())->parseSingle('local @ domain.com'); $this->assertFalse($result->invalid); $this->assertSame('local', $result->localPart); $this->assertSame('domain.com', $result->domain); @@ -1458,7 +1465,7 @@ public function testCfwsAroundAtSymbol(): void public function testCfwsInsideAngleAddr(): void { // Whitespace inside <> flanking the addr-spec. - $result = Parse::getInstance()->parseSingle('John Doe < local@domain.com >'); + $result = (new Parse())->parseSingle('John Doe < local@domain.com >'); $this->assertFalse($result->invalid); $this->assertSame('John Doe', $result->nameParsed); $this->assertSame('local', $result->localPart); @@ -1467,7 +1474,7 @@ public function testCfwsInsideAngleAddr(): void public function testCfwsAroundAtInsideAngleAddr(): void { - $result = Parse::getInstance()->parseSingle(''); + $result = (new Parse())->parseSingle(''); $this->assertFalse($result->invalid); $this->assertSame('local', $result->localPart); $this->assertSame('domain.com', $result->domain); @@ -1498,7 +1505,7 @@ public function testToArrayRoundTripsLegacyShape(): void public function testToArrayPreservesErrorCode(): void { - $typed = Parse::getInstance()->parseSingle('not-an-email'); + $typed = (new Parse())->parseSingle('not-an-email'); $arr = $typed->toArray(); $this->assertTrue($arr['invalid']); $this->assertInstanceOf(\Email\ParseErrorCode::class, $arr['invalid_reason_code']); @@ -1506,7 +1513,7 @@ public function testToArrayPreservesErrorCode(): void public function testToJsonProducesParseableJson(): void { - $typed = Parse::getInstance()->parseSingle('user@example.com'); + $typed = (new Parse())->parseSingle('user@example.com'); $decoded = json_decode($typed->toJson(), true); $this->assertIsArray($decoded); $this->assertSame('user', $decoded['local_part']); @@ -1516,7 +1523,7 @@ public function testToJsonProducesParseableJson(): void public function testToJsonSerializesErrorCodeAsString(): void { // ParseErrorCode is a BackedEnum; json_encode emits its backing value. - $typed = Parse::getInstance()->parseSingle('<'); + $typed = (new Parse())->parseSingle('<'); $decoded = json_decode($typed->toJson(), true); $this->assertSame('multiple_opening_angle', $decoded['invalid_reason_code']); } @@ -1525,7 +1532,7 @@ public function testToJsonEmitsUnescapedUnicode(): void { // Asserts JSON_UNESCAPED_UNICODE is in the flag set — without it, "münchen" // would become "m\u00fcnchen". Catches bitwise-or regressions in toJson(). - $typed = Parse::getInstance()->parseSingle('user@münchen.de'); + $typed = (new Parse())->parseSingle('user@münchen.de'); $this->assertStringContainsString('münchen', $typed->toJson()); $this->assertStringNotContainsString('\u00', $typed->toJson()); } @@ -1533,33 +1540,33 @@ public function testToJsonEmitsUnescapedUnicode(): void public function testToJsonPassesCallerFlagsThrough(): void { // Caller-supplied flags must reach json_encode (bitwise-or, not &). - $typed = Parse::getInstance()->parseSingle('user@example.com'); + $typed = (new Parse())->parseSingle('user@example.com'); $pretty = $typed->toJson(JSON_PRETTY_PRINT); $this->assertStringContainsString("\n", $pretty); } public function testStringableReturnsSimpleAddressWhenValid(): void { - $typed = Parse::getInstance()->parseSingle('"J Doe" '); + $typed = (new Parse())->parseSingle('"J Doe" '); $this->assertSame('john@example.com', (string) $typed); } public function testStringableReturnsEmptyStringWhenInvalid(): void { - $typed = Parse::getInstance()->parseSingle('not-an-email'); + $typed = (new Parse())->parseSingle('not-an-email'); $this->assertSame('', (string) $typed); } public function testCanonicalAddrSpecWithoutName(): void { - $typed = Parse::getInstance()->parseSingle('john@example.com'); + $typed = (new Parse())->parseSingle('john@example.com'); $this->assertSame('john@example.com', $typed->canonical()); } public function testCanonicalAddrSpecWithSimpleName(): void { // Atext-only name needs no quotes. - $typed = Parse::getInstance()->parseSingle('John Doe '); + $typed = (new Parse())->parseSingle('John Doe '); $this->assertSame('John Doe ', $typed->canonical()); } @@ -1567,27 +1574,27 @@ public function testCanonicalStripsUnnecessaryNameQuotes(): void { // Input had quotes; canonical form drops them because the name is // pure atext+WSP and quoting is not required per RFC 5322 §3.2.5. - $typed = Parse::getInstance()->parseSingle('"John Doe" '); + $typed = (new Parse())->parseSingle('"John Doe" '); $this->assertSame('John Doe ', $typed->canonical()); } public function testCanonicalKeepsRequiredNameQuotes(): void { // Period in display name requires quoting (it's not atext). - $typed = Parse::getInstance()->parseSingle('"John Q. Public" '); + $typed = (new Parse())->parseSingle('"John Q. Public" '); $this->assertSame('"John Q. Public" ', $typed->canonical()); } public function testCanonicalQuotesLocalPartWhenRequired(): void { // Local-part with a space must be quoted per RFC 5322 §3.2.4. - $typed = Parse::getInstance()->parseSingle('"with space"@example.com'); + $typed = (new Parse())->parseSingle('"with space"@example.com'); $this->assertSame('"with space"@example.com', $typed->canonical()); } public function testCanonicalReturnsEmptyForInvalidAddress(): void { - $typed = Parse::getInstance()->parseSingle('not-an-email'); + $typed = (new Parse())->parseSingle('not-an-email'); $this->assertSame('', $typed->canonical()); } @@ -1602,7 +1609,7 @@ public function testParseResultToArrayRoundTripsLegacyShape(): void public function testParseResultToJsonProducesParseableJson(): void { - $typed = Parse::getInstance()->parseMultiple('a@a.com, b@b.com'); + $typed = (new Parse())->parseMultiple('a@a.com, b@b.com'); $decoded = json_decode($typed->toJson(), true); $this->assertTrue($decoded['success']); $this->assertCount(2, $decoded['email_addresses']); @@ -1611,14 +1618,14 @@ public function testParseResultToJsonProducesParseableJson(): void public function testParseResultToJsonEmitsUnescapedUnicode(): void { - $typed = Parse::getInstance()->parseMultiple('user@münchen.de'); + $typed = (new Parse())->parseMultiple('user@münchen.de'); $this->assertStringContainsString('münchen', $typed->toJson()); $this->assertStringNotContainsString('\u00', $typed->toJson()); } public function testParseResultToJsonPassesCallerFlagsThrough(): void { - $typed = Parse::getInstance()->parseMultiple('a@a.com, b@b.com'); + $typed = (new Parse())->parseMultiple('a@a.com, b@b.com'); $this->assertStringContainsString("\n", $typed->toJson(JSON_PRETTY_PRINT)); } @@ -1728,31 +1735,194 @@ public function testParserIsReentrantAcrossLocalPartNormalizer(): void } /** - * Backward compatibility: validateLocalPart() keeps its original array - * signature as a deprecated extension point (removed in 4.0). A subclass - * override must still be invoked and able to change the outcome — the parser - * dispatches through $this->validateLocalPart(), not a renamed internal. + * The deprecated raw-array parse() must stay byte-identical to the typed + * API's ->toArray(). The spec fixtures now drive the typed path, so this is + * the oracle that guards the legacy array contract (until parse()'s 5.0 + * removal) and pins parse() == parseSingle()/parseMultiple()->toArray(). */ - public function testDeprecatedValidateLocalPartOverrideStillTakesEffect(): void + public function testDeprecatedParseEqualsTypedToArray(): void + { + $parser = new Parse(null, ParseOptions::rfc5322()); + + // Single mode: valid, invalid, name-addr, IP-literal, comment. + foreach ([ + '"J Doe" ', + 'not-an-email', + 'plain@example.com', + 'ip@[8.8.8.8]', + 'c@example.com (note)', + ] as $in) { + $this->assertSame( + $parser->parse($in, false), + $parser->parseSingle($in)->toArray(), + "parse('{$in}', false) must equal parseSingle()->toArray()", + ); + } + + // Multiple mode: a mixed batch (valid + invalid + name-addr). + $batch = 'a@a.com, bad@, "Q" '; + $this->assertSame( + $parser->parse($batch, true), + $parser->parseMultiple($batch)->toArray(), + 'parse(batch, true) must equal parseMultiple()->toArray()', + ); + } + + /** + * getInstance() is deprecated (removed in 5.0) but still a working public + * method: it returns a shared Parse configured with default options. + */ + public function testGetInstanceReturnsSharedDefaultInstance(): void { - $parser = new class () extends Parse { - protected function validateLocalPart(array $emailAddress): array - { - if ('blocked' === $emailAddress['local_part_parsed']) { - return ['valid' => false, 'reason' => 'blocked local part', 'code' => null, 'normalized' => null]; - } + $a = Parse::getInstance(); + $b = Parse::getInstance(); - return parent::validateLocalPart($emailAddress); - } - }; + $this->assertInstanceOf(Parse::class, $a); + $this->assertSame($a, $b, 'getInstance() must return the same shared instance'); - // Un-blocked address flows through parent::validateLocalPart() unchanged. - $ok = $parser->parseSingle('allowed@example.com'); - $this->assertFalse($ok->invalid); + // Usable and applies the default (LEGACY) options. + $this->assertFalse($a->parseSingle('john@example.com')->invalid); + } + + /** + * A quoted UTF-8 local part under an ASCII-only preset (rfc5321) is accepted + * structurally by the parser, then rejected by the validation gate with the + * precise code — exercises the allowUtf8LocalPart check in validateLocalPart(). + */ + public function testQuotedUtf8LocalPartRejectedUnderAsciiPreset(): void + { + $result = (new Parse(null, ParseOptions::rfc5321()))->parseSingle('"münchen"@example.com'); + + $this->assertTrue($result->invalid); + $this->assertSame(\Email\ParseErrorCode::Utf8NotAllowedInLocalPart, $result->invalidReasonCode); + } + + /** + * When the local-part normalizer rewrites a *quoted* local part, the display + * form is re-derived with the quotes preserved — exercises the re-quote + * branch after normalization. + */ + public function testQuotedLocalPartIsRequotedAfterNormalizer(): void + { + $opts = ParseOptions::rfc5322() + ->withLocalPartNormalizer(fn (string $local, string $domain): string => strtolower($local)); + $result = (new Parse(null, $opts))->parseSingle('"John Doe"@example.com'); + + $this->assertFalse($result->invalid); + $this->assertSame('john doe', $result->localPartParsed); + // Quotes preserved because the local part still needs quoting. + $this->assertSame('"john doe"', $result->localPart); + } + + /** + * A display name mixing a quoted word and unquoted atext — the quoted run is + * flushed into the parsed name and parsing continues (exercises the NAME + * sub-state quote-flush branch). + */ + public function testDisplayNameMixesQuotedAndUnquotedWords(): void + { + $result = (new Parse(null, ParseOptions::rfc5322()))->parseSingle('"J" Doe '); - // The override fires and rejects an otherwise-valid address. - $blocked = $parser->parseSingle('blocked@example.com'); - $this->assertTrue($blocked->invalid, 'subclass validateLocalPart() override was not honored'); - $this->assertSame('blocked local part', $blocked->invalidReason); + $this->assertFalse($result->invalid); + $this->assertSame('J Doe', $result->nameParsed); + $this->assertSame('j@example.com', $result->simpleAddress); + } + + /** + * IP-literal domains are validated against the global range when + * validateIpGlobalRange is on (rfc5321): private/reserved IPv4 and IPv6 + * literals are rejected, a globally-routable one is accepted. + */ + public function testIpLiteralGlobalRangeValidation(): void + { + $parser = new Parse(null, ParseOptions::rfc5321()); + + $this->assertSame( + \Email\ParseErrorCode::IpNotInGlobalRange, + $parser->parseSingle('user@[192.168.1.1]')->invalidReasonCode, + ); + $this->assertSame( + \Email\ParseErrorCode::Ipv6NotInGlobalRange, + $parser->parseSingle('user@[IPv6:fe80::1]')->invalidReasonCode, + ); + $this->assertFalse($parser->parseSingle('user@[8.8.8.8]')->invalid); + } + + /** + * A quoted word in the *middle* of an unquoted display-name phrase — atext, + * then a quoted-string, then more atext — flushes the quoted run into the + * parsed name (RFC 5322 §3.2.5 phrase = 1*word). + */ + public function testDisplayNameWithMidStringQuotedWord(): void + { + $result = (new Parse(null, ParseOptions::rfc5322()))->parseSingle('John "The Man" Smith '); + + $this->assertFalse($result->invalid); + $this->assertSame('John The Man Smith', $result->nameParsed); + $this->assertSame('j@example.com', $result->simpleAddress); + } + + /** + * An unquoted local part in NFD form under rfc6531 is NFC-normalized, and the + * display form is re-derived from the normalized value (RFC 6532 §3.1). + */ + public function testUnquotedLocalPartIsNfcNormalized(): void + { + // "cafe" + U+0301 (combining acute) → NFC "café" (U+00E9) + $nfd = "cafe\xCC\x81@example.com"; + $result = (new Parse(null, ParseOptions::rfc6531()))->parseSingle($nfd); + + $this->assertFalse($result->invalid); + $this->assertSame("caf\u{00E9}", $result->localPartParsed); + } + + /** + * Every deprecated public method emits a runtime E_USER_DEPRECATED notice + * (via symfony/deprecation-contracts' trigger_deprecation), so callers see + * it and tools like symfony/phpunit-bridge can aggregate it. + */ + public function testDeprecatedMethodsTriggerRuntimeDeprecations(): void + { + $seen = []; + set_error_handler(static function (int $errno, string $message) use (&$seen): bool { + if (E_USER_DEPRECATED !== $errno) { + return false; // anything else falls through to PHPUnit's handler + } + $seen[] = $message; + + return true; + }); + + try { + $opts = ParseOptions::rfc5322(); + $parser = new Parse(null, $opts); + + $parser->parse('a@b.com', false); + Parse::getInstance(); + $parser->setOptions($opts); + $opts->getBannedChars(); + $opts->getSeparators(); + $opts->getUseWhitespaceAsSeparator(); + $opts->getLengthLimits(); + $opts->getAllowedWhitespace(); + } finally { + restore_error_handler(); + } + + $joined = implode("\n", $seen); + foreach ([ + 'Parse::parse()', + 'Parse::getInstance()', + 'Parse::setOptions()', + 'ParseOptions::getBannedChars()', + 'ParseOptions::getSeparators()', + 'ParseOptions::getUseWhitespaceAsSeparator()', + 'ParseOptions::getLengthLimits()', + 'ParseOptions::getAllowedWhitespace()', + ] as $needle) { + $this->assertStringContainsString($needle, $joined, "expected a deprecation naming {$needle}"); + } + // trigger_deprecation prefixes the package + version. + $this->assertStringContainsString('Since mmucklo/email-parse 4.0:', $joined); } } diff --git a/tests/PropertyTest.php b/tests/PropertyTest.php index 91d9be9..26cecf6 100644 --- a/tests/PropertyTest.php +++ b/tests/PropertyTest.php @@ -78,7 +78,7 @@ private function randomEmailLike(): string */ public function testParseSingleNeverThrows(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); for ($i = 0; $i < self::ITERATIONS; $i++) { $input = $this->randomString(); $result = $parser->parseSingle($input); @@ -91,7 +91,7 @@ public function testParseSingleNeverThrows(): void */ public function testParseMultipleNeverThrows(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); for ($i = 0; $i < self::ITERATIONS; $i++) { $result = $parser->parseMultiple($this->randomString()); $this->assertIsBool($result->success); @@ -118,7 +118,7 @@ public function testParseIsDeterministic(): void */ public function testInvalidImpliesBothReasonAndCode(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); for ($i = 0; $i < self::ITERATIONS; $i++) { $s = $this->randomString(); $r = $parser->parseSingle($s); @@ -137,7 +137,7 @@ public function testInvalidImpliesBothReasonAndCode(): void */ public function testInvalidAlwaysHasSeverity(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); for ($i = 0; $i < self::ITERATIONS; $i++) { $r = $parser->parseSingle($this->randomString()); if ($r->invalid) { @@ -153,7 +153,7 @@ public function testInvalidAlwaysHasSeverity(): void */ public function testStringableContract(): void { - $parser = Parse::getInstance(); + $parser = new Parse(); for ($i = 0; $i < self::ITERATIONS; $i++) { $r = $parser->parseSingle($this->randomString()); $expected = $r->invalid ? '' : $r->simpleAddress; diff --git a/tests/RectorUpgradeTest.php b/tests/RectorUpgradeTest.php new file mode 100644 index 0000000..fe5d417 --- /dev/null +++ b/tests/RectorUpgradeTest.php @@ -0,0 +1,56 @@ + 4.0 Rector config against a fixture that exercises + * every ownership branch of the setter rule (owned variable, withX() chain, + * escape by argument / copy, ->getOptions() alias, parameter, own property), + * the nullsafe getter rewrite, and getInstance(). The expected output is the + * contract users get from `rector process --config rector/upgrade-4.0.php`. + */ +final class RectorUpgradeTest extends TestCase +{ + public function testUpgradeConfigRewritesFixtureAsDocumented(): void + { + $root = \dirname(__DIR__); + $rector = $root.'/bin/rector'; + if (!is_executable($rector)) { + $this->markTestSkipped('rector/rector is not installed (dev dependency)'); + } + + $work = sys_get_temp_dir().'/email-parse-rector-'.bin2hex(random_bytes(4)); + mkdir($work); + $target = $work.'/upgrade.php'; + copy(__DIR__.'/fixtures/rector/upgrade-4.0.input.php', $target); + + try { + $cmd = sprintf( + '%s process %s --config %s --clear-cache --no-progress-bar --no-ansi 2>&1', + escapeshellarg($rector), + escapeshellarg($target), + escapeshellarg($root.'/rector/upgrade-4.0.php'), + ); + exec($cmd, $output, $exit); + $this->assertSame(0, $exit, "rector failed:\n".implode("\n", $output)); + + $this->assertStringEqualsFile( + __DIR__.'/fixtures/rector/upgrade-4.0.expected.php', + (string) file_get_contents($target), + ); + + // Idempotent: a second run must not re-annotate or re-rewrite. + exec($cmd, $output2, $exit2); + $this->assertSame(0, $exit2); + $this->assertStringEqualsFile( + __DIR__.'/fixtures/rector/upgrade-4.0.expected.php', + (string) file_get_contents($target), + ); + } finally { + @unlink($target); + @rmdir($work); + } + } +} diff --git a/tests/fixtures/rector/upgrade-4.0.expected.php b/tests/fixtures/rector/upgrade-4.0.expected.php new file mode 100644 index 0000000..e85c35c --- /dev/null +++ b/tests/fixtures/rector/upgrade-4.0.expected.php @@ -0,0 +1,61 @@ +withBannedChars(['%']); +$b = ParseOptions::rfc5322(); +$b = $b->withSeparators([';']); +$c = ParseOptions::rfc5321()->withRequireFqdn(false); +$c = $c->withUseWhitespaceAsSeparator(false); + +// escape by argument: after new Parse(null, $d) the parser shares $d +$d = new ParseOptions(); +$parser = new Parse(null, $d); +// TODO email-parse 4.0: setSeparators() was removed and this ParseOptions is not owned here (a parameter, +// a ->getOptions() result, or already shared). ParseOptions is immutable: configure it where +// it is created, e.g. new Parse($logger, ParseOptions::rfc5322()->withSeparators(...)). See UPGRADE.md. +$d->setSeparators([';']); + +// escape by copy +$e = new ParseOptions(); +$f = $e; +// TODO email-parse 4.0: setBannedChars() was removed and this ParseOptions is not owned here (a parameter, +// a ->getOptions() result, or already shared). ParseOptions is immutable: configure it where +// it is created, e.g. new Parse($logger, ParseOptions::rfc5322()->withBannedChars(...)). See UPGRADE.md. +$e->setBannedChars(['!']); + +// aliased: not created here +$g = $parser->getOptions(); +// TODO email-parse 4.0: setLengthLimits() was removed and this ParseOptions is not owned here (a parameter, +// a ->getOptions() result, or already shared). ParseOptions is immutable: configure it where +// it is created, e.g. new Parse($logger, ParseOptions::rfc5322()->withLengthLimits(...)). See UPGRADE.md. +$g->setLengthLimits(new \Email\LengthLimits(64, 254, 63)); + +function configure(ParseOptions $p): void +{ + // TODO email-parse 4.0: setBannedChars() was removed and this ParseOptions is not owned here (a parameter, + // a ->getOptions() result, or already shared). ParseOptions is immutable: configure it where + // it is created, e.g. new Parse($logger, ParseOptions::rfc5322()->withBannedChars(...)). See UPGRADE.md. + $p->setBannedChars(['%']); // parameter: never owned + $q = new ParseOptions(); + $q = $q->withSeparators([',']); // owned inside the function +} + +final class Holder +{ + private ParseOptions $opts; + public function tune(): void + { + $this->opts = $this->opts->withSeparators([';']); // own property + } +} + +// getters, incl. nullsafe; getMax* must stay +$x = $a->bannedChars; +$y = $a?->separators; +$z = $a->getMaxLocalPartLength(); +$s = new \Email\Parse(); diff --git a/tests/fixtures/rector/upgrade-4.0.input.php b/tests/fixtures/rector/upgrade-4.0.input.php new file mode 100644 index 0000000..ab6662c --- /dev/null +++ b/tests/fixtures/rector/upgrade-4.0.input.php @@ -0,0 +1,49 @@ +setBannedChars(['%']); +$b = ParseOptions::rfc5322(); +$b->setSeparators([';']); +$c = ParseOptions::rfc5321()->withRequireFqdn(false); +$c->setUseWhitespaceAsSeparator(false); + +// escape by argument: after new Parse(null, $d) the parser shares $d +$d = new ParseOptions(); +$parser = new Parse(null, $d); +$d->setSeparators([';']); + +// escape by copy +$e = new ParseOptions(); +$f = $e; +$e->setBannedChars(['!']); + +// aliased: not created here +$g = $parser->getOptions(); +$g->setLengthLimits(new \Email\LengthLimits(64, 254, 63)); + +function configure(ParseOptions $p): void +{ + $p->setBannedChars(['%']); // parameter: never owned + $q = new ParseOptions(); + $q->setSeparators([',']); // owned inside the function +} + +final class Holder +{ + private ParseOptions $opts; + public function tune(): void + { + $this->opts->setSeparators([';']); // own property + } +} + +// getters, incl. nullsafe; getMax* must stay +$x = $a->getBannedChars(); +$y = $a?->getSeparators(); +$z = $a->getMaxLocalPartLength(); +$s = Parse::getInstance();