From 7892a7c902439a5e9aacefa134920dc4d4fba2e4 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Mon, 5 Jan 2026 12:40:00 +0100 Subject: [PATCH] Port Bash scripts to PHP It makes more sense to use PHP to generate PHP code than to use Bash. I love writing Bash scripts, but I know it's not for everyone, and they can become quite complex. Porting them to PHP code also lowers the barrier for people to change them. While I was making those changes, I also noticed a problem with how we save the domain suffixes. We're converting all of them to ASCII, so we are not preserving languages such as Chinese, Thai, and Hebrew, which use non-ASCII characters. --- .gitattributes | 12 +- .github/workflows/update-regionals.yaml | 7 +- bin/console | 29 ++ bin/create-mixin | 248 ------------ bin/update-doc-links | 124 ------ bin/update-domain-suffixes | 79 ---- bin/update-domain-toplevel | 42 -- bin/update-postal-codes | 48 --- composer.json | 2 + docs/09-list-of-validators-by-category.md | 14 +- phpcs.xml.dist | 1 + src-dev/Commands/CreateMixinCommand.php | 363 ++++++++++++++++++ src-dev/Commands/UpdateDocLinksCommand.php | 275 +++++++++++++ .../Commands/UpdateDomainSuffixesCommand.php | 207 ++++++++++ .../Commands/UpdateDomainToplevelCommand.php | 95 +++++ src-dev/Commands/UpdatePostalCodesCommand.php | 130 +++++++ 16 files changed, 1120 insertions(+), 556 deletions(-) create mode 100755 bin/console delete mode 100755 bin/create-mixin delete mode 100755 bin/update-doc-links delete mode 100755 bin/update-domain-suffixes delete mode 100755 bin/update-domain-toplevel delete mode 100755 bin/update-postal-codes create mode 100644 src-dev/Commands/CreateMixinCommand.php create mode 100644 src-dev/Commands/UpdateDocLinksCommand.php create mode 100644 src-dev/Commands/UpdateDomainSuffixesCommand.php create mode 100644 src-dev/Commands/UpdateDomainToplevelCommand.php create mode 100644 src-dev/Commands/UpdatePostalCodesCommand.php diff --git a/.gitattributes b/.gitattributes index 8c4e4716..79538dc4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,6 @@ -/*.dist export-ignore -/*.yml export-ignore -/.* export-ignore -/bin export-ignore -/docs export-ignore -/tests export-ignore +/* export-ignore +/composer.json -export-ignore +/data -export-ignore +/library -export-ignore +/LICENSE -export-ignore +/README.md -export-ignore diff --git a/.github/workflows/update-regionals.yaml b/.github/workflows/update-regionals.yaml index d479f56f..a2910548 100644 --- a/.github/workflows/update-regionals.yaml +++ b/.github/workflows/update-regionals.yaml @@ -25,10 +25,13 @@ jobs: ref: ${{ secrets.LAST_MINOR_VERSION }} - name: Update top level domains - run: bin/update-domain-toplevel + run: bin/console update:domain-toplevel - name: Update public domain suffixes - run: bin/update-domain-suffixes + run: bin/console update:domain-suffixes + + - name: Update postal codes + run: bin/console update:postal-codes - name: Create pull request id: cpr diff --git a/bin/console b/bin/console new file mode 100755 index 00000000..8ee0dd21 --- /dev/null +++ b/bin/console @@ -0,0 +1,29 @@ +#!/usr/bin/env php + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +require __DIR__ . '/../vendor/autoload.php'; + +use Respect\Dev\Commands\CreateMixinCommand; +use Respect\Dev\Commands\UpdateDocLinksCommand; +use Respect\Dev\Commands\UpdateDomainSuffixesCommand; +use Respect\Dev\Commands\UpdateDomainToplevelCommand; +use Respect\Dev\Commands\UpdatePostalCodesCommand; +use Symfony\Component\Console\Application; + +return (static function () { + $application = new Application('Respect/Validation', '3.0'); + $application->addCommand(new CreateMixinCommand()); + $application->addCommand(new UpdateDocLinksCommand()); + $application->addCommand(new UpdateDomainSuffixesCommand()); + $application->addCommand(new UpdateDomainToplevelCommand()); + $application->addCommand(new UpdatePostalCodesCommand()); + + return $application->run(); +})(); diff --git a/bin/create-mixin b/bin/create-mixin deleted file mode 100755 index f8254b8d..00000000 --- a/bin/create-mixin +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env php -getShortName(), $allowList, true)) { - return; - } - - if ($denyList !== [] && in_array($reflection->getShortName(), $denyList, true)) { - return; - } - - $name = $prefix ? $prefix . ucfirst($originalName) : lcfirst($originalName); - $method = $interfaceType->addMethod($name)->setPublic()->setReturnType(Chain::class); - if (str_contains($interfaceType->getName(), 'Builder')) { - $method->setStatic(); - } - - if ($prefix === 'key') { - $method->addParameter('key')->setType('int|string'); - } - - if ($prefix === 'property') { - $method->addParameter('propertyName')->setType('string'); - } - - $reflrectionConstructor = $reflection->getConstructor(); - if ($reflrectionConstructor === null) { - return; - } - - $commend = $reflrectionConstructor->getDocComment(); - if ($commend) { - $method->addComment(preg_replace('@(/\*\* *| +\* +| +\*/)@', '', $commend)); - } - - foreach ($reflrectionConstructor->getParameters() as $reflectionParameter) { - if ($reflectionParameter->isVariadic()) { - $method->setVariadic(); - } - - $type = $reflectionParameter->getType(); - $types = []; - if ($type instanceof ReflectionUnionType) { - foreach ($type->getTypes() as $type) { - $types[] = $type->getName(); - } - } elseif ($type instanceof ReflectionNamedType) { - $types[] = $type->getName(); - if ( - str_starts_with($type->getName(), 'Sokil') - || str_starts_with($type->getName(), 'Egulias') - || $type->getName() === 'finfo' - ) { - continue; - } - } - - $parameter = $method->addParameter($reflectionParameter->getName()); - $parameter->setType(implode('|', $types)); - - if (!$reflectionParameter->isDefaultValueAvailable()) { - $parameter->setNullable($reflectionParameter->isOptional()); - } - - if (count($types) > 1 || $reflectionParameter->isVariadic()) { - $parameter->setNullable(false); - } - - if (!$reflectionParameter->isDefaultValueAvailable()) { - continue; - } - - $defaultValue = $reflectionParameter->getDefaultValue(); - if (is_object($defaultValue)) { - $parameter->setDefaultValue(null); - $parameter->setNullable(true); - continue; - } - - $parameter->setDefaultValue($defaultValue); - $parameter->setNullable(false); - } -} - -function overwriteFile(string $content, string $basename): void -{ - file_put_contents(sprintf('%s/../library/Mixins/%s.php', __DIR__, $basename), implode(PHP_EOL . PHP_EOL, [ - 'isFile()) { - continue; - } - - $className = 'Respect\\Validation\\Validators\\' . $file->getBasename('.php'); - $reflection = new ReflectionClass($className); - if ($reflection->isAbstract()) { - continue; - } - - $names[$reflection->getShortName()] = $reflection; - } - - ksort($names); - - foreach ($mixins as [$name, $prefix, $allowList, $denyList]) { - $chainedNamespace = new PhpNamespace('Respect\\Validation\\Mixins'); - $chainedNamespace->addUse(Validator::class); - $chainedInterface = $chainedNamespace->addInterface($name . 'Chain'); - - $staticNamespace = new PhpNamespace('Respect\\Validation\\Mixins'); - $staticNamespace->addUse(Validator::class); - $staticInterface = $staticNamespace->addInterface($name . 'Builder'); - - if ($name === '') { - $chainedInterface->addExtend(Validator::class); - $chainedInterface->addExtend(AllChain::class); - $chainedInterface->addExtend(KeyChain::class); - $chainedInterface->addExtend(LengthChain::class); - $chainedInterface->addExtend(MaxChain::class); - $chainedInterface->addExtend(MinChain::class); - $chainedInterface->addExtend(NotChain::class); - $chainedInterface->addExtend(NullOrChain::class); - $chainedInterface->addExtend(PropertyChain::class); - $chainedInterface->addExtend(UndefOrChain::class); - $chainedInterface->addComment('@mixin \\' . ValidatorBuilder::class); - - $staticInterface->addExtend(AllBuilder::class); - $staticInterface->addExtend(KeyBuilder::class); - $staticInterface->addExtend(LengthBuilder::class); - $staticInterface->addExtend(MaxBuilder::class); - $staticInterface->addExtend(MinBuilder::class); - $staticInterface->addExtend(NotBuilder::class); - $staticInterface->addExtend(NullOrBuilder::class); - $staticInterface->addExtend(PropertyBuilder::class); - $staticInterface->addExtend(UndefOrBuilder::class); - } - - foreach ($names as $originalName => $reflection) { - addMethodToInterface($originalName, $staticInterface, $reflection, $prefix, $allowList, $denyList); - addMethodToInterface($originalName, $chainedInterface, $reflection, $prefix, $allowList, $denyList); - } - - $printer = new Printer(); - $printer->wrapLength = 115; - - overwriteFile($printer->printNamespace($staticNamespace), $staticInterface->getName()); - overwriteFile($printer->printNamespace($chainedNamespace), $chainedInterface->getName()); - } - - shell_exec(__DIR__ . '/../vendor/bin/phpcbf ' . __DIR__ . '/../library/Mixins'); -})(); diff --git a/bin/update-doc-links b/bin/update-doc-links deleted file mode 100755 index ae37fa7d..00000000 --- a/bin/update-doc-links +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash -# Usage: {script} DOCS_DIREECTORY -# Update list of rules and link related rules. - -set -eo pipefail - -declare -r IFS=$'\n' -declare -r DOCS_DIREECTORY="${1}" - -declare -A RULES_BY_CATEGORIZATION=() -declare -A RELATED_RULES=() - -list_rules() -{ - find "${DOCS_DIREECTORY}/rules" -type f | - sed "s,${DOCS_DIREECTORY}/,," | - cut -d '/' -f 2 | - cut -d '.' -f 1 | - sort -} - -list_categories() -{ - while read rule; do - declare filename="${DOCS_DIREECTORY}/rules/${rule}.md" - sed -n '/## Categorization/,/## Changelog/p' "${filename}" - done < <(list_rules) | - grep '^-' | - sort -u | - sed 's,- ,,' -} - -create_rule_documentation() -{ - # Usage: create_rule_documentation RULE RELATED_RULES - local rule=${1} - local related_rules=${2} - local filename="${DOCS_DIREECTORY}/rules/${rule}.md" - local links=$(ggrep -E '^\[.+\]: .+' "${filename}") - local related_links=$( - tr ':' '\n' <<< ${related_rules} | - sort -u | - grep -v '^$' | - grep -v "^${rule}$" | - sed -E "s,(.+),- [\1](\1.md),g" - ) - - # "Description" section - sed -nE '/^# /,/^## Changelog/p' "${filename}" | ggrep -E -v '^## Changelog' - - # "Changelog" section - sed -nE '/^## Changelog/,/^---/p' "${filename}" - - # "See also" section - echo - echo "See also:" - echo - echo "${related_links}" - - # Index of links - if [[ ! -z "${links}" ]]; then - echo - echo "${links}" - fi -} - -create_list_of_rules() -{ - echo "Building list of rules per categorization" - while read rule; do - declare filename="${DOCS_DIREECTORY}/rules/${rule}.md" - for category in $(sed -n '/## Categorization/,/## Changelog/p' "${filename}" | - grep '^-' | - sed 's,- ,,'); do - RULES_BY_CATEGORIZATION[${category}]=${RULES_BY_CATEGORIZATION[${category}]}:${rule} - done - done < <(list_rules) - - echo "Creating list of rules" - { - echo "# List of rules by category" - echo - for category in $(list_categories); do - echo "## ${category}" - sed -E 's,:,\n- ,g' <<< "${RULES_BY_CATEGORIZATION[${category}]}" | - sort -u | - sed -E 's,- (.+),- [\1](rules/\1.md),' - echo - done - - echo "## Alphabetically" - echo - ls -1 "${DOCS_DIREECTORY}/rules/" | sort | sed -E 's,^(.+).md$,- [\1](rules/\1.md),' - } > "${DOCS_DIREECTORY}/09-list-of-validators-by-category.md" -} - -link_related_rules() -{ - local temporaty=$(mktemp) - - echo "Building list of related rules" - for rule in $(list_rules); do - declare filename="${DOCS_DIREECTORY}/rules/${rule}.md" - declare related_rules=$(ggrep -E '\[.+\]\(.+\.md\)' "${filename}" | - sed -E 's,.*\[.+\]\((.+)\.md\).*,\1,' | - grep -v 'comparable-values') - - for related_rule in ${related_rules}; do - RELATED_RULES[${related_rule}]=${RELATED_RULES[${related_rule}]}:${rule} - done - - RELATED_RULES[${rule}]="${RELATED_RULES[${rule}]}:$(tr '\n' ':' <<< ${related_rules})" - done - - echo "Recreating rule documentations with related rules" - for rule in $(list_rules); do - echo "- ${rule}" - create_rule_documentation "${rule}" "${RELATED_RULES[${rule}]}" > "${temporaty}" - cat "${temporaty}" > "${DOCS_DIREECTORY}/rules/${rule}.md" - done -} - -create_list_of_rules -link_related_rules diff --git a/bin/update-domain-suffixes b/bin/update-domain-suffixes deleted file mode 100755 index 8059631a..00000000 --- a/bin/update-domain-suffixes +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -# Usage: {script} RULE_FILENAME -# Update list of TLD - -set -euo pipefail - -declare -r IFS=$'\n' - -declare -r LIST_URL="https://publicsuffix.org/list/public_suffix_list.dat" -declare -r LIST_FILENAME=$(mktemp) - -declare -r RULE_FILENAME_TEMPORARY=$(mktemp) - -echo "- Downloading list" -curl --silent --location "${LIST_URL}" --output "${LIST_FILENAME}" - -echo "- Removing old data" -rm -Rf data/domain/* -mkdir -p data/domain/public-suffix - -parse_tlds_list () { - sed '/^\/\/*/d' | - idn2 | - tr '[:lower:]' '[:upper:]' | - sed '/^$/d' | while read -r suffix - do - suffix="${suffix#\*\.}" - suffix="${suffix#\!}" - tld="${suffix##*\.}" - if test "$tld" != "$suffix" - then - prefix="${suffix%.$tld}" - echo "$tld $prefix" - else - prefix="" - fi - done -} - -echo "- Creating files" - -cat "$LIST_FILENAME" | - parse_tlds_list | - cut -d" " -f1 | - sort -u | while read -r tld_with_suffix - do - suffixlist="$(cat "$LIST_FILENAME" | while read -r line - do - if test "// ===END ICANN DOMAINS===" = "$line" - then break - else echo "$line" - fi - done | - parse_tlds_list | - grep "^$tld_with_suffix " | - cut -d" " -f2 | - tr '[:lower:]' '[:upper:]' | - LC_ALL=C sort | while read -r suffix - do - echo " '$suffix.$tld_with_suffix'," - done | LC_ALL=C sort || :)" - - if test -n "$suffixlist" - then - echo "- Creating public-suffix/$tld_with_suffix.php" - echo " "data/domain/public-suffix/$tld_with_suffix.php" - else - echo "- Skipping public-suffix/$tld_with_suffix.php" - fi - done - -wait - -echo "Finished!" diff --git a/bin/update-domain-toplevel b/bin/update-domain-toplevel deleted file mode 100755 index f337aead..00000000 --- a/bin/update-domain-toplevel +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# Usage: {script} RULE_FILENAME -# Update list of TLD - -set -euo pipefail - -declare -r IFS=$'\n' - -declare -r LIST_URL="https://data.iana.org/TLD/tlds-alpha-by-domain.txt" -declare -r LIST_FILENAME=$(mktemp) - -declare -r RULE_FILENAME=$(dirname "${BASH_SOURCE}")/../library/Validators/Tld.php -declare -r RULE_FILENAME_TEMPORARY=$(mktemp) - -echo "- Downloading list" -curl --silent --location "${LIST_URL}" --output "${LIST_FILENAME}" - -echo "- Creating temporary file" -{ - sed --silent --regexp-extended "/^ "${RULE_FILENAME_TEMPORARY}" - -echo "- Updating content of '$(basename ${RULE_FILENAME})'" -mv "${RULE_FILENAME_TEMPORARY}" "${RULE_FILENAME}" - -echo "Finished!" diff --git a/bin/update-postal-codes b/bin/update-postal-codes deleted file mode 100755 index 11c4fdda..00000000 --- a/bin/update-postal-codes +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# Usage: {script} -# Update the list of currency codes - -set -euo pipefail - -declare -r IFS=$'\n' - -declare -r LIST_URL="https://download.geonames.org/export/dump/countryInfo.txt" -declare -r LIST_FILENAME=$(mktemp) - -declare -r RULE_FILENAME=$(dirname "${BASH_SOURCE}")/../library/Validators/PostalCode.php -declare -r RULE_FILENAME_TEMPORARY=$(mktemp) - -echo "- Downloading list" -curl --silent --location "${LIST_URL}" --output "${LIST_FILENAME}" - -declare -r CURRENCY_CODES_COUNT=$(grep "" "${LIST_FILENAME}" | wc --lines) - -echo "- Creating temporary file" -{ - sed -n "/^ ['/^$country_format$/', '/$country_regex/']," - fi - done - echo ' // phpcs:disable Generic.Files.LineLength.TooLong' - sed --silent '/^ \];\/\/end/,/^}/p' "${RULE_FILENAME}" -} > "${RULE_FILENAME_TEMPORARY}" - -echo "- Updating content of '$(basename ${RULE_FILENAME})'" -mv "${RULE_FILENAME_TEMPORARY}" "${RULE_FILENAME}" - -echo "Finished!" diff --git a/composer.json b/composer.json index 4af7f829..25f4fe75 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "sokil/php-isocodes": "^4.2.1", "sokil/php-isocodes-db-only": "^4.0", "squizlabs/php_codesniffer": "^4.0", + "symfony/console": "^7.4", "symfony/var-exporter": "^6.4 || ^7.0" }, "suggest": { @@ -64,6 +65,7 @@ }, "autoload-dev": { "psr-4": { + "Respect\\Dev\\": "src-dev/", "Respect\\Validation\\": "tests/unit/", "Respect\\Validation\\Test\\": "tests/library/" } diff --git a/docs/09-list-of-validators-by-category.md b/docs/09-list-of-validators-by-category.md index 81cee67c..fbe50ad3 100644 --- a/docs/09-list-of-validators-by-category.md +++ b/docs/09-list-of-validators-by-category.md @@ -102,6 +102,13 @@ - [Uploaded](validators/Uploaded.md) - [Writable](validators/Writable.md) +## ISO codes + +- [CountryCode](validators/CountryCode.md) +- [CurrencyCode](validators/CurrencyCode.md) +- [LanguageCode](validators/LanguageCode.md) +- [SubdivisionCode](validators/SubdivisionCode.md) + ## Identifications - [Bsn](validators/Bsn.md) @@ -131,13 +138,6 @@ - [Url](validators/Url.md) - [VideoUrl](validators/VideoUrl.md) -## ISO codes - -- [CountryCode](validators/CountryCode.md) -- [CurrencyCode](validators/CurrencyCode.md) -- [LanguageCode](validators/LanguageCode.md) -- [SubdivisionCode](validators/SubdivisionCode.md) - ## Localization - [CountryCode](validators/CountryCode.md) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 701b58b9..f4bf469e 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -12,6 +12,7 @@ library/ + src-dev/ tests/ diff --git a/src-dev/Commands/CreateMixinCommand.php b/src-dev/Commands/CreateMixinCommand.php new file mode 100644 index 00000000..5d62b242 --- /dev/null +++ b/src-dev/Commands/CreateMixinCommand.php @@ -0,0 +1,363 @@ + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +namespace Respect\Dev\Commands; + +use DirectoryIterator; +use Nette\PhpGenerator\InterfaceType; +use Nette\PhpGenerator\Method; +use Nette\PhpGenerator\PhpNamespace; +use Nette\PhpGenerator\Printer; +use ReflectionClass; +use ReflectionNamedType; +use ReflectionParameter; +use ReflectionUnionType; +use Respect\Validation\Mixins\AllBuilder; +use Respect\Validation\Mixins\AllChain; +use Respect\Validation\Mixins\Chain; +use Respect\Validation\Mixins\KeyBuilder; +use Respect\Validation\Mixins\KeyChain; +use Respect\Validation\Mixins\LengthBuilder; +use Respect\Validation\Mixins\LengthChain; +use Respect\Validation\Mixins\MaxBuilder; +use Respect\Validation\Mixins\MaxChain; +use Respect\Validation\Mixins\MinBuilder; +use Respect\Validation\Mixins\MinChain; +use Respect\Validation\Mixins\NotBuilder; +use Respect\Validation\Mixins\NotChain; +use Respect\Validation\Mixins\NullOrBuilder; +use Respect\Validation\Mixins\NullOrChain; +use Respect\Validation\Mixins\PropertyBuilder; +use Respect\Validation\Mixins\PropertyChain; +use Respect\Validation\Mixins\UndefOrBuilder; +use Respect\Validation\Mixins\UndefOrChain; +use Respect\Validation\Validator; +use Respect\Validation\ValidatorBuilder; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +use function array_filter; +use function array_merge; +use function count; +use function dirname; +use function file_exists; +use function file_get_contents; +use function file_put_contents; +use function implode; +use function in_array; +use function is_object; +use function ksort; +use function lcfirst; +use function preg_replace; +use function shell_exec; +use function sprintf; +use function str_contains; +use function str_starts_with; +use function ucfirst; + +#[AsCommand( + name: 'create:mixin', + description: 'Generate mixin interfaces from validators', +)] +final class CreateMixinCommand extends Command +{ + private const array NUMBER_RELATED_VALIDATORS = [ + 'Between', + 'BetweenExclusive', + 'Equals', + 'Equivalent', + 'Even', + 'Factor', + 'Fibonacci', + 'Finite', + 'GreaterThan', + 'Identical', + 'In', + 'Infinite', + 'LessThan', + 'LessThanOrEqual', + 'GreaterThanOrEqual', + 'Multiple', + 'Odd', + 'PerfectSquare', + 'Positive', + 'PrimeNumber', + ]; + + private const array STRUCTURE_RELATED_VALIDATORS = [ + 'Exists', + 'Key', + 'KeyExists', + 'KeyOptional', + 'KeySet', + 'NullOr', + 'UndefOr', + 'Property', + 'PropertyExists', + 'PropertyOptional', + 'Attributes', + 'Templated', + 'Named', + ]; + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('Generating mixin interfaces'); + + // Scan validators directory + $libraryDir = dirname(__DIR__, 2) . '/library'; + $validatorsDir = $libraryDir . '/Validators'; + $validators = $this->scanValidators($validatorsDir); + + $io->text(sprintf('Found %d validators', count($validators))); + + // Define mixins + $mixins = [ + ['All', 'all', [], array_merge(['All'], self::STRUCTURE_RELATED_VALIDATORS)], + ['Key', 'key', [], self::STRUCTURE_RELATED_VALIDATORS], + ['Length', 'length', self::NUMBER_RELATED_VALIDATORS, []], + ['Max', 'max', self::NUMBER_RELATED_VALIDATORS, []], + ['Min', 'min', self::NUMBER_RELATED_VALIDATORS, []], + ['Not', 'not', [], ['Not', 'NullOr', 'UndefOr', 'Attributes', 'Templated', 'Named']], + ['NullOr', 'nullOr', [], ['NullOr', 'Blank', 'Undef', 'UndefOr', 'Templated', 'Named']], + ['Property', 'property', [], self::STRUCTURE_RELATED_VALIDATORS], + ['UndefOr', 'undefOr', [], ['NullOr', 'Blank', 'Undef', 'UndefOr', 'Attributes', 'Templated', 'Named']], + ['', null, [], []], + ]; + + $io->section('Generating mixin interfaces'); + + foreach ($mixins as [$name, $prefix, $allowList, $denyList]) { + $io->text(sprintf('Generating %sBuilder and %sChain', $name ?: 'Base', $name ?: 'Base')); + + $chainedNamespace = new PhpNamespace('Respect\\Validation\\Mixins'); + $chainedNamespace->addUse(Validator::class); + $chainedInterface = $chainedNamespace->addInterface($name . 'Chain'); + + $staticNamespace = new PhpNamespace('Respect\\Validation\\Mixins'); + $staticNamespace->addUse(Validator::class); + $staticInterface = $staticNamespace->addInterface($name . 'Builder'); + + if ($name === '') { + $chainedInterface->addExtend(Validator::class); + $chainedInterface->addExtend(AllChain::class); + $chainedInterface->addExtend(KeyChain::class); + $chainedInterface->addExtend(LengthChain::class); + $chainedInterface->addExtend(MaxChain::class); + $chainedInterface->addExtend(MinChain::class); + $chainedInterface->addExtend(NotChain::class); + $chainedInterface->addExtend(NullOrChain::class); + $chainedInterface->addExtend(PropertyChain::class); + $chainedInterface->addExtend(UndefOrChain::class); + $chainedInterface->addComment('@mixin \\' . ValidatorBuilder::class); + + $staticInterface->addExtend(AllBuilder::class); + $staticInterface->addExtend(KeyBuilder::class); + $staticInterface->addExtend(LengthBuilder::class); + $staticInterface->addExtend(MaxBuilder::class); + $staticInterface->addExtend(MinBuilder::class); + $staticInterface->addExtend(NotBuilder::class); + $staticInterface->addExtend(NullOrBuilder::class); + $staticInterface->addExtend(PropertyBuilder::class); + $staticInterface->addExtend(UndefOrBuilder::class); + } + + foreach ($validators as $originalName => $reflection) { + $this->addMethodToInterface( + $originalName, + $staticInterface, + $reflection, + $prefix, + $allowList, + $denyList, + ); + $this->addMethodToInterface( + $originalName, + $chainedInterface, + $reflection, + $prefix, + $allowList, + $denyList, + ); + } + + $printer = new Printer(); + $printer->wrapLength = 115; + + $this->overwriteFile($printer->printNamespace($staticNamespace), $staticInterface->getName()); + $this->overwriteFile($printer->printNamespace($chainedNamespace), $chainedInterface->getName()); + } + + // Run code beautifier + $io->section('Running code beautifier'); + $mixinsDir = $libraryDir . '/Mixins'; + $phpcbfPath = dirname(__DIR__, 2) . '/vendor/bin/phpcbf'; + + if (file_exists($phpcbfPath)) { + shell_exec($phpcbfPath . ' ' . $mixinsDir); + $io->success('Code beautified'); + } else { + $io->warning('phpcbf not found, skipping code beautification'); + } + + $io->success('Mixin interfaces generated successfully'); + + return Command::SUCCESS; + } + + /** @return array */ + private function scanValidators(string $directory): array + { + $names = []; + + foreach (new DirectoryIterator($directory) as $file) { + if (!$file->isFile()) { + continue; + } + + $className = 'Respect\\Validation\\Validators\\' . $file->getBasename('.php'); + $reflection = new ReflectionClass($className); + + if ($reflection->isAbstract()) { + continue; + } + + $names[$reflection->getShortName()] = $reflection; + } + + ksort($names); + + return $names; + } + + /** + * @param array $allowList + * @param array $denyList + */ + private function addMethodToInterface( + string $originalName, + InterfaceType $interfaceType, + ReflectionClass $reflection, + string|null $prefix, + array $allowList, + array $denyList, + ): void { + if ($allowList !== [] && !in_array($reflection->getShortName(), $allowList, true)) { + return; + } + + if ($denyList !== [] && in_array($reflection->getShortName(), $denyList, true)) { + return; + } + + $name = $prefix ? $prefix . ucfirst($originalName) : lcfirst($originalName); + $method = $interfaceType->addMethod($name)->setPublic()->setReturnType(Chain::class); + + if (str_contains($interfaceType->getName(), 'Builder')) { + $method->setStatic(); + } + + if ($prefix === 'key') { + $method->addParameter('key')->setType('int|string'); + } + + if ($prefix === 'property') { + $method->addParameter('propertyName')->setType('string'); + } + + $reflectionConstructor = $reflection->getConstructor(); + if ($reflectionConstructor === null) { + return; + } + + $comment = $reflectionConstructor->getDocComment(); + if ($comment) { + $method->addComment(preg_replace('@(/\*\* *| +\* +| +\*/)@', '', $comment)); + } + + foreach ($reflectionConstructor->getParameters() as $reflectionParameter) { + $this->addParameterToMethod($method, $reflectionParameter); + } + } + + private function addParameterToMethod(Method $method, ReflectionParameter $reflectionParameter): void + { + if ($reflectionParameter->isVariadic()) { + $method->setVariadic(); + } + + $type = $reflectionParameter->getType(); + $types = []; + + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $subType) { + $types[] = $subType->getName(); + } + } elseif ($type instanceof ReflectionNamedType) { + $types[] = $type->getName(); + if ( + str_starts_with($type->getName(), 'Sokil') + || str_starts_with($type->getName(), 'Egulias') + || $type->getName() === 'finfo' + ) { + return; + } + } + + $parameter = $method->addParameter($reflectionParameter->getName()); + $parameter->setType(implode('|', $types)); + + if (!$reflectionParameter->isDefaultValueAvailable()) { + $parameter->setNullable($reflectionParameter->isOptional()); + } + + if (count($types) > 1 || $reflectionParameter->isVariadic()) { + $parameter->setNullable(false); + } + + if (!$reflectionParameter->isDefaultValueAvailable()) { + return; + } + + $defaultValue = $reflectionParameter->getDefaultValue(); + if (is_object($defaultValue)) { + $parameter->setDefaultValue(null); + $parameter->setNullable(true); + + return; + } + + $parameter->setDefaultValue($defaultValue); + $parameter->setNullable(false); + } + + private function overwriteFile(string $content, string $basename): void + { + $libraryDir = dirname(__DIR__, 2) . '/library'; + $docheaderPath = dirname(__DIR__, 2) . '/.docheader'; + $docheader = file_exists($docheaderPath) ? file_get_contents($docheaderPath) : ''; + + $finalContent = implode("\n\n", array_filter([ + ' + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +namespace Respect\Dev\Commands; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +use function array_unique; +use function basename; +use function count; +use function dirname; +use function file_exists; +use function file_get_contents; +use function file_put_contents; +use function glob; +use function implode; +use function in_array; +use function preg_match; +use function preg_match_all; +use function preg_split; +use function sort; +use function sprintf; +use function trim; + +use const PHP_EOL; + +#[AsCommand( + name: 'update:doc-links', + description: 'Update list of validators and link related validators in documentation', +)] +final class UpdateDocLinksCommand extends Command +{ + /** @var array> */ + private array $validatorsByCategory = []; + + /** @var array> */ + private array $relatedRules = []; + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $docsDirectory = dirname(__DIR__, 2) . '/docs'; + + if (!file_exists($docsDirectory)) { + $io->error(sprintf('Documentation directory not found: %s', $docsDirectory)); + + return Command::FAILURE; + } + + $validators = $this->listRules($docsDirectory); + $this->createListOfRules($docsDirectory, $validators); + $this->linkRelatedRules($docsDirectory, $validators, $io); + $io->success('Documentation updated successfully'); + + return Command::SUCCESS; + } + + /** @return array */ + private function listRules(string $docsDirectory): array + { + $files = glob($docsDirectory . '/validators/*.md'); + if ($files === false) { + return []; + } + + $validators = []; + foreach ($files as $file) { + $validators[] = basename($file, '.md'); + } + + sort($validators); + + return $validators; + } + + /** + * @param array $validators + * + * @return array + */ + private function listCategories(string $docsDirectory, array $validators): array + { + $categories = []; + + foreach ($validators as $validator) { + $filename = sprintf('%s/validators/%s.md', $docsDirectory, $validator); + $content = file_get_contents($filename); + if ($content === false) { + continue; + } + + // Extract categories between "## Categorization" and "## Changelog" + if (!preg_match('/## Categorization\s*(.*?)\s*## Changelog/s', $content, $matches)) { + continue; + } + + preg_match_all('/^-\s*(.+)$/m', $matches[1], $categoryMatches); + foreach ($categoryMatches[1] as $category) { + $categories[] = trim($category); + } + } + + $categories = array_unique($categories); + sort($categories); + + return $categories; + } + + /** @param array $validators */ + private function createListOfRules(string $docsDirectory, array $validators): void + { + // Build validators by category + foreach ($validators as $validator) { + $filename = sprintf('%s/validators/%s.md', $docsDirectory, $validator); + $content = file_get_contents($filename); + if ($content === false) { + continue; + } + + if (!preg_match('/## Categorization\s*(.*?)\s*## Changelog/s', $content, $matches)) { + continue; + } + + preg_match_all('/^-\s*(.+)$/m', $matches[1], $categoryMatches); + foreach ($categoryMatches[1] as $category) { + $category = trim($category); + if (!isset($this->validatorsByCategory[$category])) { + $this->validatorsByCategory[$category] = []; + } + + $this->validatorsByCategory[$category][] = $validator; + } + } + + // Generate the list file + $categories = $this->listCategories($docsDirectory, $validators); + $lines = ['# List of validators by category', '']; + + foreach ($categories as $category) { + $lines[] = sprintf('## %s', $category); + $lines[] = ''; + + if (isset($this->validatorsByCategory[$category])) { + $categoryRules = $this->validatorsByCategory[$category]; + sort($categoryRules); + foreach ($categoryRules as $validator) { + $lines[] = sprintf('- [%s](validators/%s.md)', $validator, $validator); + } + } + + $lines[] = ''; + } + + $lines[] = '## Alphabetically'; + $lines[] = ''; + foreach ($validators as $validator) { + $lines[] = sprintf('- [%1$s](validators/%1$s.md)', $validator); + } + + $outputFile = sprintf('%s/09-list-of-validators-by-category.md', $docsDirectory); + file_put_contents($outputFile, trim(implode("\n", $lines)) . PHP_EOL); + } + + /** @param array $validators */ + private function linkRelatedRules(string $docsDirectory, array $validators, SymfonyStyle $io): void + { + // Build list of related validators + foreach ($validators as $validator) { + $filename = sprintf('%s/validators/%s.md', $docsDirectory, $validator); + $content = file_get_contents($filename); + if ($content === false) { + continue; + } + + // Find all markdown links + preg_match_all('/\[([^\]]+)\]\(([^\)]+)\.md\)/', $content, $matches); + foreach ($matches[2] as $relatedRule) { + $relatedRule = basename($relatedRule); + if ($relatedRule === '08-comparable-values' || $relatedRule === 'comparing-empty-values') { + continue; + } + + if (!isset($this->relatedRules[$relatedRule])) { + $this->relatedRules[$relatedRule] = []; + } + + if (!in_array($validator, $this->relatedRules[$relatedRule], true)) { + $this->relatedRules[$relatedRule][] = $validator; + } + + if (!isset($this->relatedRules[$validator])) { + $this->relatedRules[$validator] = []; + } + + if (in_array($relatedRule, $this->relatedRules[$validator], true)) { + continue; + } + + $this->relatedRules[$validator][] = $relatedRule; + } + } + + // Update each validator documentation + $io->progressStart(count($validators)); + foreach ($validators as $validator) { + $this->createRuleDocumentation($docsDirectory, $validator); + $io->progressAdvance(); + } + + $io->progressFinish(); + } + + private function createRuleDocumentation(string $docsDirectory, string $validator): void + { + $filename = sprintf('%s/validators/%s.md', $docsDirectory, $validator); + $content = file_get_contents($filename); + if ($content === false) { + return; + } + + // Extract links at the bottom + preg_match_all('/^\[.+\]: .+$/m', $content, $linkMatches); + $links = $linkMatches[0] ?? []; + + // Get content before "See also" or "---" + $parts = preg_split('/(^## See also:|^---)/m', $content); + $mainContent = $parts[0] ?? $content; + + // Build "See also" section + $relatedLinks = []; + if (isset($this->relatedRules[$validator])) { + $related = array_unique($this->relatedRules[$validator]); + sort($related); + foreach ($related as $relatedRule) { + if ($relatedRule === $validator) { + continue; + } + + $relatedLinks[] = sprintf('- [%s](%s.md)', $relatedRule, $relatedRule); + } + } + + // Rebuild the document + $lines = [ + trim($mainContent), + '', + '---', + '', + ]; + + if ($relatedLinks !== []) { + $lines[] = 'See also:'; + $lines[] = ''; + $lines = [...$lines, ...$relatedLinks]; + } + + if ($links !== []) { + $lines[] = ''; + $lines = [...$lines, ...$links]; + } + + file_put_contents($filename, trim(implode("\n", $lines)) . PHP_EOL); + } +} diff --git a/src-dev/Commands/UpdateDomainSuffixesCommand.php b/src-dev/Commands/UpdateDomainSuffixesCommand.php new file mode 100644 index 00000000..2fa5b9c8 --- /dev/null +++ b/src-dev/Commands/UpdateDomainSuffixesCommand.php @@ -0,0 +1,207 @@ + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +namespace Respect\Dev\Commands; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\VarExporter\VarExporter; + +use function array_keys; +use function array_unique; +use function count; +use function dirname; +use function explode; +use function file_get_contents; +use function file_put_contents; +use function glob; +use function implode; +use function is_dir; +use function mb_strtoupper; +use function mkdir; +use function preg_match; +use function rmdir; +use function sort; +use function sprintf; +use function str_replace; +use function str_starts_with; +use function trim; +use function unlink; + +use const PHP_EOL; + +#[AsCommand( + name: 'update:domain-suffixes', + description: 'Update list of public domain suffixes', +)] +final class UpdateDomainSuffixesCommand extends Command +{ + private const string LIST_URL = 'https://publicsuffix.org/list/public_suffix_list.dat'; + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('Updating domain suffixes'); + + // Download the list + $io->section('Downloading list'); + $io->text(sprintf('Fetching from: %s', self::LIST_URL)); + + $listContent = file_get_contents(self::LIST_URL); + if ($listContent === false) { + $io->error('Failed to download public suffix list'); + + return Command::FAILURE; + } + + $io->success('Downloaded successfully'); + + // Clean old data + $io->section('Removing old data'); + $dataDir = dirname(__DIR__, 2) . '/data/domain'; + $this->removeDirectory($dataDir . '/public-suffix'); + + if (!is_dir($dataDir)) { + mkdir($dataDir, 0777, true); + } + + mkdir($dataDir . '/public-suffix', 0777, true); + $io->success('Old data removed'); + + // Process the list + $io->section('Processing public suffix list'); + + $suffixes = $this->parseTldsList($listContent); + $tlds = array_unique(array_keys($suffixes)); + sort($tlds); + + $io->text(sprintf('Found %d TLDs with suffixes', count($tlds))); + + // Create files + $io->section('Creating suffix files'); + $progressBar = $io->createProgressBar(count($tlds)); + $progressBar->start(); + + foreach ($tlds as $tld) { + $suffixList = $suffixes[$tld]; + if ($suffixList === []) { + $progressBar->advance(); + continue; + } + + sort($suffixList); + + $fileContent = implode(PHP_EOL, [ + 'advance(); + } + + $progressBar->finish(); + $io->newLine(2); + + $io->success('Domain suffixes updated successfully'); + + return Command::SUCCESS; + } + + /** @return array> */ + private function parseTldsList(string $content): array + { + $lines = explode("\n", $content); + $suffixes = []; + $icannOnly = true; + + foreach ($lines as $line) { + $line = trim($line); + + // Check if we've reached the end of ICANN domains + if ($line === '// ===END ICANN DOMAINS===') { + $icannOnly = false; + } + + // Skip comments and empty lines + if ($line === '' || str_starts_with($line, '//')) { + continue; + } + + // Process the suffix + $suffix = $line; + + // Remove wildcards and exceptions + $suffix = str_replace('*.', '', $suffix); + $suffix = str_replace('!', '', $suffix); + + // Convert to uppercase (using multibyte for international characters) + $suffix = mb_strtoupper($suffix, 'UTF-8'); + + // Split into TLD and prefix + if (!preg_match('/^([^.]+)$|^(.+)\.([^.]+)$/', $suffix, $matches)) { + continue; + } + + if (isset($matches[3])) { + // Has a prefix + $tld = $matches[3]; + $prefix = $matches[2]; + + if (!isset($suffixes[$tld])) { + $suffixes[$tld] = []; + } + + // Only add ICANN domains + if ($icannOnly) { + $suffixes[$tld][] = $prefix . '.' . $tld; + } + } else { + // Just a TLD + $tld = $matches[1]; + if (!isset($suffixes[$tld])) { + $suffixes[$tld] = []; + } + } + } + + return $suffixes; + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $files = glob($directory . '/*'); + if ($files === false) { + return; + } + + foreach ($files as $file) { + if (is_dir($file)) { + $this->removeDirectory($file); + } else { + @unlink($file); + } + } + + @rmdir($directory); + } +} diff --git a/src-dev/Commands/UpdateDomainToplevelCommand.php b/src-dev/Commands/UpdateDomainToplevelCommand.php new file mode 100644 index 00000000..20085df1 --- /dev/null +++ b/src-dev/Commands/UpdateDomainToplevelCommand.php @@ -0,0 +1,95 @@ + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +namespace Respect\Dev\Commands; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\VarExporter\VarExporter; + +use function basename; +use function count; +use function dirname; +use function explode; +use function file_get_contents; +use function file_put_contents; +use function implode; +use function sprintf; +use function str_starts_with; +use function trim; + +use const PHP_EOL; + +#[AsCommand( + name: 'update:domain-toplevel', + description: 'Update list of Top Level Domains (TLD) in the Tld validator', +)] +final class UpdateDomainToplevelCommand extends Command +{ + private const string LIST_URL = 'https://data.iana.org/TLD/tlds-alpha-by-domain.txt'; + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('Updating TLD list'); + + // Download the list + $io->section('Downloading list'); + $io->text(sprintf('Fetching from: %s', self::LIST_URL)); + + $listContent = file_get_contents(self::LIST_URL); + if ($listContent === false) { + $io->error('Failed to download TLD list'); + + return Command::FAILURE; + } + + $io->success('Downloaded successfully'); + + // Process the list + $io->section('Processing TLD list'); + + $lines = explode("\n", trim($listContent)); + $tlds = []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + $tlds[] = $line; + } + + // Create the data file + $dataFilename = dirname(__DIR__, 2) . '/data/domain/tld.php'; + + $fileContent = implode(PHP_EOL, [ + 'error('Failed to write data file'); + + return Command::FAILURE; + } + + $io->success(sprintf('Updated %s successfully', basename($dataFilename))); + $io->text(sprintf('Total TLDs: %d', count($tlds))); + + return Command::SUCCESS; + } +} diff --git a/src-dev/Commands/UpdatePostalCodesCommand.php b/src-dev/Commands/UpdatePostalCodesCommand.php new file mode 100644 index 00000000..5745dcaa --- /dev/null +++ b/src-dev/Commands/UpdatePostalCodesCommand.php @@ -0,0 +1,130 @@ + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +namespace Respect\Dev\Commands; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\VarExporter\VarExporter; + +use function basename; +use function count; +use function dirname; +use function explode; +use function file_get_contents; +use function file_put_contents; +use function implode; +use function ksort; +use function preg_replace; +use function preg_replace_callback; +use function sprintf; +use function str_contains; +use function str_starts_with; +use function strlen; +use function trim; + +use const PHP_EOL; + +#[AsCommand( + name: 'update:postal-codes', + description: 'Update the list of postal codes in the PostalCode validator', +)] +final class UpdatePostalCodesCommand extends Command +{ + private const string LIST_URL = 'https://download.geonames.org/export/dump/countryInfo.txt'; + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $io->title('Updating postal codes list'); + + // Download the list + $io->section('Downloading list'); + $io->text(sprintf('Fetching from: %s', self::LIST_URL)); + + $listContent = file_get_contents(self::LIST_URL); + if ($listContent === false) { + $io->error('Failed to download postal codes list'); + + return Command::FAILURE; + } + + $io->success('Downloaded successfully'); + + // Process the list + $io->section('Processing postal codes'); + + $lines = explode("\n", $listContent); + $postalCodes = []; + + foreach ($lines as $line) { + $line = trim($line); + + // Skip comments and empty lines + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + // Split by tab + $parts = explode("\t", $line); + if (count($parts) < 15) { + continue; + } + + $countryCode = $parts[0]; + $countryFormat = $parts[13]; + $countryRegex = trim($parts[14]); + + if ($countryRegex === '') { + continue; + } + + // Convert format + $countryFormat = preg_replace_callback('/(#+|@+)/', static function ($matches) { + $length = strlen($matches[0]); + $regex = str_contains($matches[0], '#') ? '\d' : '\w'; + if ($length > 1) { + $regex .= '{' . $length . '}'; + } + + return $regex; + }, $countryFormat); + + $postalCodes[$countryCode] = ['/^' . $countryFormat . '$/', '/' . $countryRegex . '/']; + } + + ksort($postalCodes); + + // Create the data file + $dataFilename = dirname(__DIR__, 2) . '/data/domain/postal-code.php'; + + $fileContent = implode(PHP_EOL, [ + 'error('Failed to write data file'); + + return Command::FAILURE; + } + + $io->success(sprintf('Updated %s successfully', basename($dataFilename))); + $io->text(sprintf('Total postal codes: %d', count($postalCodes))); + + return Command::SUCCESS; + } +}