diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f9bd7f2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`netlogix/xml-processor` — a PHP library that streams XML files through `\XMLReader` and dispatches events per node, so huge XML files can be processed with low, constant memory usage instead of loading a full DOM. PSR-4 namespace root: `Netlogix\XmlProcessor\` → `src/`. + +## Commands + +```bash +composer install # install dependencies +composer test # full suite: unit + behat +composer test:unit # PHPUnit only (composer test:unit -- --filter=testName for a single test) +composer test:behat # Behat behaviour specs only +composer test:coverage # Xdebug-based coverage, writes .coverage/ HTML (requires Xdebug) +composer lint # mago lint (static analysis) +composer format # mago fmt --dry-run +composer format:fix # mago fmt (apply formatting) +composer apply-coding-standard # composer:normalize:fix + rector:fix + format:fix, full pipeline +``` + +Run a single PHPUnit test directly with `vendor/bin/phpunit --filter tests/Unit/...` when iterating. + +CI (`.github/workflows/ci.yml`) runs the matrix across PHP 7.4–8.5 on every PR, then PHPUnit (with coverage) and Behat. Keep changes compatible with PHP 7.4+ (`symfony/polyfill-php80` is a real dependency, not incidental) — Rector (`rector.php`) is configured to target PHP 7.4 and enforces this. + +## Architecture + +**Event-driven streaming, not a DOM.** `XmlProcessor::processFile()` (`src/XmlProcessor.php`) opens the file with `\XMLReader` and loops `$xml->read()`, translating each `XMLReader` node type into an event name (`XmlProcessor::NODE_TYPE_*`, e.g. `NodeType_1` for `ELEMENT`) plus two synthetic events, `openFile` and `endOfFile`. Node processors subscribe to these events by node path and event name; matched callables are invoked with a context object. + +**Node processors** implement `NodeProcessorInterface::getSubscribedEvents(string $nodePath, XmlProcessorContext $context): iterable` and yield `[eventName => callable]` pairs. Most processors instead extend `AbstractNodeProcessor` (`src/NodeProcessor/AbstractNodeProcessor.php`), define a `const NODE_PATH`, and implement one or more of: +- `OpenNodeProcessorInterface::openElement(OpenContext $context)` — start tag +- `CloseNodeProcessorInterface::closeElement(CloseContext $context)` — end tag +- `TextNodeProcessorInterface::textElement(TextContext $context)` — text content + +`AbstractNodeProcessor::isNode()` matches the current node path against `NODE_PATH` via `XmlProcessor::checkNodePath()`, which accepts an exact path, a leading-`/`-anchored root path, or a path suffix — so `NODE_PATH` can target a node regardless of its ancestry, or anchor it to the document root. + +**Context objects** (`src/NodeProcessor/Context/`) carry per-callback state and form a small hierarchy: `NodeProcessorContext` (base: current node path, access to `XmlProcessorContext`) → `AbstractElementContext` (adds `selfClosing`) → `OpenContext` (adds parsed attributes) / `CloseContext`. `TextContext` extends `NodeProcessorContext` directly and adds the text value. `XmlProcessor::createContext()` picks the right subclass and populates it before invoking processors — do not add new context state without also wiring it there. + +**`XmlProcessorContext`** (`src/XmlProcessorContext.php`) is the object handed to `getSubscribedEvents()` and reachable from every `NodeProcessorContext`. It exposes the raw `\XMLReader`, lets processors look up sibling processors by class (`getProcessor()`), and exposes `skipCurrentNode()` — a closure-based hook into `XmlProcessor` that lets a processor abort processing of the current element (used together with `setSkipNodes()`/`checkNodePath()` for path-based skip rules). + +**Event dispatch caching.** `XmlProcessor::getProcessorForEvent()` memoizes, per node path + event, the list of matching processor callables in `$this->eventCache` — subscriptions are only recomputed the first time a given path/event pair is seen. Any change to how `getSubscribedEvents()` decides subscriptions must stay a pure function of `($nodePath, $context)`, since results are cached across repeated visits to the same path. + +**Skip and whitelist controls**, set on `XmlProcessor` before `processFile()`: +- `setSkipNodes(?array $skipNodes)` — paths (matched via `checkNodePath`) whose subtree is skipped entirely (fast-forwarded via `$xml->next()`). +- `setWhitelistEvents(?array $whitelistEvents)` — if non-null, only these event names are dispatched at all; everything else is a no-op, independent of processor subscriptions. + +**Self-closing elements** are detected via `$xml->isEmptyElement` at `ELEMENT` time; `XmlProcessor` synthesizes an immediate `eventCloseElement()` for them (and for skipped nodes) since `\XMLReader` never emits a separate `END_ELEMENT` for ``. + +## Testing conventions + +- Unit tests live in `tests/Unit/...`, mirroring `src/` namespaces; fixtures (test XML, fixture processor classes) live in `tests/Fixtures//`. +- Behat scenarios (`features/*.feature`) exercise processors under `Netlogix\XmlProcessor\Behat\NodeProcessor` (defined in `features/NodeProcessor/`) via `features/bootstrap/FeatureContext.php`; these are also unit-tested directly under `tests/Unit/Behat/NodeProcessor/`. +- `performance/` holds standalone benchmark scripts (not part of the test suite) for checking throughput/memory on large XML fixtures — run them directly with `php performance/test.php`, not via Composer/PHPUnit.