From 84a0ca26c5fc9dd7547819fd8daa9316ab24705c Mon Sep 17 00:00:00 2001 From: Tom Wright Date: Fri, 19 Jan 2024 17:16:06 +0000 Subject: [PATCH] Ported initial Reference implementations Signed-off-by: Tom Wright --- .editorconfig | 20 + .gitattributes | 11 + .github/workflows/integrate.yml | 130 +++++ .gitignore | 8 + CHANGELOG.md | 2 + README.md | 32 ++ composer.json | 34 ++ ecs.php | 13 + phpstan.neon | 4 + src/Reference.php | 77 +++ src/Reference/AtHandle.php | 58 +++ src/Reference/Banking/Iban.php | 75 +++ src/Reference/Banking/RoutingNumber.php | 55 +++ src/Reference/Banking/Swift.php | 45 ++ src/Reference/Banking/UkSortCode.php | 56 +++ src/Reference/Commerce/Upc.php | 41 ++ src/Reference/Domain.php | 56 +++ src/Reference/Email.php | 55 +++ src/Reference/Guid.php | 166 +++++++ .../Identity/UkNationalInsurance.php | 60 +++ src/Reference/InitialKey.php | 41 ++ src/Reference/Music/CatalogueNumber.php | 61 +++ src/Reference/Music/GenericTrackReference.php | 46 ++ src/Reference/Music/Isrc.php | 58 +++ src/Reference/Music/IsrcRange.php | 55 +++ src/Reference/Music/Iswc.php | 60 +++ src/Reference/Music/PrsTunecode.php | 56 +++ src/Reference/NumericId.php | 46 ++ src/Reference/Slug.php | 56 +++ src/ReferenceTrait.php | 447 ++++++++++++++++++ 30 files changed, 1924 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/integrate.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 ecs.php create mode 100644 phpstan.neon create mode 100644 src/Reference.php create mode 100644 src/Reference/AtHandle.php create mode 100644 src/Reference/Banking/Iban.php create mode 100644 src/Reference/Banking/RoutingNumber.php create mode 100644 src/Reference/Banking/Swift.php create mode 100644 src/Reference/Banking/UkSortCode.php create mode 100644 src/Reference/Commerce/Upc.php create mode 100644 src/Reference/Domain.php create mode 100644 src/Reference/Email.php create mode 100644 src/Reference/Guid.php create mode 100644 src/Reference/Identity/UkNationalInsurance.php create mode 100644 src/Reference/InitialKey.php create mode 100644 src/Reference/Music/CatalogueNumber.php create mode 100644 src/Reference/Music/GenericTrackReference.php create mode 100644 src/Reference/Music/Isrc.php create mode 100644 src/Reference/Music/IsrcRange.php create mode 100644 src/Reference/Music/Iswc.php create mode 100644 src/Reference/Music/PrsTunecode.php create mode 100644 src/Reference/NumericId.php create mode 100644 src/Reference/Slug.php create mode 100644 src/ReferenceTrait.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fc134fc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# https://EditorConfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +block_comment_start = /* +block_comment = * +block_comment_end = */ + +[*.yml] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..789106a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +.editorconfig export-ignore +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +CHANGELOG.md export-ignore +ecs.php export-ignore +phpstan.neon export-ignore +phpunit.xml.dist export-ignore +docs/ export-ignore +tests/ export-ignore +stubs/ export-ignore diff --git a/.github/workflows/integrate.yml b/.github/workflows/integrate.yml new file mode 100644 index 0000000..b495e04 --- /dev/null +++ b/.github/workflows/integrate.yml @@ -0,0 +1,130 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow + +name: "Integrate" + +on: + push: + branches: + - "develop" + pull_request: null + +env: + PHP_EXTENSIONS: "intl" + +jobs: + file_consistency: + name: "1️⃣ File consistency" + runs-on: "ubuntu-latest" + steps: + - name: "Set up PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "8.1" + extensions: "${{ env.PHP_EXTENSIONS }}" + ini-values: "post_max_size=256M" + + - name: "Checkout code" + uses: "actions/checkout@v3" + + - name: Install Effigy + run: | + composer global config --no-plugins allow-plugins.phpstan/extension-installer true + composer global require decodelabs/effigy + + - name: "Install dependencies" + uses: "ramsey/composer-install@v2" + with: + dependency-versions: "highest" + + - name: "Check file permissions" + run: | + composer global exec effigy check-executable-permissions + + - name: "Check exported files" + run: | + composer global exec effigy check-git-exports + + - name: "Find non-printable ASCII characters" + run: | + composer global exec effigy check-non-ascii + + - name: "Check source code for syntax errors" + run: | + composer global exec effigy lint + + static_analysis: + name: "3️⃣ Static Analysis" + needs: + - "file_consistency" + runs-on: "ubuntu-latest" + strategy: + matrix: + php-version: + - "8.1" + - "8.2" + - "8.3" + steps: + - name: "Set up PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "${{ matrix.php-version }}" + extensions: "${{ env.PHP_EXTENSIONS }}" + ini-values: "post_max_size=256M" + + - name: "Checkout code" + uses: "actions/checkout@v3" + + - name: Install Effigy + run: | + composer global config --no-plugins allow-plugins.phpstan/extension-installer true + composer global require decodelabs/effigy + + - name: "Validate Composer configuration" + run: "composer validate --strict" + + - name: "Install dependencies" + uses: "ramsey/composer-install@v2" + with: + dependency-versions: "highest" + + - name: "Execute static analysis" + run: | + composer global exec effigy analyze -- --headless + + + coding_standards: + name: "4️⃣ Coding Standards" + needs: + - "file_consistency" + runs-on: "ubuntu-latest" + steps: + - name: "Set up PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "8.1" + extensions: "${{ env.PHP_EXTENSIONS }}" + ini-values: "post_max_size=256M" + + - name: "Checkout code" + uses: "actions/checkout@v3" + + - name: "Check EditorConfig configuration" + run: "test -f .editorconfig" + + - name: "Check adherence to EditorConfig" + uses: "greut/eclint-action@v0" + + - name: Install Effigy + run: | + composer global config --no-plugins allow-plugins.phpstan/extension-installer true + composer global require decodelabs/effigy + + - name: "Install dependencies" + uses: "ramsey/composer-install@v2" + with: + dependency-versions: "highest" + + - name: "Check coding style" + run: | + composer global exec effigy format -- --headless + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b0d60a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/vendor +composer.phar +composer.lock +.DS_Store +Thumbs.db +/phpunit.xml +/.idea +/.env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..904b75f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +## v0.1.0 (2024-01-19) +* Ported initial Reference implementation diff --git a/README.md b/README.md new file mode 100644 index 0000000..932268a --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Referential + +[![PHP from Packagist](https://img.shields.io/packagist/php-v/decodelabs/referential?style=flat)](https://packagist.org/packages/decodelabs/referential) +[![Latest Version](https://img.shields.io/packagist/v/decodelabs/referential.svg?style=flat)](https://packagist.org/packages/decodelabs/referential) +[![Total Downloads](https://img.shields.io/packagist/dt/decodelabs/referential.svg?style=flat)](https://packagist.org/packages/decodelabs/referential) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/decodelabs/referential/integrate.yml?branch=develop)](https://github.com/decodelabs/referential/actions/workflows/integrate.yml) +[![PHPStan](https://img.shields.io/badge/PHPStan-enabled-44CC11.svg?longCache=true&style=flat)](https://github.com/phpstan/phpstan) +[![License](https://img.shields.io/packagist/l/decodelabs/referential?style=flat)](https://packagist.org/packages/decodelabs/referential) + +### A framework for finding, parsing, inspecting and formatting reference IDs + +Referential provides a generalist approach to handling ID references. It enables a consistent way of working with keys across multiple data sources and formats. + +_Get news and updates on the [DecodeLabs blog](https://blog.decodelabs.com)._ + +--- + +## Installation + +Install via Composer: + +```bash +composer require decodelabs/referential +``` + +## Usage + +Coming soon... + +## Licensing + +Referential is licensed under the MIT License. See [LICENSE](./LICENSE) for the full license text. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..38554c9 --- /dev/null +++ b/composer.json @@ -0,0 +1,34 @@ +{ + "name": "decodelabs/referential", + "description": "A framework for finding, parsing, inspecting and formatting reference IDs", + "type": "library", + "keywords": [ ], + "license": "MIT", + "authors": [ { + "name": "Tom Wright", + "email": "tom@inflatablecookie.com" + } ], + "require": { + "php": "^8.1", + "decodelabs/exceptional": "^0.4.4" + }, + "require-dev": { + "decodelabs/glitch": "^0.18.11", + "decodelabs/tagged": "^0.14.12", + "decodelabs/phpstan-decodelabs": "^0.6.7", + "decodelabs/guidance": "^0.1.8" + }, + "suggest": { + "decodelabs/tagged": "For formatting as HTML" + }, + "autoload": { + "psr-4": { + "DecodeLabs\\Referential\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-develop": "0.1.x-dev" + } + } +} diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..56f5fea --- /dev/null +++ b/ecs.php @@ -0,0 +1,13 @@ +paths([__DIR__.'/src']); + $ecsConfig->sets([SetList::CLEAN_CODE, SetList::PSR_12]); +}; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..16f0d34 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + paths: + - src + level: max diff --git a/src/Reference.php b/src/Reference.php new file mode 100644 index 0000000..7dc9a24 --- /dev/null +++ b/src/Reference.php @@ -0,0 +1,77 @@ +prepareNormalized($value) + ], [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Banking/Iban.php b/src/Reference/Banking/Iban.php new file mode 100644 index 0000000..5e7fec7 --- /dev/null +++ b/src/Reference/Banking/Iban.php @@ -0,0 +1,75 @@ + $matches + */ + protected function formatNormalizedMatches( + array $matches + ): string { + return implode(' ', $matches); + } + + /** + * Convert canonical to html value + * + * @param array $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.banking.iban'}(function () use ($matches) { + yield Html::{'span.value'}(array_shift($matches)); + + foreach ($matches as $part) { + yield ' '; + yield Html::{'span.value'}($part); + } + }, [ + 'title' => $this->canonical + ]); + } + + /** + * Match token parts + * + * @return array|null + */ + protected function matchParts( + string $value + ): ?array { + if (!preg_match($this->getCanonicalPattern(true), $value, $matches)) { + return null; + } + + $output = str_split($matches[2], 4); + array_unshift($output, $matches[1]); + + return $output; + } +} diff --git a/src/Reference/Banking/RoutingNumber.php b/src/Reference/Banking/RoutingNumber.php new file mode 100644 index 0000000..e6687fa --- /dev/null +++ b/src/Reference/Banking/RoutingNumber.php @@ -0,0 +1,55 @@ + $matches + */ + protected function formatNormalizedMatches( + array $matches + ): string { + return implode(' ', $matches); + } + + /** + * Convert canonical to html value + * + * @param array $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.banking.routing'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + yield ' '; + yield Html::{'span.value'}($matches[1]); + yield ' '; + yield Html::{'span.value'}($matches[2]); + }); + } +} diff --git a/src/Reference/Banking/Swift.php b/src/Reference/Banking/Swift.php new file mode 100644 index 0000000..4aab6c8 --- /dev/null +++ b/src/Reference/Banking/Swift.php @@ -0,0 +1,45 @@ + $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.banking.swift'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + yield Html::{'span.value'}($matches[1]); + yield Html::{'span.value'}($matches[2]); + yield Html::{'?span.value'}($matches[3] ?? null); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Banking/UkSortCode.php b/src/Reference/Banking/UkSortCode.php new file mode 100644 index 0000000..aa526d6 --- /dev/null +++ b/src/Reference/Banking/UkSortCode.php @@ -0,0 +1,56 @@ + $matches + */ + protected function formatNormalizedMatches( + array $matches + ): string { + return implode('-', $matches); + } + + /** + * Convert canonical to html value + * + * @param array $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.banking.uk-sort-code'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[1]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[2]); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Commerce/Upc.php b/src/Reference/Commerce/Upc.php new file mode 100644 index 0000000..978b7f8 --- /dev/null +++ b/src/Reference/Commerce/Upc.php @@ -0,0 +1,41 @@ + $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.commerce.upc'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Domain.php b/src/Reference/Domain.php new file mode 100644 index 0000000..83cc81d --- /dev/null +++ b/src/Reference/Domain.php @@ -0,0 +1,56 @@ +prepareNormalized($value), [ + 'href' => 'https://' . $value, + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Email.php b/src/Reference/Email.php new file mode 100644 index 0000000..63e9ebe --- /dev/null +++ b/src/Reference/Email.php @@ -0,0 +1,55 @@ +prepareNormalized($value), [ + 'href' => 'mailto:' . $value + ]); + } +} diff --git a/src/Reference/Guid.php b/src/Reference/Guid.php new file mode 100644 index 0000000..33e84a5 --- /dev/null +++ b/src/Reference/Guid.php @@ -0,0 +1,166 @@ + $matches + */ + protected function formatCanonicalMatches( + array $matches + ): string { + return implode('-', $matches); + } + + /** + * Combine match parts + * + * @param array $matches + */ + protected function formatNormalizedMatches( + array $matches + ): string { + return implode('-', $matches); + } + + /** + * Convert canonical to html value + * + * @param array $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.guid'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[1]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[2]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[3]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[4]); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Identity/UkNationalInsurance.php b/src/Reference/Identity/UkNationalInsurance.php new file mode 100644 index 0000000..5e9693c --- /dev/null +++ b/src/Reference/Identity/UkNationalInsurance.php @@ -0,0 +1,60 @@ + $matches + */ + protected function formatNormalizedMatches( + array $matches + ): string { + return implode(' ', $matches); + } + + /** + * Convert canonical to html value + * + * @param array $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.identity.uk-ni'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + yield ' '; + yield Html::{'span.value'}($matches[1]); + yield ' '; + yield Html::{'span.value'}($matches[2]); + yield ' '; + yield Html::{'span.value'}($matches[3]); + yield ' '; + yield Html::{'span.value'}($matches[4]); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/InitialKey.php b/src/Reference/InitialKey.php new file mode 100644 index 0000000..ee9c93d --- /dev/null +++ b/src/Reference/InitialKey.php @@ -0,0 +1,41 @@ + $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.initial'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0] . $matches[1]); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Music/CatalogueNumber.php b/src/Reference/Music/CatalogueNumber.php new file mode 100644 index 0000000..e5e4b3d --- /dev/null +++ b/src/Reference/Music/CatalogueNumber.php @@ -0,0 +1,61 @@ +raw)); + } + + /** + * Convert canonical to formatted value + */ + protected function prepareHtml( + string $value + ): Markup { + return Html::{'samp.number.music.catalogue'}(function () use ($value) { + yield Html::{'span.value'}($this->prepareNormalized($value)); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Music/GenericTrackReference.php b/src/Reference/Music/GenericTrackReference.php new file mode 100644 index 0000000..faad3d0 --- /dev/null +++ b/src/Reference/Music/GenericTrackReference.php @@ -0,0 +1,46 @@ + $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.music.reference'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + }, [ + 'title' => $this->canonical + ]); + } + + public static function isGeneric(): bool + { + return true; + } +} diff --git a/src/Reference/Music/Isrc.php b/src/Reference/Music/Isrc.php new file mode 100644 index 0000000..363a68c --- /dev/null +++ b/src/Reference/Music/Isrc.php @@ -0,0 +1,58 @@ + $matches + */ + protected function formatNormalizedMatches( + array $matches + ): string { + return implode('-', $matches); + } + + /** + * Convert canonical to html value + * + * @param array $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.music.isrc'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[1]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[2]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[3]); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Music/IsrcRange.php b/src/Reference/Music/IsrcRange.php new file mode 100644 index 0000000..70fe4d2 --- /dev/null +++ b/src/Reference/Music/IsrcRange.php @@ -0,0 +1,55 @@ + $matches + */ + protected function formatNormalizedMatches( + array $matches + ): string { + return implode('-', $matches); + } + + /** + * Convert canonical to html value + * + * @param array $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.music.isrc.range'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[1]); + yield Html::{'b.grammar'}('*'); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Music/Iswc.php b/src/Reference/Music/Iswc.php new file mode 100644 index 0000000..0f80059 --- /dev/null +++ b/src/Reference/Music/Iswc.php @@ -0,0 +1,60 @@ + $matches + */ + protected function formatNormalizedMatches( + array $matches + ): string { + return $matches[0] . '-' . $matches[1] . '.' . $matches[2] . '.' . $matches[3] . '-' . $matches[4]; + } + + /** + * Convert canonical to html value + * + * @param array $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.music.iswc'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[1]); + yield Html::{'span.grammar'}('.'); + yield Html::{'span.value'}($matches[2]); + yield Html::{'span.grammar'}('.'); + yield Html::{'span.value'}($matches[3]); + yield Html::{'span.grammar'}('-'); + yield Html::{'span.value'}($matches[4]); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/Music/PrsTunecode.php b/src/Reference/Music/PrsTunecode.php new file mode 100644 index 0000000..ef87967 --- /dev/null +++ b/src/Reference/Music/PrsTunecode.php @@ -0,0 +1,56 @@ + $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.music.prs-tunecode'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + yield Html::{'span.value'}($matches[1] . $matches[2]); + }, [ + 'title' => $this->canonical + ]); + } +} diff --git a/src/Reference/NumericId.php b/src/Reference/NumericId.php new file mode 100644 index 0000000..65a06a0 --- /dev/null +++ b/src/Reference/NumericId.php @@ -0,0 +1,46 @@ + $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number.numeric-id'}(function () use ($matches) { + yield Html::{'span.value'}($matches[0]); + }, [ + 'title' => $this->canonical + ]); + } + + public static function isGeneric(): bool + { + return true; + } +} diff --git a/src/Reference/Slug.php b/src/Reference/Slug.php new file mode 100644 index 0000000..cc45217 --- /dev/null +++ b/src/Reference/Slug.php @@ -0,0 +1,56 @@ + $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.slug'}($matches[0], [ + 'title' => $this->canonical + ]); + } + + public static function isGeneric(): bool + { + return true; + } +} diff --git a/src/ReferenceTrait.php b/src/ReferenceTrait.php new file mode 100644 index 0000000..5c6bc46 --- /dev/null +++ b/src/ReferenceTrait.php @@ -0,0 +1,447 @@ +validate()) { + return null; + } + + return $output; + } + + /** + * Generate and validate + */ + public static function isValid( + ?string $value + ): bool { + return (bool)static::tryInstantiate($value); + } + + /** + * Is reference in canonical format? + */ + public static function isCanonical( + ?string $value + ): bool { + if (!$ref = static::tryInstantiate($value)) { + return false; + } + + return $value === $ref->getCanonical(); + } + + /** + * Generate and convert to canonical + */ + public static function canonicalize( + ?string $value + ): ?string { + if (!$ref = static::tryInstantiate($value)) { + return null; + } + + return $ref->getCanonical(); + } + + /** + * Generate and format + */ + public static function normalize( + ?string $value + ): ?string { + if (!$ref = static::tryInstantiate($value)) { + return null; + } + + return $ref->getString(); + } + + /** + * Generate and format as HTML + */ + public static function format( + ?string $value + ): ?Markup { + if (!$ref = static::tryInstantiate($value)) { + return null; + } + + return $ref->getHtml(); + } + + + + /** + * Get matchable canonical pattern + */ + public static function getCanonicalPattern( + bool $wrapped + ): string { + if (!defined('static::CANONICAL_PATTERN')) { + throw Exceptional::Setup('Canonical pattern has not been defined'); + } + + $output = static::CANONICAL_PATTERN; + + if (!$wrapped) { + $output = substr((string)$output, 1, -1); + } + + return $output; + } + + /** + * Get canonical max string length + */ + public static function getCanonicalMaxLength(): int + { + if (!defined('static::CANONICAL_MAX_LENGTH')) { + throw Exceptional::Setup('Canonical max length has not been defined'); + } + + return static::CANONICAL_MAX_LENGTH; + } + + /** + * Get matchable normal pattern + */ + public static function getNormalPattern( + bool $wrapped + ): string { + if (!defined('static::NORMAL_PATTERN')) { + throw Exceptional::Setup('Normal pattern has not been defined'); + } + + $output = static::NORMAL_PATTERN; + + if (!$wrapped) { + $output = substr((string)$output, 1, -1); + } + + return $output; + } + + /** + * Get normal max string length + */ + public static function getNormalMaxLength(): int + { + if (!defined('static::NORMAL_MAX_LENGTH')) { + throw Exceptional::Setup('Normal max length has not been defined'); + } + + return static::NORMAL_MAX_LENGTH; + } + + + /** + * Get example instance + */ + public static function getExample(): static + { + if (!defined('static::EXAMPLE')) { + throw Exceptional::Setup('Example has not been defined'); + } + + return new static(static::EXAMPLE); + } + + + /** + * Get sanitizer for validator + */ + public static function getSanitizer(): Closure + { + return function ( + ?string $value + ): ?string { + $output = static::canonicalize($value); + + if ($output === null) { + return $value; + } + + return $output; + }; + } + + /** + * Is this a generic reference? + */ + public static function isGeneric(): bool + { + return false; + } + + + + + /** + * Init with any value + */ + public function __construct( + ?string $value + ) { + $this->raw = (string)$value; + $this->canonical = $this->prepareCanonical($this->raw); + } + + + /** + * Valid if canonical has been calculated + */ + public function validate(): bool + { + return $this->canonical !== null; + } + + /** + * Get raw input + */ + public function getRaw(): string + { + return $this->raw; + } + + /** + * Get canonicalized version + */ + public function getCanonical(): ?string + { + return $this->canonical; + } + + + + + + /** + * Prepare canonical value + */ + protected function prepareCanonical( + string $value + ): ?string { + $value = $this->prepareCanonicalString($value); + + if (!$matches = $this->matchParts($value)) { + return null; + } + + return $this->formatCanonicalMatches($matches); + } + + /** + * Prepare canonical string + */ + protected function prepareCanonicalString( + string $value + ): string { + $value = strtoupper($value); + $value = preg_replace('/[^A-Z0-9]/', '', $value); + + return (string)$value; + } + + /** + * Combine match parts + * + * @param array $matches + */ + protected function formatCanonicalMatches( + array $matches + ): string { + return implode($matches); + } + + + + + /** + * Shortcut to format + */ + public function __toString(): string + { + return $this->getString(); + } + + + /** + * Attempt to convert to visual format + */ + public function getString(): string + { + if ($this->canonical === null) { + return $this->raw; + } + + if ($this->formatted === null) { + try { + $this->formatted = $this->prepareNormalized($this->canonical); + } catch (Throwable $e) { + return $this->raw; + } + } + + return $this->formatted; + } + + + /** + * Convert canonical to formatted value + */ + protected function prepareNormalized( + string $value + ): string { + if (!$matches = $this->matchParts($value)) { + throw Exceptional::UnexpectedValue('Unable to match canonical value', null, $value); + } + + return $this->formatNormalizedMatches($matches); + } + + /** + * Combine match parts + * + * @param array $matches + */ + protected function formatNormalizedMatches( + array $matches + ): string { + return $this->formatCanonicalMatches($matches); + } + + + /** + * Attempt to convert to HTML + */ + public function getHtml(): Markup + { + $this->checkTagged(); + + if ($this->html === null) { + if ($this->canonical === null) { + $this->html = $this->prepareRawErrorHtml($this->raw); + } else { + try { + $this->html = $this->prepareHtml($this->canonical); + } catch (Throwable $e) { + $this->html = $this->prepareRawErrorHtml($this->raw); + } + } + + $this->html = Html::raw((string)$this->html); + } + + return $this->html; + } + + + /** + * Convert canonical to formatted value + */ + protected function prepareHtml( + string $value + ): Markup { + if (!$matches = $this->matchParts($value)) { + throw Exceptional::UnexpectedValue('Unable to match canonical value', null, $value); + } + + return $this->formatHtmlMatches($matches); + } + + /** + * Combine match parts + * + * @param array $matches + */ + protected function formatHtmlMatches( + array $matches + ): Markup { + return Html::{'samp.number'}($this->formatCanonicalMatches($matches)); + } + + /** + * Create error HTML + */ + protected function prepareRawErrorHtml( + string $raw + ): Markup { + return Html::{'samp.error.invalid'}($raw); + } + + /** + * Check for tagged + */ + protected function checkTagged(): void + { + if (!class_exists(Html::class)) { + throw Exceptional::ComponentUnavailable( + 'Unable to generate HTML - Tagged library is not available' + ); + } + } + + + + /** + * Match token parts + * + * @return array|null + */ + protected function matchParts( + string $value + ): ?array { + if (!preg_match($this->getCanonicalPattern(true), $value, $matches)) { + return null; + } + + array_shift($matches); + return $matches; + } +}