Imported from Chemaclass/edifact-parser (
AGENTS.md). Install upstream withnpx skills add Chemaclass/edifact-parser. Copyright stays with the author.
AGENTS.md
AI context for understanding this EDIFACT parser library architecture.
Core Architecture
Parsing flow: EdifactParser → SegmentFactory → ParserResult
-
EdifactParser (
src/EdifactParser.php)- Entry point; tokenizing is pluggable via
Tokenizer\TokenizerInterfaceNativeTokenizer(default) — regex-free single pass, ~1.8x faster tokenizing, losslessSabasTokenizer— wrapssabas/edifact, the 6.x default. Strips every byte in \x80-\xFF, so it destroys non-ASCII data. Kept for bug-for-bug compatibility.- Kept honest by
TokenizerEquivalenceTest, which diffsNativeTokenizeragainstEDI\Parserover fixtures plus a generated corpus; extend it before touching either. It compares raw tokenization, not error policy — some fixtures are deliberately malformed andSabasTokenizerrejects them.
- Delegates to SegmentFactory for typed segment objects
- Returns ParserResult
- Entry point; tokenizing is pluggable via
-
SegmentFactory (
src/Segments/SegmentFactory.php)- Maps 3-char tags (UNH, NAD, LIN) to segment classes
- All segments implement SegmentInterface
- Returns UnknownSegment for unregistered tags
-
ParserResult (
src/ParserResult.php)- Contains
globalSegments(UNA, UNB, UNZ - file-level) - Contains
transactionMessages[](UNH...UNT message blocks)
- Contains
-
TransactionMessage (
src/TransactionMessage.php)- Single UNH...UNT message block
- Contains
groupedSegments,lineItems[],contextSegments[]
Segment Organization
TransactionMessage organizes segments three ways:
- groupedSegments: Flat lookup
['NAD']['CN']- fastest access - lineItems: LIN segments with children (products/orders) - created by DetailsSectionBuilder
- contextSegments: Hierarchical parent-child - built by ContextStackParser
Key Patterns
Context hierarchy (ContextStackParser, defaults — override via GroupingRules):
- Parents: NAD, LIN, DOC
- Children: COM, CTA, PIA, IMD, MEA, QTY, PRI, TAX, DTM, MOA
Line item boundaries (MessageDataBuilder\Builder, defaults — override via GroupingRules):
- Start: LIN segment
- End: UNS, CNT, or UNT segments
- DetailsSectionBuilder groups segments into line items
- SimpleBuilder handles flat grouping
Segment abstraction:
- SegmentInterface:
tag(),subId(),rawValues(),parsedSubId() - AbstractSegment: Base implementation. Shared protected helpers segments delegate to:
requiredSubId()— subId fromrawValues[1][0], throwsMissingSubIdif absentcomponent(int $index, int $group = 1)— read a composite element,''if absent
- ContextSegment: Decorator with
children()for hierarchy; alsochildByTag(),childrenByTag(),hasChildren(),toArray()/toJson(), Countable + IteratorAggregate - HasRetrievableSegments: Trait for
segmentsByTag(),segmentByTagAndSubId(),query() - SegmentArray:
fromSegment()/fromSegments()— the one place segments become plain arrays; everytoArray()in the library goes through it
Keyed maps use array-key, not string: PHP normalizes a numeric-looking subId
('1', '21') to an int array key, so grouped maps are typed
array<string, array<array-key, SegmentInterface>> and segmentByTagAndSubId() takes
string|int.
SubId logic:
- Base
subId()readsrawValues()[1]; string'CN'or array['21', 'C62']→ joined as'21:C62' - Segments with a mandatory composite id (UNH/UNB/CNT/DTM/CUX/PRI/QTY/RFF) override
subId()withrequiredSubId()— these throwMissingSubIdon malformed input - Used for distinguishing multiple segments with the same tag
Public API Surface (for extraction/consumption)
- Typed accessors on segments: e.g.
NADNameAddress::name()/countryCode(),QTYQuantity::quantityAsFloat(),PRIPrice::priceAsFloat(),DTMDateTimePeriod::asDateTime() - Envelope metadata:
UNBInterchangeHeader(syntax id/version, sender/recipient, prep date/time, control ref),UNZInterchangeTrailer,UNTMessageFooter,BGMBeginningOfMessage SegmentQuery($message->query()): fluentwithTag/withTags/withoutTags/withSubId/ where/ofType/limit/skip/first/last/get/count/exists/isEmpty/map/reduce/groupByTag/ countByTag/each; Countable + IteratorAggregate- Collections:
ParserResult(messages),TransactionMessage(segments, in order),LineItem(segments),FunctionalGroup(messages) are Countable + IteratorAggregate.ParserResult::firstMessage()/messagesOfType();TransactionMessage::has()/countByTag()/ toArray()/toJson() - Bulk entry points that skip argument unpacking:
TransactionMessage::groupSegments()andContextStackParser::parseAll()take aniterable; the variadicgroupSegmentsByMessage()/parse()delegate to them Analysis\MessageAnalyzer: counts,getPartyQualifiers(),getCurrencies(),calculateTotalAmount()/Quantity(),getSummary()- Fluent builders (
Segments\Builder\*):NADNameAddress::builder()etc. →build() - Qualifier constants (
Segments\Qualifier\*): NAD/QTY/PRI/DTM/RFF magic-string maps - Writer (
Serializer\EdifactSerializer+UnaSeparators): renderiterable<SegmentInterface>back to an.edistring (inverse of parsing) - Interchange assembly (
Writer\InterchangeBuilder+Writer\MessageBuilder): build a full UNB…UNZ with auto UNT/UNZ counts, thentoString() - Segment groups (
Directory\MessageStructure,SegmentGroup,SegmentPosition,StructureGrouper->GroupInstance): the real nested structure from the directory, as opposed to theGroupingRulesheuristic. The matcher is greedy and order-driven; when the same tag could belong to several levels, document order decides. Never drop unmatched segments — they go into the ungrouped remainder - Generated segments (
tools/generate-segments.php->Segments\Generated\*,Segments\GeneratedSegments): never edit generated files, regenerate. Component 0 of an element MUST usefirstComponent(), notcomponent(0, n)— a single-value element round-trips as a plain string.DEFAULT_SEGMENTSstays at 32; generated tags are opt-in viawithDirectorySegments(). Coverage comes from one reflection-driven test that calls every generated accessor, not per-class tests - Directory data (
Directory\XmlDirectory+DirectoryInterface,SegmentDefinition,Composite,DataElement): UNTDID segment definitions and code lists, read with XMLReader and cached.php-edifact/edifact-mappingis asuggest— the parser must keep working without it, so every entry point returns null rather than throwing when data is absent. Unit tests run against a small committed fixture intests/fixtures/directoryso the suite does not need the 150 MB package; one test loads the real D96A when present - Validator naming rule:
diagnose()returnslist<Diagnostic>on every validator;validate()returns the olderlist<ValidationViolation>and exists only onMessageValidator. Two methods with the same name and different return types in one namespace is a trap — keep new validators ondiagnose() - Directory validation (
Validation\DirectoryValidator): element/composite requirements, representation, lengths, and opt-in code lists. ComplementsMessageValidator, which works at message level - Predefined rule sets (
Validation\MessageRuleSets):orders()/invoic()/desadv()/iftmin() - Charset (
Charset\Charset): map UNB syntax id → encoding, decode values to UTF-8 - Diagnostics (
Diagnostics\Diagnostic+DiagnosticCode): one type for parse and validation failures — stable code, severity, segment index, tag, element path,toArray()/toJson(). Reached viaInvalidFile::getDiagnostics(),MessageValidator::diagnose()andValidationViolation::toDiagnostic(). Codes are public API and must stay stable; messages are free to change — never match on message text - Validation (
Validation\MessageValidator+MessageRuleSet→ValidationViolation): required-segment, cardinality andinSequence()conformance checks; never throws - Duplicate-preserving access:
query()andTransactionMessage::segments()keep every segment in order (dups included); keyed views index by tag+subId (last wins) - Keyed views hold the typed segment, never a
ContextSegment— sosegmentByTagAndSubId('NAD', 'BY')->name()andinstanceof NADNameAddressboth work. Go from a segment to what was grouped under it withTransactionMessage::childrenOf()/contextFor()(indexed byspl_object_id, and accepting either the segment or the context object) - Diff (
Diff\InterchangeDiff->Diff\Difference): segment-level comparison, aligned by LCS over tag + subId. Never align by position — one insertion would otherwise cascade into every following segment - CLI (
bin/edifact,Console\Application): parse/inspect/validate/segments/diff. Contract is part of the API — data on stdout, messages on stderr, exit 0/1/2.Optionstakes its stdin stream as an argument so the piped path stays testable;OutputInterfacekeeps the two channels separable in tests - Introspection (
SegmentFactory::registeredTags()/classForTag()/describeTag(),Segments\SegmentDescriptor): enumerate tags and accessors at runtime.registeredTags()andclassForTag()must never autoload segment classes — see Hot Paths. Descriptors are reflection-derived; do not hand-maintain them.schema/message.schema.jsonpublishes thetoArray()shape and is asserted against real output by a test - Grouping config (
GroupingRules): injectable context/child/line-item-break tags - Streaming (
StreamingParser): generator yielding oneTransactionMessageat a time, bounded memory for large interchanges; honours a leadingUNA(custom delimiters) - Functional groups (
ParserResult::functionalGroups()→FunctionalGroup): UNG/UNE envelope; messages also stay available flat viatransactionMessages()
Extension Points
- Add custom segments: Extend AbstractSegment, register in SegmentFactory
- Modify context / line-item rules: pass a customized
GroupingRulesto theEdifactParserconstructor or tocreateWithDefaultSegments()(no longer hardcoded consts) - Custom builders: Implement BuilderInterface for different grouping logic
Hot Paths (do not regress)
Enforced, not just documented: composer bench measures each of these, and CI runs the
same suite against the PR's base branch on the same runner, failing on a >1.5x regression.
Do not edit tools/benchmark.php in a commit that also claims a performance delta.
The corpus must be valid EDIFACT. Malformed input penalises whichever implementation
detects it, which silently inflates the ratio in favour of the one that does not. A stray
?@ (a release before a non-delimiter) in the original corpus pushed SabasTokenizer
through its unescaped-release preg_match on every element and made NativeTokenizer look
2.2x faster; on clean data the honest figure is 1.8x. Quote ratios only from a corpus both
sides accept, and say which corpus.
These run once per segment of an interchange — hundreds of thousands of times on a large file. Keep them allocation- and call-free:
SegmentFactory::createSegmentFromArray()— no per-instance validation; classes are checked once on construction, andwithDefaultSegments()skips even that (guarded bySegmentFactoryTest::every_default_class_implements_the_segment_interface) so building a factory does not autoload all 32 segment classes.GroupingRules::is*Tag()— hash lookups over maps built in the constructor, notin_array.MessageDataBuilder\Builder::addSegment()— state transitions inlined on purpose.TransactionMessage::groupSegments()— one pass; global (UNA/UNB/UNZ) segments are collected inside it rather than by a second filter pass.StreamingParser::extractSegments()—strcspn/substrruns, never a per-character loop.TransactionMessagememoizes its ordered segment list and tag counts;ParserResultmemoizes the merged segment map.
Conventions & Constraints
- Min PHP 8.0 (
composer.jsonplatform.php: 8.0) — enums (8.1) are NOT available; usefinal class+public constfor constant groups (seeSegments/Qualifier/*). - Public library: preserve method signatures and const values; changes to them are BC breaks.
- All code passes PHP-CS-Fixer, Psalm, PHPStan (level 5) and Rector; tests required for new behavior.
- Conventional commits (
ref:for refactors); land work via a branch + PR.
Commands
composer test-unit # Unit tests
composer test-functional # Functional tests
composer quality # All checks (CS, Psalm, PHPStan, Rector)
composer csfix # Fix code style
Documentation contract: llms.txt + docs/llms/*.md are the agent-facing docs. Every
snippet in them MUST have a runnable counterpart in example/llms-*.php asserting the same
thing, and CI runs all examples with assertions on. If you change an API, update the doc AND
its example — a snippet with no example is how the README Quick Start stayed broken for years.
Packaging: .gitattributes decides what ships, and it is the only thing that does.
composer verify-package builds the dist with git archive, installs it into a throwaway
project and uses it — including the CLI, whose autoloader resolution differs when installed
as a dependency. CI runs it. Add an export-ignore and this is what catches an over-exclusion.
Toolchain gotcha: the pinned Psalm (^5.26) is happiest on PHP ≤ 8.3 — run it under
8.3 if your CLI is newer, and add --threads=1 if it dies mid-run. On PHP > 8.3,
PHP-CS-Fixer needs PHP_CS_FIXER_IGNORE_ENV=1. PHPStan passing does not guarantee Psalm
passes (Psalm is stricter about union returns from rawValues() accessors) — run both
before pushing. CI enforces 100% line coverage, so every new method needs a test.