chemaclass / edifact-parser
An EDIFACT file parser to extract the values from any defined segment
Fund package maintenance!
Requires
- php: >=8.0
- ext-json: *
- ext-mbstring: *
- sabas/edifact: ^1.3
- webmozart/assert: ^1.12
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- php-edifact/edifact-mapping: ^0.5.2
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^9.6
- rector/rector: ^2.6
- vimeo/psalm: ^5.26
Suggests
- php-edifact/edifact-mapping: UN/EDIFACT directory data (D96A-D26x), for DirectoryValidator: element/composite requirements, representation, lengths and code lists
This package is auto-updated.
Last update: 2026-08-10 19:12:23 UTC
README
A complete PHP toolkit for UN/EDIFACT β read, write, validate, and stream EDI interchanges with a typed, object-oriented API.
EDIFACT β Electronic Data Interchange For Administration, Commerce, and Transport β is the international standard for structured business documents (orders, invoices, despatch advices, transport instructions). π New to it? Start here.
Why this library
- π₯ Parse any interchange β unknown tags degrade gracefully to raw values, so no message type is unsupported.
- π€ Write it back β serialize segments to a valid
.edistring, or assemble a fullUNBβ¦UNZinterchange with auto-computed control counts. - β Validate β pluggable rule sets for required segments, cardinality, and order.
- π Stream β parse multi-gigabyte files in bounded memory (one message at a time).
- π§± Model the full envelope β interchange β functional groups (
UNG/UNE) β messages, with duplicate-preserving access and typed metadata on every envelope segment. - π·οΈ 32 typed segments out of the box with domain accessors, plus qualifier constants β or 134 with the directory bundle, and trivially extensible with your own.
- π Directory-aware β validate elements, lengths, representations and code lists against
the real UN/EDIFACT directories, and group messages into their actual nested segment
groups (
SG1β¦SGn) rather than a heuristic. - π₯οΈ CLI included β
edifact parse|inspect|validate|segments, JSON on stdout, documented exit codes. - π©Ί Structured diagnostics β stable codes and positions across parsing and validation, so failures are matched on, not string-searched.
- π Fluent query API and a statistics analyzer for extracting data. Results,
messages, line items and queries are all
Countableanditerable, and any of them dumps to plain arrays or JSON. - π Charset-aware (
UNOAβ¦UNOY), strictly typed (PHP 8.0+, PSR-4), and fully covered by PHPUnit, PHPStan, Psalm, Rector and PHP-CS-Fixer.
Table of Contents
- Installation Β· Upgrading from 6.x
- Command line
- Quick Start
- Parsing Β· Streaming large files
- Reading data Β· Typed accessors Β· Dumping a message Β· Query API Β· Line items Β· Context hierarchy Β· Envelope metadata Β· Functional groups Β· Statistics Β· Qualifier constants Β· Character sets Β· Built-in segments
- Writing EDIFACT
- Validation
- Extending
- Debugging
- Development
- Contributing
For AI coding agents: llms.txt and docs/llms/. Every snippet there
is backed by a runnable file under example/ that CI executes.
πΎ Installation
composer require chemaclass/edifact-parser
Requires PHP 8.0+ with ext-json and ext-mbstring.
π Quick Start
<?php declare(strict_types=1); use EdifactParser\EdifactParser; require 'vendor/autoload.php'; $result = EdifactParser::createWithDefaultSegments() ->parseFile('/path/to/order.edi'); // or ->parse($ediString) foreach ($result->transactionMessages() as $message) { echo $message->messageType(); // 'ORDERS', 'INVOIC', 'IFTMIN', β¦ // Typed accessors β no magic array indices $buyer = $message->segmentByTagAndSubId('NAD', 'BY'); echo $buyer?->name(); // 'ACME Corporation' echo $buyer?->countryCode(); // 'DE' foreach ($message->lineItems() as $lineItem) { $qty = $lineItem->segmentByTagAndSubId('QTY', '21'); echo $qty?->quantityAsFloat(); // 100.0 } }
The parser never throws on unknown segments β they become UnknownSegments you can still
read via rawValues(), so you can process any interchange and add typed segments later.
π₯ Parsing
EdifactParser::parse() / parseFile() return a ParserResult:
$result = EdifactParser::createWithDefaultSegments()->parse($ediString); $result->transactionMessages(); // list<TransactionMessage> β the UNHβ¦UNT blocks $result->functionalGroups(); // list<FunctionalGroup> β UNGβ¦UNE groups, if any $result->globalSegments(); // TransactionMessage β file-level UNA/UNB/UNZ $result->firstMessage(); // ?TransactionMessage $result->messagesOfType('INVOIC'); // list<TransactionMessage> β an interchange may mix types count($result); // number of messages foreach ($result as $message) { β¦ } // iterate the messages directly
A message starts at UNH and ends at UNT; an interchange wraps messages between
UNB and UNZ, optionally grouped by UNG/UNE. Invalid input throws
InvalidFile.
Choosing a tokenizer
Turning raw text into segments is pluggable. NativeTokenizer is the default β a
regex-free single-pass scanner, ~1.8Γ faster at tokenizing (~1.3Γ on parse() overall),
and it never rewrites the bytes it reads:
$parser = EdifactParser::createWithDefaultSegments(); // NativeTokenizer
SabasTokenizer delegates to sabas/edifact, which was the default up to 6.x. It is still
available, but be aware it strips every byte in \x80-\xFF β NAD+BY+++MΓΌller comes
back as Mller:
use EdifactParser\Tokenizer\SabasTokenizer; new EdifactParser(SegmentFactory::withDefaultSegments(), tokenizer: new SabasTokenizer());
Reach for it when you need bug-for-bug compatibility with 6.x, or want the restricted
UNOB repertoire enforced. Otherwise the default is both faster and lossless.
For ASCII input the two tokenize identically β verified segment-for-segment across the test fixtures and a generated corpus.
Streaming large files
Stream messages one at a time in bounded memory β ideal for large interchanges. A
leading UNA service-string advice (custom separators/release char) is honoured
automatically:
use EdifactParser\StreamingParser; foreach (StreamingParser::createWithDefaultSegments()->parseFile('/path/to/large.edi') as $message) { process($message); // only one message is held in memory at a time }
π₯οΈ Command line
composer require installs an edifact binary. It answers "what is in this file?" without
writing a script β and it is built for automation as much as for people:
edifact parse order.edi # the parsed interchange as JSON edifact inspect order.edi # type, counts by tag, line items edifact validate order.edi # rule set picked from the message type edifact validate order.edi --rules=ORDERS edifact segments # every tag the parser knows edifact segments --tag=NAD # its accessors and return types edifact diff before.edi after.edi # what changed between two interchanges edifact parse order.edi --pretty # readable JSON cat order.edi | edifact inspect # reads stdin when no path is given
Contract, so output can be consumed without guessing:
- data on stdout, diagnostics on stderr β never interleaved, so
| jqalways works - exit codes:
0success/valid,1invalid input,2usage error - JSON by default;
--prettyis purely cosmetic
No console framework is pulled in β a parsing library should not put one in your vendor/.
π Reading data
Typed accessors
Typed segments expose their fields as methods β self-documenting and IDE-friendly:
// NAD (Name & Address) $nad->partyQualifier(); // 'BY' $nad->name(); // 'ACME Corporation' $nad->street(); $nad->city(); $nad->postalCode(); $nad->countryCode(); // ISO 3166-1 alpha-2 // QTY / PRI β with numeric conversion $qty->quantityAsFloat(); // float $qty->measureUnit(); // 'PCE', 'KGM', β¦ $pri->priceAsFloat(); // float // DTM β with date parsing $dtm->asDateTime(); // DateTimeImmutable|null
Every segment also exposes the raw structure when you need it:
$segment->tag(); // 'NAD' $segment->subId(); // 'BY' $segment->rawValues(); // ['NAD', 'BY', ['0410106314', '160', 'Z12'], β¦]
Accessing segments
// Fastest single lookup, by tag + subId $nad = $message->segmentByTagAndSubId('NAD', 'BY'); // ?SegmentInterface // All segments with a tag (keyed by subId) $allNad = $message->segmentsByTag('NAD'); $nad?->name(); // always null-check β not every segment exists in every message // Presence and counts, answered from an index built once per message $message->has('QTY'); // bool $message->countByTag(); // ['UNH' => 1, 'NAD' => 2, 'LIN' => 40, β¦] count($message); // total segments, duplicates included // A message is iterable in document order, so it goes straight into anything // expecting iterable<SegmentInterface> foreach ($message as $segment) { β¦ }
Dumping a message
toArray() / toJson() render a whole message β or a single segment β as plain data,
with context children nested. Useful for logs, snapshot tests and diffing interchanges:
$message->toArray(); // ['type' => 'ORDERS', 'segments' => [['tag' => 'UNH', 'subId' => '1', β¦], β¦]] $message->toJson(); // pretty-printed JSON of the same structure $segment->toArray(); // ['tag' => 'NAD', 'subId' => 'BY', 'rawValues' => [...]]
Fluent query API
Chain filters and transformations over every segment (order preserved, duplicates included):
// Filter $message->query()->withTag('NAD')->withSubId('CN')->get(); $message->query()->withTags(['NAD', 'LIN'])->get(); $message->query()->withoutTags(['UNH', 'UNT'])->get(); $message->query()->ofType(NADNameAddress::class)->get(); $message->query()->withTag('PRI')->where(fn($s) => $s->priceAsFloat() > 1000)->get(); // Chain + paginate $message->query() ->withTag('NAD')->withSubId('SU') ->where(fn($s) => $s->countryCode() === 'DE') ->limit(10)->skip(0)->get(); // Transform / inspect $message->query()->withTag('NAD')->map(fn($s) => $s->name()); $message->query()->withTag('MOA')->reduce(fn(float $t, $s) => $t + $s->amountAsFloat(), 0.0); $message->query()->withTags(['QTY', 'PRI'])->groupByTag(); // ['QTY' => [...], 'PRI' => [...]] $message->query()->countByTag(); // ['NAD' => 2, 'LIN' => 40, β¦] $message->query()->withTag('NAD')->first(); // ?SegmentInterface $message->query()->withTag('NAD')->count(); $message->query()->withTag('UNS')->exists(); // bool // A query is countable and iterable β no ->get() needed to loop foreach ($message->query()->withTag('NAD') as $nad) { β¦ }
query()and$message->segments()return every segment in original order, duplicates included. The keyed lookups (segmentByTagAndSubId(),allSegments()) index by tag + subId and keep the last occurrence β use the query API when duplicates matter.
Line items
Line items group each LIN with its related detail segments (QTY, PRI, PIA, β¦) β
ideal for orders and invoices:
foreach ($message->lineItems() as $lineItem) { $lin = $lineItem->segmentByTagAndSubId('LIN', '1'); $qty = $lineItem->segmentByTagAndSubId('QTY', '21'); echo $lin?->itemNumber(); // product identifier echo $qty?->quantityAsFloat(); count($lineItem); // segments in this line item foreach ($lineItem as $segment) { β¦ } // β¦or iterate them }
Hierarchical context segments
Context segments preserve parent β child relationships (e.g. NAD β CTA β COM):
foreach ($message->contextSegments() as $context) { if ($context->tag() === 'NAD') { foreach ($context as $child) { // β¦or ->children() // $child->tag(), $child->rawValues(), β¦ } $context->childByTag('CTA'); // ?SegmentInterface β the first one $context->childrenByTag('COM'); // list<SegmentInterface> β all of them $context->hasChildren(); // bool count($context); // number of children $context->toArray(); // the segment with its children nested } }
Keyed lookups always hand back the typed segment, so you can go the other way too β read a segment normally, then ask the message what was grouped under it:
$buyer = $message->segmentByTagAndSubId('NAD', 'BY'); // NADNameAddress $buyer?->name(); // typed accessors work $message->childrenOf($buyer); // list<SegmentInterface> β the CTA/COM under this NAD $message->contextFor($buyer); // ?ContextSegment β the same, as a context object
Interchange & envelope metadata
Every envelope segment exposes typed metadata:
$unb = $result->globalSegments()->segmentByTagAndSubId('UNB', 'UNOC'); $unb?->syntaxIdentifier(); // 'UNOC' $unb?->senderIdentification(); $unb?->recipientIdentification(); $unb?->preparationDate(); // 'YYMMDD' $unb?->interchangeControlReference(); $unz = $result->globalSegments()->segmentByTagAndSubId('UNZ', '1'); $unz?->interchangeControlCount(); // number of messages/groups $unt = $message->query()->withTag('UNT')->first(); // segmentCount(), messageReferenceNumber() $bgm = $message->query()->withTag('BGM')->first(); // documentCode() e.g. '220', documentNumber()
Functional groups (UNG/UNE)
When an interchange wraps messages in UNGβ¦UNE groups, read them directly. Interchanges
without groups return an empty list β messages stay available flat via
transactionMessages():
foreach ($result->functionalGroups() as $group) { $group->messageType(); // e.g. 'ORDERS' (from the UNG) $group->header()->groupReference(); $group->trailer()?->controlCount(); foreach ($group as $message) { // β¦or ->messages() // β¦ } count($group); // messages in the group }
Statistics & analysis
MessageAnalyzer extracts counts and aggregates:
use EdifactParser\Analysis\MessageAnalyzer; $analyzer = new MessageAnalyzer($message); $analyzer->getType(); // 'ORDERS' $analyzer->segmentCount(); $analyzer->lineItemCount(); $analyzer->segmentCountByTag('QTY'); $analyzer->getPartyQualifiers(); // ['BY', 'SU', 'CN'] (unique) $analyzer->getCurrencies(); // ['EUR'] $analyzer->calculateTotalAmount('125'); // sum MOA with qualifier 125 $analyzer->calculateTotalQuantity('21'); // sum ordered quantities $analyzer->hasSummarySection(); // UNS present? $analyzer->getSummary(); // array of the above
Qualifier constants
Avoid magic strings with typed qualifier catalogs (IDE autocomplete, usable in match):
use EdifactParser\Segments\Qualifier\NADQualifier; $message->query() ->withTag('NAD') ->where(fn($s) => $s->partyQualifier() === NADQualifier::BUYER) // 'BY' ->get();
| Class | Covers |
|---|---|
NADQualifier |
Party roles β BY, SU, CN, CZ, DP, IV, PR, CA, FW, MF, UC, WH |
QTYQualifier |
Quantity types β 1, 3, 11, 12, 21, 33, 46, 47, 48, 192 |
PRIQualifier |
Price types β AAA, AAB, AAE, AAF, AAG, CAL, CT, DIS, LIS, MIN, RRP |
DTMQualifier |
Date/time types β 137, 2, 3, 4, 10, 11, 13, β¦ |
RFFQualifier |
Reference types β ON, IV, DQ, CU, SRN, CT, POR, β¦ |
Character sets
The parser reads raw bytes. Decode non-ASCII values to UTF-8 from the interchange's syntax identifier:
use EdifactParser\Charset\Charset; $unb = $result->globalSegments()->segmentByTagAndSubId('UNB', 'UNOC'); $unb?->characterEncoding(); // 'ISO-8859-1' $name = Charset::toUtf8($nad->name(), $unb->syntaxIdentifier());
UNOA/UNOB β ASCII, UNOCβUNOK β ISO-8859-*, UNOY β UTF-8.
Built-in segments
32 segments are typed and registered by default (134 with
SegmentFactory::withDirectorySegments()):
- Envelope / service:
UNB,UNG,UNH,UNS,UNT,UNE,UNZ - Header:
BGM,DTM,RFF,NAD,CUX,TDT,LOC,FTX - Party / terms:
CTA,COM,PAT,PCD,TAX,TOD - Detail / summary:
LIN,PIA,IMD,QTY,PRI,MEA,PAC,GID,MOA,PCI,CNT
Any other tag parses as an UnknownSegment (readable via rawValues()); add your own typed
class in a few lines β see Extending.
π€ Writing EDIFACT
Build individual segments
Fluent, type-safe builders produce segment objects:
use EdifactParser\Segments\NADNameAddress; use EdifactParser\Segments\Qualifier\NADQualifier; $nad = NADNameAddress::builder() ->withQualifier(NADQualifier::BUYER) ->withPartyId('123456') ->withName('ACME Corporation') ->withCity('Springfield') ->withCountryCode('US') ->build();
NADNameAddress, QTYQuantity and PRIPrice provide ::builder().
Serialize segments to a string
EdifactSerializer is the inverse of parsing β it round-trips a parsed interchange
byte-for-byte and escapes separators/release chars for you:
use EdifactParser\Serializer\EdifactSerializer; use EdifactParser\Serializer\UnaSeparators; $serializer = new EdifactSerializer(); echo $serializer->serializeSegment($nad); // NAD+BY+123456++ACME Corporation++Springfield+++US' $edi = $serializer->serialize([$unh, $bgm, $nad, $unt], includeUna: true); // A TransactionMessage is iterable, so it round-trips without unwrapping $edi = $serializer->serialize($message); // Custom delimiters new EdifactSerializer(new UnaSeparators(component: '#', element: '|'));
Assemble a full interchange
InterchangeBuilder writes a complete UNBβ¦UNZ interchange and fills in the UNT segment
counts and the UNZ control count automatically:
use EdifactParser\Writer\InterchangeBuilder; use EdifactParser\Writer\MessageBuilder; $edi = InterchangeBuilder::create('SENDER', 'RECIPIENT', 'REF1') ->preparedAt('200101', '1200') ->addMessage( MessageBuilder::create('1', 'ORDERS') ->addSegment($bgm) ->addSegment($nad) ) ->toString(); // ready-to-send EDIFACT string
β Validation
Check a message against a pluggable rule set β required segments, cardinality, and relative order. The validator never throws; an empty result means the message conforms:
use EdifactParser\Validation\MessageRuleSet; use EdifactParser\Validation\MessageValidator; $rules = MessageRuleSet::forType('ORDERS') ->require('UNH', 'BGM', 'UNT') // mandatory segments ->occurs('NAD', 1, 5) // between 1 and 5 NAD segments ->occurs('LIN', 1) // at least 1 line item ->inSequence('UNH', 'BGM', 'UNT'); // relative order of these tags $validator = new MessageValidator(); foreach ($validator->validate($message, $rules) as $violation) { echo "{$violation->segmentTag()}: {$violation->message()}\n"; } $validator->isValid($message, $rules); // bool
Ready-made rule sets for common message types are provided as starting points:
use EdifactParser\Validation\MessageRuleSets; $validator->validate($message, MessageRuleSets::orders()); // orders(), invoic(), desadv(), iftmin()
π§ Extending
Custom segments
Extend AbstractSegment and register your class. The shared accessor helpers
(element(), component(), firstComponent()) safely read simple and composite elements:
namespace YourApp\Segments; use EdifactParser\Segments\AbstractSegment; /** @psalm-immutable */ final class EQDEquipmentDetails extends AbstractSegment { public function tag(): string { return 'EQD'; } // EQD+CN+ABCU1234567+22G1 public function equipmentQualifier(): string { return $this->element(1); // 'CN' } public function equipmentId(): string { return $this->firstComponent(2); // 'ABCU1234567' } }
use EdifactParser\EdifactParser; use EdifactParser\Segments\SegmentFactory; use YourApp\Segments\EQDEquipmentDetails; $factory = SegmentFactory::withAdditionalSegments([ 'EQD' => EQDEquipmentDetails::class, // added on top of the 32 built-ins ]); $parser = new EdifactParser($factory);
withAdditionalSegments()keeps every default and merges your tags on top β registering a custom class under a default tag overrides that default. UsewithSegments()instead when you want an explicit, closed set of segments.
Introspection
Ask the factory what it knows, instead of reading the source:
$factory = SegmentFactory::withDefaultSegments(); $factory->registeredTags(); // ['BGM', 'CNT', 'COM', β¦ ] β 32 tags, sorted $factory->classForTag('NAD'); // EdifactParser\Segments\NADNameAddress $factory->classForTag('ZZZ'); // null β would become an UnknownSegment $factory->describeTag('QTY')?->accessors(); // ['measureUnit' => 'string', 'qualifier' => 'string', // 'quantity' => 'string', 'quantityAsFloat' => 'float']
Descriptors are derived by reflection, so they cannot drift from the code.
registeredTags() and classForTag() read the map only β no class is loaded.
The shape of toArray()/toJson() is published as a JSON Schema at
schema/message.schema.json, and a test asserts the schema
still matches what a parsed message actually produces.
Composable segment bundles
The defaults are exposed as two composable bundles so you can build a lean factory
that only types the tags you care about β everything else still parses as a readable
UnknownSegment:
SegmentFactory::ENVELOPE_SEGMENTSβ the UN* service/control segments (7).SegmentFactory::BUSINESS_SEGMENTSβ header, party/terms, detail and summary (25).SegmentFactory::DEFAULT_SEGMENTSβ the union of both (32).
// Envelope structure + just the segments you extract: $factory = SegmentFactory::withSegments( SegmentFactory::ENVELOPE_SEGMENTS + [ 'NAD' => NADNameAddress::class, 'LIN' => LINLineItem::class, ], );
Custom grouping rules
Context hierarchies and line-item boundaries are driven by GroupingRules. Pass a
customized instance to change which tags open a context, attach as children, or close a
line-item section:
use EdifactParser\EdifactParser; use EdifactParser\GroupingRules; use EdifactParser\Segments\SegmentFactory; use EdifactParser\StreamingParser; $rules = GroupingRules::default() ->withContextTags(['NAD', 'LIN']) ->withChildTags(['CTA', 'COM', 'DTM']) ->withBreakLineItemTags(['UNS', 'CNT', 'UNT']); $parser = new EdifactParser(SegmentFactory::withDefaultSegments(), $rules); // β¦or, when the default segments are all you need: $parser = EdifactParser::createWithDefaultSegments($rules); $stream = StreamingParser::createWithDefaultSegments($rules);
GroupingRules also reads back what it is configured with β contextTags(),
childTags(), breakLineItemTags() β and the defaults are exposed as
GroupingRules::DEFAULT_CONTEXT_TAGS, DEFAULT_CHILD_TAGS and
DEFAULT_BREAK_LINE_ITEM_TAGS.
More examples in example/: extracting data,
query filtering,
printing segments,
context segments.
π Debugging
$segment->toArray(); // ['tag' => 'NAD', 'subId' => 'CN', 'rawValues' => [...]] $segment->toJson(); // pretty-printed JSON $message->toArray(); // ['type' => 'ORDERS', 'segments' => [...]] β contexts nested $message->toJson();
Error handling
use EdifactParser\Exception\InvalidFile; try { $result = $parser->parseFile('invalid.edi'); } catch (InvalidFile $e) { $e->getErrors(); // parser errors, as strings $e->getContext(); // extra context, formatted into getMessage() }
Structured diagnostics
Matching on English prose is fragile, so parse failures and validation failures share one type with stable codes and, where known, a position:
use EdifactParser\Diagnostics\DiagnosticCode; catch (InvalidFile $e) { foreach ($e->getDiagnostics() as $d) { $d->code(); // 'segment.unterminated' β stable, match on this $d->severity(); // 'error' | 'warning' $d->segmentIndex(); // 2 $d->tag(); // 'NAD' $d->elementPath(); // 'C186/6060', when known $d->toArray(); // JSON-serialisable (string) $d; // error [segment.unterminated] at segment 2 (NAD): β¦ } } // The validator speaks the same vocabulary $diagnostics = (new MessageValidator())->diagnose($message, MessageRuleSets::orders()); $violation->code() === DiagnosticCode::SEGMENT_REQUIRED;
Codes are public API β see DiagnosticCode for the catalogue. Messages are not: they may
be reworded at any time.
π οΈ Development
composer install composer test # PHPUnit (unit + functional) composer quality # PHP-CS-Fixer, Psalm, PHPStan, Rector composer csfix # apply code-style fixes composer bench # benchmark the hot paths (corpus generated at runtime) composer verify-package # build the dist, install it elsewhere, and use it
CI runs the benchmarks on every pull request, measuring the base branch and the head
branch on the same runner and failing when a metric regresses beyond 1.5Γ. Absolute
timings on shared hardware mean little; ratios measured back to back do. Never change
tools/benchmark.php in a commit that also reports a performance delta β the numbers
stop being comparable.
- PHP 8.0+, strict types, PSR-4. Type hints and tests required for new functionality.
- All code must pass PHP-CS-Fixer, Psalm, PHPStan and Rector (CI is authoritative).
Local toolchain note: the pinned Psalm (
vimeo/psalm ^4.30) runs on PHP β€ 8.3 β run it under 8.3 if your CLI is newer. On PHP > 8.3, PHP-CS-Fixer needsPHP_CS_FIXER_IGNORE_ENV=1.
π€ Contributing
Contributions of all kinds are welcome β bug fixes, ideas, and improvements.
- π Report an issue
- π§ Open a pull request
π See the contributing guide to get started.