Skip to content

Refactor: decompose Parse::parse() into ParseContext + per-state handlers - #71

Open
mmucklo wants to merge 4 commits into
masterfrom
refactor/parse-decompose
Open

Refactor: decompose Parse::parse() into ParseContext + per-state handlers#71
mmucklo wants to merge 4 commits into
masterfrom
refactor/parse-decompose

Conversation

@mmucklo

@mmucklo mmucklo commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Decomposes the ~772-line Parse::parse() state machine. Pure behavior-preserving refactor — no parsing logic, conditions, ordering, error codes, or output shape changed.

What changed

  • parse(): 772 → 193 lines — setup + a thin switch ($ctx->state) dispatching to one handler per state, plus post-loop finalization.
  • New src/ParseContext.php — a per-parse object holding the ~24 accumulator fields + loop control + hoisted input/config as typed properties. Created fresh per parse() call and never stored on the Parse instance, so reentrancy is preserved (the localPartNormalizer callback can re-enter parse()).
  • 10 extracted handlers (handleStateTrim, handleStateAddress, handleStateQuote, handleStateComment, …); the hot atext/period branches are inlined for performance.

Independent verification (not just the author's report)

  • Correctness: 108 tests pass on default and SEED=1/42/1337/99999; PHPStan level 8, Psalm, and CS all clean. Reentrancy confirmed with a localPartNormalizer that re-enters parse() — returns the correct result, no context corruption.
  • Performance: measured a min-of-5 throughput A/B (refactored vs pre-refactor master), Xdebug off. Refactored is faster — 1.88s vs 3.18s for 125k parses. (The sandbox's default config has Xdebug on + opcache-CLI off, which penalizes method calls; that instrumentation artifact accounts for any interpreted slowdown seen without it.)
  • Psalm baseline grew by a few entries — all the same state-machine literal-narrowing false-positives on $ctx->state (incl. the defensive default: dead-code case), not real issues.

Review notes

  • ParseContext keeps the old array keys as snake_case property names (original_address, local_part_parsed, …) to keep the conversion mechanical and behavior-identical. Idiomatic camelCase could be a follow-up.

Replace the ~24-key $emailAddress accumulator array threaded through
parse() and its validation helpers with a typed ParseContext object.
Property names mirror the former array keys so the change is a pure
mechanical conversion with no behaviour change.

A fresh ParseContext is created per parse() call and reset per address
via resetAddress(); it is never stored on the Parse instance, preserving
reentrancy across a localPartNormalizer callback.

Type the addAddress() parameters (array/ParseContext/int) and drop the
always-true isset() guard on the non-nullable domain property; refresh
the PHPStan and Psalm baselines to drop the now-obsolete array-shape
entries.
Decompose the ~772-line parse() state machine into a thin switch that
dispatches to one handler per parser state, plus sub-handlers for the
heavy STATE_ADDRESS branches (CFWS, '@', '.', atext, non-atext). parse()
is now ~190 lines.

Loop control (state/subState/commentNestLevel) and the hoisted input and
config move onto ParseContext so the handlers read them without long
parameter lists; behaviour, error codes and output are unchanged. Refresh
the Psalm baseline for the state-machine narrowing false-positives that
shift when the discriminant becomes a context property (PHPStan level 8
handles the mutation across calls and stays clean).
Fold the atext and period handling back inline into handleStateAddress so
the dominant STATE_ADDRESS path makes a single method call per character
instead of two. The larger, less-frequent branches (CFWS whitespace, '@',
non-atext) stay in their own helpers.

Behaviour is unchanged (full suite still green). Under opcache+JIT the
batch-parsing benchmarks now run at or below the pre-refactor baseline.
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.74%. Comparing base (39cf648) to head (f9cd672).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##             master      #71      +/-   ##
============================================
- Coverage     96.01%   95.74%   -0.27%     
- Complexity      434      445      +11     
============================================
  Files             6        7       +1     
  Lines          1078     1105      +27     
============================================
+ Hits           1035     1058      +23     
- Misses           43       47       +4     
Files with missing lines Coverage Δ
src/Parse.php 94.05% <ø> (-0.54%) ⬇️
src/ParseContext.php 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Post-review cleanup for the parse() decomposition.

- Rename the parse-state local (and every handler parameter) from
  $emailAddress to $ctx: it holds a ParseContext, not an address, and
  the old name shadowed the concept of the address being built. Pure
  mechanical rename, no behavior change.

- Make ParseContext::resetAddress(int $state, int $subState) the single
  source of truth for per-address reset. It now also sets state/subState
  and zeroes commentNestLevel, which nothing reset before: an
  unterminated comment could leak its nesting level into the next address
  in a batch, self-healing only because '(' reassigns it to 1. Both the
  initial setup and the per-address reset in parse() now go through it.

Roadmap: mark the parse() readability refactor delivered and record the
remaining non-blocking follow-ups (snake_case fields, structural split of
the context's three concerns, chars/len duplication, handleStateAddress).

108 tests / 7199 assertions, PHPStan and CS clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant