├── VERSION ├── .gitignore ├── .travis.yml ├── phpunit.dist.xml ├── phpunit.ci.xml ├── composer.json ├── test ├── AutolinkStaticTest.php └── AutolinkTest.php ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── src ├── AutolinkOptions.php ├── AutolinkStatic.php ├── LinkHelper.php ├── HtmlBuilder.php └── Autolink.php ├── bin ├── release.php └── Console.php └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 2.2.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # System 2 | .* 3 | !.gitignore 4 | !.travis.yml 5 | !.github 6 | 7 | # Composer 8 | /vendor/* 9 | composer.lock 10 | 11 | # Test 12 | phpunit.xml 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | - 7.2 6 | - 7.3 7 | - 7.4 8 | - 8.0 9 | - nightly 10 | 11 | matrix: 12 | allow_failures: 13 | - php: 8.0 14 | - php: nightly 15 | 16 | before_install: 17 | 18 | before_script: 19 | - composer install 20 | 21 | script: 22 | - ./vendor/bin/phpunit -v --configuration phpunit.travis.xml 23 | -------------------------------------------------------------------------------- /phpunit.dist.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | test 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /phpunit.ci.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | test 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "asika/autolink", 3 | "description": "Auto convert url to link anchor.", 4 | "type": "library", 5 | "keywords": [ 6 | "link", 7 | "linkify", 8 | "autolink" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "Simon Asika", 13 | "email": "asika32764@gmail.com" 14 | } 15 | ], 16 | "homepage": "https://github.com/asika32764/php-autolink", 17 | "minimum-stability": "beta", 18 | "require": { 19 | "php": ">=8.2" 20 | }, 21 | "require-dev": { 22 | "windwalker/test": "^4.0", 23 | "windwalker/utilities": "^4.0", 24 | "phpunit/phpunit": "^10.0||^11.0" 25 | }, 26 | "scripts": { 27 | "test": "phpunit" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Asika\\Autolink\\": "src/" 32 | } 33 | }, 34 | "config": { 35 | }, 36 | "license": "MIT" 37 | } 38 | -------------------------------------------------------------------------------- /test/AutolinkStaticTest.php: -------------------------------------------------------------------------------- 1 | %s', $url, $url), AutolinkStatic::convert($url)); 35 | self::assertEquals(sprintf('%s', $url, $url), AutolinkStatic::link($url)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: UnitTest 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | matrix: 9 | php-versions: [ '8.2', '8.3', '8.4' ] 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | # PHP 14 | - name: Setup PHP 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: ${{ matrix.php-versions }} 18 | # extensions: mbstring 19 | - name: Get composer cache directory 20 | id: composercache 21 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 22 | - name: Cache composer dependencies 23 | uses: actions/cache@v4 24 | with: 25 | path: ${{ steps.composercache.outputs.dir }} 26 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} 27 | restore-keys: ${{ runner.os }}-composer- 28 | - name: Install dependencies 29 | run: composer update --prefer-dist --prefer-stable --no-progress --no-suggest 30 | 31 | - name: Run test suite 32 | run: php vendor/bin/phpunit --configuration phpunit.ci.xml 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2019 Simon Asika 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /src/AutolinkOptions.php: -------------------------------------------------------------------------------- 1 | 'stripScheme', 38 | 'text_limit' => 'textLimit', 39 | 'auto_title' => 'autoTitle', 40 | 'escape' => 'escape', 41 | 'link_no_scheme' => 'linkNoScheme', 42 | default => $key, 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/AutolinkStatic.php: -------------------------------------------------------------------------------- 1 | null, 23 | 'user' => null, 24 | 'pass' => null, 25 | 'host' => null, 26 | 'port' => null, 27 | 'path' => null, 28 | 'query' => null, 29 | 'fragment' => null 30 | ]; 31 | 32 | /** 33 | * @param string $url 34 | * @param int $lastPartLimit 35 | * @param int $dots 36 | * 37 | * @return string 38 | * 39 | * @since 1.1.1 40 | * 41 | * @deprecated Use Autolink::shortenUrl() instead. 42 | */ 43 | public static function shorten(string $url, int $lastPartLimit = 15, int $dots = 6): string 44 | { 45 | $parsed = array_merge(static::$defaultParsed, parse_url($url)); 46 | 47 | // @link http://php.net/manual/en/function.parse-url.php#106731 48 | $scheme = isset($parsed['scheme']) ? $parsed['scheme'] . '://' : ''; 49 | $host = isset($parsed['host']) ? $parsed['host'] : ''; 50 | $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; 51 | $user = isset($parsed['user']) ? $parsed['user'] : ''; 52 | $pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : ''; 53 | $pass = ($user || $pass) ? "$pass@" : ''; 54 | $path = isset($parsed['path']) ? $parsed['path'] : ''; 55 | $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; 56 | $fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : ''; 57 | 58 | $first = $scheme . $user . $pass . $host . $port . '/'; 59 | 60 | $last = $path . $query . $fragment; 61 | 62 | if (!$last) { 63 | return $first; 64 | } 65 | 66 | if (strlen($last) <= $lastPartLimit) { 67 | return $first . $last; 68 | } 69 | 70 | $last = explode('/', $last); 71 | $last = array_pop($last); 72 | 73 | if (strlen($last) > $lastPartLimit) { 74 | $last = '/' . substr($last, 0, $lastPartLimit) . str_repeat('.', $dots); 75 | } 76 | 77 | return $first . str_repeat('.', $dots) . $last; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/HtmlBuilder.php: -------------------------------------------------------------------------------- 1 | 'readonly', 40 | 'disabled' => 'disabled', 41 | 'multiple' => 'true', 42 | 'checked' => 'checked', 43 | 'selected' => 'selected', 44 | ]; 45 | 46 | /** 47 | * Create a html element. 48 | * 49 | * @param string $name Element tag name. 50 | * @param array $attribs Element attributes. 51 | * @param mixed $content Element content. 52 | * @param bool $forcePair Force pair it. 53 | * 54 | * @return string Created element string. 55 | */ 56 | public static function create( 57 | string $name, 58 | array $attribs = [], 59 | string $content = '', 60 | bool $forcePair = false 61 | ): string { 62 | $forcePair = $forcePair ?: !in_array(strtolower($name), static::$unpairedElements, true); 63 | 64 | $name = trim($name); 65 | 66 | $tag = '<' . $name; 67 | 68 | $tag .= static::buildAttributes($attribs); 69 | 70 | if ($content !== null) { 71 | $tag .= '>' . $content . ''; 72 | } else { 73 | $tag .= $forcePair ? '>' : ' />'; 74 | } 75 | 76 | return $tag; 77 | } 78 | 79 | /** 80 | * buildAttributes 81 | * 82 | * @param array $attribs 83 | * 84 | * @return string 85 | */ 86 | public static function buildAttributes(array $attribs): string 87 | { 88 | $attribs = static::mapAttrValues($attribs); 89 | 90 | $string = ''; 91 | 92 | foreach ((array) $attribs as $key => $value) { 93 | if ($value === true) { 94 | $string .= ' ' . $key; 95 | 96 | continue; 97 | } 98 | 99 | if ($value === null || $value === false) { 100 | continue; 101 | } 102 | 103 | $string .= ' ' . $key . '=' . static::quote($value); 104 | } 105 | 106 | return $string; 107 | } 108 | 109 | /** 110 | * quote 111 | * 112 | * @param string $value 113 | * 114 | * @return string 115 | */ 116 | public static function quote(string $value): string 117 | { 118 | return '"' . $value . '"'; 119 | } 120 | 121 | /** 122 | * mapAttrValues 123 | * 124 | * @param array $attribs 125 | * 126 | * @return array 127 | */ 128 | protected static function mapAttrValues(array $attribs): array 129 | { 130 | foreach (static::$trueValueMapping as $key => $value) { 131 | $attribs[$key] = !empty($attribs[$key]) ? $value : null; 132 | } 133 | 134 | return $attribs; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /bin/release.php: -------------------------------------------------------------------------------- 1 | get('dry-run'); 19 | } 20 | } 21 | 22 | protected function configure(): void 23 | { 24 | $this->addParameter('version', type: static::STRING) 25 | ->description('Can be name or major|minor|patch|alpha|beta|rc') 26 | ->default('patch'); 27 | 28 | $this->addParameter('suffix', type: static::STRING) 29 | ->description('The suffix type. Can be alpha|beta|rc'); 30 | 31 | $this->addParameter('--dry-run|-d', type: static::BOOLEAN) 32 | ->description('Run process but do not execute any commands.'); 33 | 34 | $this->addParameter('--from', type: static::STRING) 35 | ->description('The version to release from. Default is the current version.') 36 | ->required(true); 37 | } 38 | 39 | protected function doExecute(): int 40 | { 41 | foreach ($this->scripts as $script) { 42 | $this->exec($script); 43 | } 44 | 45 | $currentVersion = $this->get('from') ?: trim(file_get_contents(__DIR__ . '/../VERSION')); 46 | $targetVersion = (string) $this->get('version'); 47 | $targetSuffix = (string) $this->get('suffix'); 48 | 49 | if (in_array($targetVersion, ['alpha', 'beta', 'rc'])) { 50 | $targetSuffix = $targetVersion; 51 | $targetVersion = 'patch'; 52 | } 53 | 54 | $targetVersion = static::versionPush($currentVersion, $targetVersion, $targetSuffix); 55 | 56 | $this->writeln('Release version: ' . $targetVersion); 57 | 58 | if (!$this->isDryRun) { 59 | static::writeVersion($targetVersion); 60 | } 61 | 62 | $this->exec(sprintf('git commit -am "Release version: %s"', $targetVersion)); 63 | $this->exec(sprintf('git tag %s', $targetVersion)); 64 | 65 | $this->exec('git push'); 66 | $this->exec('git push --tags'); 67 | 68 | return static::SUCCESS; 69 | } 70 | 71 | protected static function writeVersion(string $version): false|int 72 | { 73 | return file_put_contents(static::versionFile(), $version . "\n"); 74 | } 75 | 76 | protected static function versionFile(): string 77 | { 78 | return __DIR__ . '/../VERSION'; 79 | } 80 | 81 | protected static function versionPush( 82 | string $currentVersion, 83 | string $targetVersion, 84 | string $targetSuffix, 85 | ): string { 86 | [$major, $minor, $patch, $suffixType, $suffixVersion] = static::parseVersion($currentVersion); 87 | 88 | switch ($targetVersion) { 89 | case 'major': 90 | $major++; 91 | $minor = $patch = 0; 92 | if ($targetSuffix) { 93 | $suffixType = $targetSuffix; 94 | $suffixVersion = 1; 95 | } else { 96 | $suffixType = ''; 97 | $suffixVersion = 0; 98 | } 99 | break; 100 | 101 | case 'minor': 102 | $minor++; 103 | $patch = 0; 104 | if ($targetSuffix) { 105 | $suffixType = $targetSuffix; 106 | $suffixVersion = 1; 107 | } else { 108 | $suffixType = ''; 109 | $suffixVersion = 0; 110 | } 111 | break; 112 | 113 | case 'patch': 114 | if (!$suffixType) { 115 | $patch++; 116 | } 117 | if ($targetSuffix) { 118 | if ($suffixType === $targetSuffix) { 119 | $suffixVersion++; 120 | } else { 121 | $suffixType = $targetSuffix; 122 | $suffixVersion = 1; 123 | } 124 | } else { 125 | $suffixType = ''; 126 | $suffixVersion = 0; 127 | } 128 | break; 129 | 130 | default: 131 | return $targetVersion; 132 | } 133 | 134 | $currentVersion = $major . '.' . $minor . '.' . $patch; 135 | 136 | if ($suffixType) { 137 | $currentVersion .= '-' . $suffixType . '.' . $suffixVersion; 138 | } 139 | 140 | return $currentVersion; 141 | } 142 | 143 | public static function parseVersion(string $currentVersion): array 144 | { 145 | [$currentVersion, $prerelease] = explode('-', $currentVersion, 2) + ['', '']; 146 | 147 | [$major, $minor, $patch] = explode('.', $currentVersion, 3) + ['', '0', '0']; 148 | $major = (int) $major; 149 | $minor = (int) $minor; 150 | $patch = (int) $patch; 151 | $prereleaseType = ''; 152 | $prereleaseVersion = 0; 153 | 154 | if ($prerelease) { 155 | $matched = preg_match('/(rc|beta|alpha)[.-]?(\d+)/i', $prerelease, $matches); 156 | 157 | if ($matched) { 158 | $prereleaseType = strtolower($matches[1]); 159 | $prereleaseVersion = (int) $matches[2]; 160 | } 161 | } 162 | 163 | return [$major, $minor, $patch, $prereleaseType, $prereleaseVersion]; 164 | } 165 | 166 | public function exec(string $cmd, \Closure|null|false $output = null, bool $showCmd = true): ExecResult 167 | { 168 | $this->writeln('>> ' . ($this->isDryRun ? '(Dry Run) ' : '') . $cmd); 169 | 170 | if (!$this->isDryRun) { 171 | return parent::exec($cmd, $output, false); 172 | } 173 | 174 | return new ExecResult(); 175 | } 176 | 177 | public function addScript(string $script): static 178 | { 179 | $this->scripts[] = $script; 180 | 181 | return $this; 182 | } 183 | }; 184 | 185 | $app->execute(); 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Autolink Library 2 | 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/asika32764/php-autolink/ci.yml?style=for-the-badge) 4 | [![Packagist Version](https://img.shields.io/packagist/v/asika/autolink?style=for-the-badge) 5 | ](https://packagist.org/packages/asika/autolink) 6 | [![Packagist Downloads](https://img.shields.io/packagist/dt/asika/autolink?style=for-the-badge)](https://packagist.org/packages/asika/autolink) 7 | 8 | A library to auto convert URLs to links. 9 | 10 | ## Table of Content 11 | 12 | 13 | * [PHP Autolink Library](#php-autolink-library) 14 | * [Table of Content](#table-of-content) 15 | * [Requirement](#requirement) 16 | * [Installation via Composer](#installation-via-composer) 17 | * [Getting Started](#getting-started) 18 | * [Use Autolink Object](#use-autolink-object) 19 | * [Convert Text](#convert-text) 20 | * [Add Attributes](#add-attributes) 21 | * [Convert Email](#convert-email) 22 | * [Attributes Escaping](#attributes-escaping) 23 | * [Options](#options) 24 | * [`textLimit`](#textlimit) 25 | * [`autoTitle`](#autotitle) 26 | * [`stripScheme`](#stripscheme) 27 | * [`escape`](#escape) 28 | * [`linkNoScheme`](#linknoscheme) 29 | * [Scheme](#scheme) 30 | * [Link Builder](#link-builder) 31 | 32 | 33 | ## Requirement 34 | 35 | - Version 2.1.x require PHP 8.2 or higher. 36 | - Version 2.0.x require PHP 8.0 or higher. 37 | - Version 1.x supports PHP 5.3 to 7.4 38 | 39 | ## Installation via Composer 40 | 41 | Add this to composer.json require block. 42 | 43 | ``` json 44 | { 45 | "require": { 46 | "asika/autolink": "^2.0" 47 | } 48 | } 49 | ``` 50 | 51 | ## Getting Started 52 | 53 | This is a quick start to convert URL to link: 54 | 55 | ```php 56 | use Asika\Autolink\AutolinkStatic; 57 | 58 | $text = AutolinkStatic::convert($text); 59 | $text = AutolinkStatic::convertEmail($text); 60 | ``` 61 | 62 | ## Use Autolink Object 63 | 64 | Create the object: 65 | 66 | ```php 67 | use Asika\Autolink\Autolink; 68 | 69 | $autolink = new Autolink(); 70 | ``` 71 | 72 | Create with options. 73 | 74 | ```php 75 | use Asika\Autolink\AutolinkOptions; 76 | 77 | $options = new AutolinkOptions( 78 | stripScheme: false, 79 | textLimit: null, 80 | autoTitle: false, 81 | escape: true, 82 | linkNoScheme: false 83 | ); 84 | 85 | $schemes = ['http', 'https', 'skype', 'itunes']; 86 | 87 | $autolink = new Autolink($options, $schemes); 88 | ``` 89 | 90 | ## Convert Text 91 | 92 | This is an example text: 93 | 94 | ``` html 95 | This is Simple URL: 96 | http://www.google.com.tw 97 | 98 | This is SSL URL: 99 | https://www.google.com.tw 100 | 101 | This is URL with multi-level query: 102 | http://example.com/?foo[1]=a&foo[2]=b 103 | ``` 104 | 105 | We convert all URLs. 106 | 107 | ```php 108 | $text = $autolink->convert($text); 109 | ``` 110 | 111 | Output: 112 | 113 | ``` html 114 | This is Simple URL: 115 | http://www.google.com.tw 116 | 117 | This is SSL URL: 118 | https://www.google.com.tw 119 | 120 | This is URL with multi-level query: 121 | http://example.com/?foo[1]=a&foo[2]=b 122 | ``` 123 | 124 | ### Add Attributes 125 | 126 | ```php 127 | $text = $autolink->convert($text, ['class' => 'center']); 128 | ``` 129 | 130 | All link will add this attributes: 131 | 132 | ```php 133 | This is Simple URL: 134 | http://www.google.com.tw 135 | 136 | This is SSL URL: 137 | https://www.google.com.tw 138 | ``` 139 | 140 | ## Convert Email 141 | 142 | Email url has no scheme, we use anoter method to convert them, and it will add `mailto:` at begin of `href`. 143 | 144 | ```php 145 | $text = $autolink->convertEmail($text); 146 | ``` 147 | 148 | Output 149 | 150 | ``` html 151 | foo@example.com 152 | 153 | ``` 154 | 155 | ## Attributes Escaping 156 | 157 | As `htmlspecialchars()` in PHP 8.1 or higher will escape single quote as default, 158 | Autolink will also escape single quote even in 8.0. Use this method to keep all escape 159 | behavior same at any PHP versions: 160 | 161 | ```php 162 | $autolink->escape('...'); 163 | ``` 164 | 165 | If you want to change the escape behavior, set your custom escape handler: 166 | 167 | ```php 168 | $autolink->setEscapeHandler(fn => ...); 169 | ``` 170 | 171 | ## Options 172 | 173 | ### `textLimit` 174 | 175 | We can set this option by constructor or setter: 176 | 177 | ```php 178 | $autolink->textLimit(50); 179 | 180 | $text = $autolink->convert($text); 181 | ``` 182 | 183 | The link text will be: 184 | 185 | ``` 186 | http://campus.asukademy.com/learning/job/84-fin... 187 | ``` 188 | 189 | Use Your own limit handler by set a callback: 190 | 191 | ```php 192 | $autolink->textLimit(function($url) { 193 | return substr($url, 0, 50) . '...'; 194 | }); 195 | ``` 196 | 197 | Or use `\Asika\Autolink\LinkHelper::shorten()` Pretty handler: 198 | 199 | ```php 200 | $autolink->textLimit(function($url) { 201 | return \Asika\Autolink\Autolink::shortenUrl($url, 15, 6); 202 | }); 203 | ``` 204 | 205 | Output: 206 | 207 | ``` text 208 | http://campus.asukademy.com/....../84-find-interns...... 209 | ``` 210 | 211 | ### `autoTitle` 212 | 213 | Use AutoTitle to force add title on anchor element. 214 | 215 | ```php 216 | $autolink->autoTitle(true); 217 | 218 | $text = $autolink->convert($text); 219 | ``` 220 | 221 | Output: 222 | 223 | ``` html 224 | http://www.google.com.tw 225 | ``` 226 | 227 | ### `stripScheme` 228 | 229 | Strip Scheme on link text: 230 | 231 | ```php 232 | $autolink->stripScheme(true); 233 | 234 | $text = $autolink->convert($text); 235 | ``` 236 | 237 | Output 238 | 239 | ``` html 240 | www.google.com.tw 241 | ``` 242 | 243 | ### `escape` 244 | 245 | Auto escape URL, default is `true`: 246 | 247 | ```php 248 | $autolink->autoEscape(false); 249 | 250 | $text = $autolink->convert($text); 251 | 252 | $autolink->autoEscape(true); 253 | 254 | $text = $autolink->convert($text); 255 | ``` 256 | 257 | Output 258 | 259 | ``` html 260 | http://www.google.com.tw?foo=bar&yoo=baz 261 | http://www.google.com.tw?foo=bar&yoo=baz 262 | ``` 263 | 264 | ### `linkNoScheme` 265 | 266 | Convert URL which no scheme. If you pass `TRUE` to this option, Autolink will use 267 | `http` as default scheme, you can also provide your own default scheme. 268 | 269 | ```php 270 | $autolink->linkNoScheme('https'); 271 | 272 | $text = $autolink->convert('www.google.com.tw'); 273 | ``` 274 | 275 | Output 276 | 277 | ``` html 278 | www.google.com.tw 279 | ``` 280 | 281 | ## Scheme 282 | 283 | You can add new scheme to convert URL begin with it, for example: `vnc://example.com` 284 | 285 | ```php 286 | $autolink->addScheme('skype', 'vnc'); 287 | ``` 288 | 289 | Default schemes is `http, https, ftp, ftps`. 290 | 291 | ## Link Builder 292 | 293 | If you don't want to use `` element as your link, you can set a callback to build link HTML. 294 | 295 | ```php 296 | $autolink->setLinkBuilder(function(string $url, array $attribs) { 297 | $attribs['src'] = htmlspecialchars($url); 298 | 299 | return \Asika\Autolink\HtmlBuilder::create('img', $attribs, null); 300 | }); 301 | ``` 302 | -------------------------------------------------------------------------------- /src/Autolink.php: -------------------------------------------------------------------------------- 1 | null, 35 | 'user' => null, 36 | 'pass' => null, 37 | 'host' => null, 38 | 'port' => null, 39 | 'path' => null, 40 | 'query' => null, 41 | 'fragment' => null 42 | ]; 43 | 44 | /** 45 | * Property linkBuilder. 46 | * 47 | * @var callable 48 | */ 49 | protected $linkBuilder; 50 | 51 | protected ?\Closure $escapeHandler = null; 52 | 53 | /** 54 | * Class init. 55 | * 56 | * @param AutolinkOptions|array $options Basic options. 57 | * @param array $schemes 58 | */ 59 | public function __construct(AutolinkOptions|array $options = [], array $schemes = []) 60 | { 61 | $this->setOptions($options); 62 | 63 | $this->setSchemes(...array_merge($this->schemes, $schemes)); 64 | } 65 | 66 | /** 67 | * render 68 | * 69 | * @param string $text 70 | * @param array $attribs 71 | * 72 | * @return string 73 | */ 74 | public function convert(string $text, array $attribs = []): string 75 | { 76 | $linkNoScheme = $this->getLinkNoScheme(); 77 | $staticDomains = '|localhost'; 78 | 79 | if ($linkNoScheme) { 80 | $schemeRegex = "[(%s)\:\/\/@]*"; 81 | $staticDomains = ''; 82 | } else { 83 | $schemeRegex = "(%s)\:\/\/"; 84 | } 85 | 86 | $schemeRegex = sprintf($schemeRegex, $this->getSchemes(true)); 87 | 88 | $regex = '/(([a-zA-Z]*=")*' . $schemeRegex . "([\-\p{L}\p{N}\p{M}]+\.[\p{L}\p{M}]{2,}$staticDomains)([\/\p{L}\p{N}\p{M}\-._~:?#\[\]@!$&'()*+,;=%\">]*)?)/u"; 89 | 90 | return preg_replace_callback( 91 | $regex, 92 | function ($matches) use ($attribs, $linkNoScheme) { 93 | $url = $matches[0]; 94 | 95 | preg_match('/[a-zA-Z]*\=\"(.*)/', $url, $inElements); 96 | 97 | if ($inElements) { 98 | return $url; 99 | } 100 | 101 | if ( 102 | $linkNoScheme 103 | && ( 104 | str_starts_with($url, '://') 105 | || str_starts_with($url, '@') 106 | ) 107 | ) { 108 | return $url; 109 | } 110 | 111 | $suffix = ''; 112 | 113 | if (str_ends_with($url, '.')) { 114 | $suffix = '.'; 115 | $url = substr($url, 0, -1); 116 | } 117 | 118 | if (str_ends_with($url, ')')) { 119 | $suffix = ')'; 120 | $url = substr($url, 0, -1); 121 | } 122 | 123 | return $this->link($url, $attribs) . $suffix; 124 | }, 125 | $text 126 | ); 127 | } 128 | 129 | /** 130 | * renderEmail 131 | * 132 | * @param string $text 133 | * @param array $attribs 134 | * 135 | * @return string 136 | */ 137 | public function convertEmail(string $text, array $attribs = []): string 138 | { 139 | $regex = "/(([a-zA-Z]*=\")*[a-zA-Z0-9!#$%&'*+-\/=?^_`{|}~:]+@[a-zA-Z0-9!#$%&'*+-\/=?^_`{|}~]+\.[a-zA-Z\">]{2,})/"; 140 | 141 | return preg_replace_callback( 142 | $regex, 143 | function ($matches) use ($attribs) { 144 | // Email should not start with a dot. 145 | if (str_starts_with($matches[0], '.')) { 146 | return $matches[0]; 147 | } 148 | 149 | preg_match('/[a-zA-Z]*\=\"(.*)/', $matches[0], $inElements); 150 | 151 | if (!$inElements) { 152 | $email = $this->isAutoEscape() ? $this->escape($matches[0]) : $matches[0]; 153 | 154 | $attribs['href'] = 'mailto:' . $email; 155 | 156 | return $this->buildLink($matches[0], $attribs); 157 | } 158 | 159 | return $matches[0]; 160 | }, 161 | $text 162 | ); 163 | } 164 | 165 | /** 166 | * convert 167 | * 168 | * @param string $url 169 | * @param array $attribs 170 | * 171 | * @return string 172 | */ 173 | public function link(string $url, array $attribs = []): string 174 | { 175 | $content = $url; 176 | 177 | if ($this->isStripScheme()) { 178 | if (preg_match('!^(' . $this->getSchemes(true) . ')://!i', $content, $m)) { 179 | $content = substr($content, strlen($m[1]) + 3); 180 | } 181 | } 182 | 183 | if ($limit = $this->getTextLimit()) { 184 | if (is_callable($limit)) { 185 | $content = $limit($content); 186 | } else { 187 | $content = $this->shorten($content, $limit); 188 | } 189 | } 190 | 191 | $attribs['href'] = $this->isAutoEscape() ? $this->escape($url) : $url; 192 | 193 | if (($scheme = $this->getLinkNoScheme()) && !str_contains($attribs['href'], '://')) { 194 | $scheme = is_string($scheme) ? $scheme : 'http'; 195 | 196 | $attribs['href'] = $scheme . '://' . $attribs['href']; 197 | } 198 | 199 | if ($this->isAutoTitle()) { 200 | $attribs['title'] = $this->escape($url); 201 | } 202 | 203 | return $this->buildLink($content, $attribs); 204 | } 205 | 206 | /** 207 | * buildLink 208 | * 209 | * @param string|null $url 210 | * @param array $attribs 211 | * 212 | * @return string 213 | */ 214 | protected function buildLink(?string $url = null, array $attribs = []): string 215 | { 216 | if (is_callable($this->linkBuilder)) { 217 | return (string) ($this->linkBuilder)($url, $attribs); 218 | } 219 | 220 | return HtmlBuilder::create('a', $attribs, $this->escape($url)); 221 | } 222 | 223 | /** 224 | * autolinkLabel 225 | * 226 | * @param string $text 227 | * @param int $limit 228 | * 229 | * @return string 230 | */ 231 | public function shorten(string $text, int $limit): string 232 | { 233 | if (!$limit) { 234 | return $text; 235 | } 236 | 237 | if (strlen($text) > $limit) { 238 | return substr($text, 0, $limit - 3) . '...'; 239 | } 240 | 241 | return $text; 242 | } 243 | 244 | public function stripScheme(bool $value = false): static 245 | { 246 | $this->options->stripScheme = $value; 247 | 248 | return $this; 249 | } 250 | 251 | public function isStripScheme(): bool 252 | { 253 | return $this->options->stripScheme; 254 | } 255 | 256 | public function autoEscape(bool $value = true): static 257 | { 258 | $this->options->escape = $value; 259 | 260 | return $this; 261 | } 262 | 263 | public function isAutoEscape(): bool 264 | { 265 | return (bool) $this->options->escape; 266 | } 267 | 268 | /** 269 | * @param int|callable|null $value 270 | * 271 | * @return static 272 | */ 273 | public function textLimit(int|callable|null $value = null): static 274 | { 275 | if (is_callable($value)) { 276 | $value = $value(...); 277 | } 278 | 279 | $this->options->textLimit = $value; 280 | 281 | return $this; 282 | } 283 | 284 | public function getTextLimit(): int|callable|null 285 | { 286 | $value = $this->options->textLimit; 287 | 288 | // Fix for B/C 289 | if ($value === false) { 290 | $value = null; 291 | } 292 | 293 | return $value; 294 | } 295 | 296 | public function autoTitle(bool $value = false): static 297 | { 298 | $this->options->autoTitle = $value; 299 | 300 | return $this; 301 | } 302 | 303 | public function isAutoTitle(): bool 304 | { 305 | return $this->options->autoTitle; 306 | } 307 | 308 | /** 309 | * linkNoScheme 310 | * 311 | * @param bool $value 312 | * 313 | * @return static 314 | */ 315 | public function linkNoScheme(bool|string $value = false): static 316 | { 317 | $this->options->linkNoScheme = $value; 318 | 319 | return $this; 320 | } 321 | 322 | public function getLinkNoScheme(): bool|string 323 | { 324 | return $this->options->linkNoScheme; 325 | } 326 | 327 | /** 328 | * optionAccess 329 | * 330 | * @param string $name 331 | * @param mixed $value 332 | * 333 | * @return static 334 | * 335 | * @deprecated Use {@see AutolinkOptions} instead. 336 | */ 337 | protected function setOption(string $name, mixed $value = null): static 338 | { 339 | $name = AutolinkOptions::mapOptionKey($name); 340 | 341 | $this->options->$name = $value; 342 | 343 | return $this; 344 | } 345 | 346 | /** 347 | * @param string $name 348 | * @param mixed|null $default 349 | * 350 | * @return mixed 351 | * 352 | * @deprecated Use {@see AutolinkOptions} instead. 353 | */ 354 | protected function getOption(string $name, mixed $default = null): mixed 355 | { 356 | $name = AutolinkOptions::mapOptionKey($name); 357 | 358 | return $this->options->$name ?? $default; 359 | } 360 | 361 | /** 362 | * @param string ...$schemes 363 | * 364 | * @return static 365 | */ 366 | public function addScheme(string ...$schemes): static 367 | { 368 | foreach ($schemes as $scheme) { 369 | $scheme = strtolower($scheme); 370 | $this->schemes[] = $scheme; 371 | } 372 | 373 | $this->schemes = array_unique($this->schemes); 374 | 375 | return $this; 376 | } 377 | 378 | /** 379 | * @param string $scheme 380 | * 381 | * @return static 382 | */ 383 | public function removeScheme(string $scheme): static 384 | { 385 | $index = array_search($scheme, $this->schemes, true); 386 | 387 | if ($index !== false) { 388 | unset($this->schemes[$index]); 389 | } 390 | 391 | return $this; 392 | } 393 | 394 | public function getOptions(): AutolinkOptions 395 | { 396 | return $this->options; 397 | } 398 | 399 | /** 400 | * Method to set property options 401 | * 402 | * @param AutolinkOptions|array $options 403 | * 404 | * @return static Return self to support chaining. 405 | */ 406 | public function setOptions(AutolinkOptions|array $options): static 407 | { 408 | $this->options = AutolinkOptions::wrap($options); 409 | 410 | return $this; 411 | } 412 | 413 | /** 414 | * Method to get property Schemes 415 | * 416 | * @param bool $regex 417 | * 418 | * @return array|string 419 | */ 420 | public function getSchemes(bool $regex = false): array|string 421 | { 422 | if ($regex) { 423 | return implode('|', $this->schemes); 424 | } 425 | 426 | return $this->schemes; 427 | } 428 | 429 | /** 430 | * Method to set property schemes 431 | * 432 | * @param string ...$schemes 433 | * 434 | * @return static Return self to support chaining. 435 | */ 436 | public function setSchemes(string ...$schemes): static 437 | { 438 | $schemes = array_unique(array_map('strtolower', $schemes)); 439 | 440 | $this->schemes = $schemes; 441 | 442 | return $this; 443 | } 444 | 445 | /** 446 | * Method to get property LinkBuilder 447 | * 448 | * @return callable 449 | */ 450 | public function getLinkBuilder(): callable 451 | { 452 | return $this->linkBuilder; 453 | } 454 | 455 | /** 456 | * Method to set property linkBuilder 457 | * 458 | * @param callable $linkBuilder 459 | * 460 | * @return static Return self to support chaining. 461 | */ 462 | public function setLinkBuilder(callable $linkBuilder): static 463 | { 464 | if (!is_callable($linkBuilder)) { 465 | throw new \InvalidArgumentException('Please use a callable or Closure.'); 466 | } 467 | 468 | $this->linkBuilder = $linkBuilder; 469 | 470 | return $this; 471 | } 472 | 473 | /** 474 | * @param string $url 475 | * @param int $lastPartLimit 476 | * @param int $dots 477 | * 478 | * @return string 479 | */ 480 | public static function shortenUrl(string $url, int $lastPartLimit = 15, int $dots = 6): string 481 | { 482 | $parsed = array_merge(static::$defaultParsed, parse_url($url)); 483 | 484 | // @link http://php.net/manual/en/function.parse-url.php#106731 485 | $scheme = isset($parsed['scheme']) ? $parsed['scheme'] . '://' : ''; 486 | $host = $parsed['host'] ?? ''; 487 | $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; 488 | $user = $parsed['user'] ?? ''; 489 | $pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : ''; 490 | $pass = ($user || $pass) ? "$pass@" : ''; 491 | $path = $parsed['path'] ?? ''; 492 | $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; 493 | $fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : ''; 494 | 495 | $first = $scheme . $user . $pass . $host . $port . '/'; 496 | 497 | $last = $path . $query . $fragment; 498 | 499 | if (!$last) { 500 | return $first; 501 | } 502 | 503 | if (strlen($last) <= $lastPartLimit) { 504 | return $first . $last; 505 | } 506 | 507 | $last = explode('/', $last); 508 | $last = array_pop($last); 509 | 510 | if (strlen($last) > $lastPartLimit) { 511 | $last = '/' . substr($last, 0, $lastPartLimit) . str_repeat('.', $dots); 512 | } 513 | 514 | return $first . str_repeat('.', $dots) . $last; 515 | } 516 | 517 | public function escape(string $str): string 518 | { 519 | return $this->getEscapeHandler()($str); 520 | } 521 | 522 | public function getEscapeHandler(): ?\Closure 523 | { 524 | return $this->escapeHandler 525 | // PHP 8.1 or higher will escape single quite 526 | ?? static fn ($str) => htmlspecialchars($str, ENT_QUOTES | ENT_SUBSTITUTE); 527 | } 528 | 529 | public function setEscapeHandler(?\Closure $escapeHandler): static 530 | { 531 | $this->escapeHandler = $escapeHandler; 532 | 533 | return $this; 534 | } 535 | } 536 | -------------------------------------------------------------------------------- /test/AutolinkTest.php: -------------------------------------------------------------------------------- 1 | instance = new Autolink(); 35 | } 36 | 37 | /** 38 | * testConvert 39 | * 40 | * @return void 41 | */ 42 | public function testConvert() 43 | { 44 | $text = <<LINK 71 | http://example.com/path?foo[1]=a&foo[2]=b 72 | 73 |
74 |

http://example.com/path?foo[1]=a&foo[2]=b

75 | 76 | TEXT; 77 | 78 | $html = <<http://www.google.com.tw 81 | 82 | This is SSL URL: 83 | https://www.google.com.tw 84 | 85 | This is URL with path: 86 | http://www.google.com.tw/images 87 | 88 | This is URL with query: 89 | http://www.google.com.tw/search?q=foo&num=100 90 | 91 | This is URL with multi-level query: 92 | http://example.com/?foo[1]=a&foo[2]=b 93 | 94 | This is URL with fragment: 95 | http://example.com/path#top 96 | 97 | This is URL inline: http://example.com/path#top with test. 98 | 99 | This is an IDN URL: http://dømi.fo 100 | 101 | This is an IDN URL in Devanagari: http://सार्वभौमिक-स्वीकृति-परीक्षण.संगठन 102 | 103 | This is URL in HTML: 104 | LINK 105 | http://example.com/path?foo[1]=a&foo[2]=b 106 | 107 |
108 |

http://example.com/path?foo[1]=a&foo[2]=b

109 | 110 | HTML; 111 | 112 | self::assertStringSafeEquals($html, $this->instance->convert($text)); 113 | } 114 | 115 | /** 116 | * testConvert 117 | * 118 | * @return void 119 | */ 120 | public function testLink() 121 | { 122 | $url = 'http://www.google.com'; 123 | 124 | self::assertEquals( 125 | 'http://www.google.com', 126 | $this->instance->link($url, ['foo' => 'bar']) 127 | ); 128 | 129 | $this->instance->stripScheme(true); 130 | 131 | self::assertEquals( 132 | 'www.google.com', 133 | $this->instance->link($url, ['foo' => 'bar']) 134 | ); 135 | 136 | $this->instance->autoTitle(true); 137 | 138 | self::assertEquals( 139 | 'www.google.com', 140 | $this->instance->link($url, ['foo' => 'bar']) 141 | ); 142 | } 143 | 144 | /** 145 | * testTextLimit 146 | * 147 | * @return void 148 | */ 149 | public function testTextLimit() 150 | { 151 | $url = 'http://campus.asukademy.com/learning/job/84-find-internship-opportunity-through-platform.html'; 152 | 153 | $this->instance->textLimit(50); 154 | 155 | self::assertEquals( 156 | 'http://campus.asukademy.com/learning/job/84-fin...', 157 | $this->instance->link($url) 158 | ); 159 | 160 | $this->instance->textLimit(function ($url) { 161 | return Autolink::shortenUrl($url); 162 | }); 163 | 164 | self::assertEquals( 165 | 'http://campus.asukademy.com/....../84-find-interns......', 166 | $this->instance->link($url) 167 | ); 168 | } 169 | 170 | /** 171 | * testAutoTitle 172 | * 173 | * @return void 174 | */ 175 | public function testAutoTitle() 176 | { 177 | $url = 'http://example.com/path?foo["1"]=a&foo[\'2\']=b'; 178 | 179 | $this->instance->autoTitle(true); 180 | 181 | self::assertEquals( 182 | 'http://example.com/path?foo["1"]=a&foo['2']=b', 183 | $this->instance->link($url, ['foo' => 'bar']) 184 | ); 185 | } 186 | 187 | /** 188 | * testStripScheme 189 | * 190 | * @return void 191 | */ 192 | public function testStripScheme() 193 | { 194 | $this->instance->stripScheme(true); 195 | 196 | $url = 'http://campus.asukademy.com/learning/job/84-find-internship-opportunity-through-platform.html'; 197 | 198 | self::assertEquals( 199 | 'campus.asukademy.com/learning/job/84-find-internship-opportunity-through-platform.html', 200 | $this->instance->link($url) 201 | ); 202 | } 203 | 204 | public function testAddScheme() 205 | { 206 | $url = 'ftp://example.com'; 207 | 208 | self::assertEquals('' . $url . '', $this->instance->convert($url)); 209 | 210 | $url = 'ftps://example.com'; 211 | 212 | self::assertEquals('' . $url . '', $this->instance->convert($url)); 213 | 214 | $url = 'https://example.com'; 215 | 216 | self::assertEquals('' . $url . '', $this->instance->convert($url)); 217 | 218 | $url = 'skype://example.com'; 219 | 220 | self::assertEquals($url, $this->instance->convert($url)); 221 | 222 | $this->instance->addScheme('skype'); 223 | 224 | self::assertEquals('' . $url . '', $this->instance->convert($url)); 225 | } 226 | 227 | public function testLinkNoScheme() 228 | { 229 | $this->instance->linkNoScheme('http'); 230 | 231 | $url = 'ftp://example.com'; 232 | 233 | self::assertEquals('' . $url . '', $this->instance->convert($url)); 234 | 235 | $url = 'example.com'; 236 | 237 | self::assertEquals('' . $url . '', $this->instance->convert($url)); 238 | 239 | $url = 'https://example.com'; 240 | 241 | self::assertEquals('' . $url . '', $this->instance->convert($url)); 242 | 243 | $url = 'skype://example.com'; 244 | 245 | self::assertEquals($url, $this->instance->convert($url)); 246 | 247 | $this->instance->addScheme('skype'); 248 | 249 | self::assertEquals('' . $url . '', $this->instance->convert($url)); 250 | 251 | $url = 'dømi.fo'; 252 | 253 | self::assertEquals('' . $url . '', $this->instance->convert($url)); 254 | 255 | $url = 'dømi.fo/dømi'; 256 | 257 | self::assertEquals('' . $url . '', $this->instance->convert($url)); 258 | } 259 | 260 | public function testLinkNoSchemeShouldIgnoreEmail(): void 261 | { 262 | $this->instance->linkNoScheme('http'); 263 | 264 | $url = 'ABC hello@email.com CBA'; 265 | 266 | self::assertEquals('ABC hello@email.com CBA', $this->instance->convert($url)); 267 | } 268 | 269 | /** 270 | * testGetAndSetScheme 271 | * 272 | * @return void 273 | */ 274 | public function testGetAndSetScheme() 275 | { 276 | $autolink = new Autolink([], ['a', 'b', 'http']); 277 | 278 | self::assertEquals(['http', 'https', 'ftp', 'ftps', 'a', 'b'], $autolink->getSchemes()); 279 | 280 | self::assertEquals('http|https|ftp|ftps|a|b', $autolink->getSchemes(true)); 281 | 282 | $autolink->setSchemes('skype'); 283 | 284 | self::assertEquals(['skype'], $autolink->getSchemes()); 285 | 286 | $autolink->setSchemes('mailto'); 287 | 288 | self::assertEquals(['mailto'], $autolink->getSchemes()); 289 | 290 | $autolink->setSchemes('mailto', 'mailto'); 291 | 292 | self::assertEquals(['mailto'], $autolink->getSchemes()); 293 | 294 | $autolink->removeScheme('mailto'); 295 | 296 | self::assertEquals([], $autolink->getSchemes()); 297 | } 298 | 299 | public function testAutoEscape() 300 | { 301 | $autolink = new Autolink(); 302 | 303 | $url = 'https://example.com/?foo=bar&yoo=baz'; 304 | 305 | self::assertEquals( 306 | '' . htmlspecialchars($url) . '', 307 | $autolink->convert($url) 308 | ); 309 | 310 | $autolink->autoEscape(false); 311 | 312 | self::assertEquals('' . htmlspecialchars($url) . '', $autolink->convert($url)); 313 | 314 | $url = 'hello+admin&test@example.org'; 315 | 316 | $autolink->autoEscape(true); 317 | 318 | self::assertEquals( 319 | '' . htmlspecialchars($url) . '', 320 | $autolink->convertEmail($url) 321 | ); 322 | 323 | $autolink->autoEscape(false); 324 | 325 | self::assertEquals( 326 | '' . htmlspecialchars($url) . '', 327 | $autolink->convertEmail($url) 328 | ); 329 | } 330 | 331 | public function testConvertEmail() 332 | { 333 | $text = <<LINK 341 | sakura@flower.com 342 | sakura.dot@flower.com 343 |
344 |
345 | 346 | My email address is sakura@flower.com. 347 | 348 | and emails are coming in between(sakura@flower.com). 349 | TEXT; 350 | 351 | $html = <<sakura@flower.com 354 | 355 | This is Email inline: sakura@flower.com with test. 356 | 357 | This is Email in HTML: 358 | LINK 359 | sakura@flower.com 360 | sakura.dot@flower.com 361 |
362 |
363 | 364 | My email address is sakura@flower.com. 365 | 366 | and emails are coming in between(sakura@flower.com). 367 | HTML; 368 | 369 | self::assertStringSafeEquals($html, $this->instance->convertEmail($text)); 370 | } 371 | 372 | /** 373 | * testSetLinkBuilder 374 | * 375 | * @return void 376 | */ 377 | public function testGetAndSetLinkBuilder() 378 | { 379 | $this->instance->setLinkBuilder(function ($url, $attribs) { 380 | return $url . json_encode($attribs); 381 | }); 382 | 383 | self::assertEquals( 384 | 'http://google.com{"foo":"bar","href":"http:\/\/google.com"}', 385 | $this->instance->link('http://google.com', ['foo' => 'bar']) 386 | ); 387 | 388 | self::assertInstanceOf('Closure', $this->instance->getLinkBuilder()); 389 | } 390 | 391 | public function testIgnoreTrailingDot(): void 392 | { 393 | $txt = 'Link to https://google.com.'; 394 | 395 | $html = $this->instance->convert($txt); 396 | 397 | assertEquals( 398 | 'Link to https://google.com.', 399 | $html, 400 | ); 401 | 402 | $txt = 'Link to https://google.com/search?foo=yoo.'; 403 | 404 | $html = $this->instance->convert($txt); 405 | 406 | assertEquals( 407 | 'Link to https://google.com/search?foo=yoo.', 408 | $html, 409 | ); 410 | } 411 | 412 | public function testLocalhost(): void 413 | { 414 | $txt = 'Link to http://localhost with some text.'; 415 | 416 | $html = $this->instance->convert($txt); 417 | 418 | assertEquals( 419 | 'Link to http://localhost with some text.', 420 | $html, 421 | ); 422 | 423 | $txt = 'Link to http://localhost.'; 424 | 425 | $html = $this->instance->convert($txt); 426 | 427 | assertEquals( 428 | 'Link to http://localhost.', 429 | $html, 430 | ); 431 | 432 | // Localhost without scheme should be ignored. 433 | $txt = 'Link to localhost.'; 434 | 435 | $this->instance->linkNoScheme(true); 436 | $html = $this->instance->convert($txt); 437 | 438 | assertEquals( 439 | 'Link to localhost.', 440 | $html, 441 | ); 442 | } 443 | 444 | public function testTrailingParenthesis(): void 445 | { 446 | $txt = 'Link to (https://google.com) with some text.'; 447 | 448 | $html = $this->instance->convert($txt); 449 | 450 | assertEquals( 451 | 'Link to (https://google.com) with some text.', 452 | $html, 453 | ); 454 | } 455 | 456 | public function testIgnoreImages(): void 457 | { 458 | $txt = << 460 | HTML; 461 | 462 | $html = $this->instance->convert($txt); 463 | 464 | assertEquals( 465 | 'Should ignore image: foo', 466 | $html, 467 | ); 468 | } 469 | 470 | /** 471 | * urlProvider 472 | * 473 | * @return array 474 | */ 475 | public static function urlProvider() 476 | { 477 | return [ 478 | [ 479 | 'http://www.projectup.net/blog/index.php?option=com_content&view=article&id=15726:-agile-&catid=8:pmp-pm&Itemid=18', 480 | 'http://www.projectup.net/....../index.php?optio......', 481 | 15, 482 | 6, 483 | ], 484 | [ 485 | 'http://campus.asukademy.com/learning/job/84-find-internship-opportunity-through-platform.html', 486 | 'http://campus.asukademy.com/....../84-find-interns......', 487 | 15, 488 | 6, 489 | ], 490 | [ 491 | 'http://user:pass@campus.asukademy.com:8888/learning/job/84-find-internship-opportunity-through-platform.html', 492 | 'http://user:pass@campus.asukademy.com:8888/....../84-find-interns......', 493 | 15, 494 | 6, 495 | ], 496 | [ 497 | 'http://campus.asukademy.com/learning/job/84-find-internship-opportunity-through-platform.html', 498 | 'http://campus.asukademy.com/.../84-fi...', 499 | 5, 500 | 3, 501 | ], 502 | ]; 503 | } 504 | 505 | /** 506 | * testShorten 507 | * 508 | * @param $url 509 | * @param $expect 510 | * @param $limit 511 | * @param $dots 512 | */ 513 | #[DataProvider('urlProvider')] 514 | public function testShortenUrl($url, $expect, $limit, $dots) 515 | { 516 | self::assertEquals($expect, Autolink::shortenUrl($url, $limit, $dots)); 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /bin/Console.php: -------------------------------------------------------------------------------- 1 | parse($argv ?? $_SERVER['argv'], $validate); 51 | } 52 | 53 | public function __construct( 54 | public $stdout = STDOUT, 55 | public $stderr = STDERR, 56 | public $stdin = STDIN, 57 | public string $heading = '', 58 | public string $epilog = '', 59 | public ?string $commandName = null, 60 | public ArgvParser $parser = new ArgvParser(), 61 | ) { 62 | } 63 | 64 | public function addParameter( 65 | string|array $name, 66 | ParameterType $type, 67 | string $description = '', 68 | bool $required = false, 69 | mixed $default = null, 70 | bool $negatable = false, 71 | ): Parameter { 72 | return $this->parser->addParameter($name, $type, $description, $required, $default, $negatable); 73 | } 74 | 75 | public function addHelpParameter(): Parameter 76 | { 77 | return $this->addParameter('--help|-h', static::BOOLEAN, 'Show description of all parameters', false); 78 | } 79 | 80 | public function addVerbosityParameter(): Parameter 81 | { 82 | return $this->addParameter('--verbosity|-v', static::LEVEL, 'The verbosity level of the output'); 83 | } 84 | 85 | public function get(string $name, mixed $default = null): mixed 86 | { 87 | return $this->params[$name] ?? $default; 88 | } 89 | 90 | protected function configure(): void 91 | { 92 | } 93 | 94 | protected function preprocess(): void 95 | { 96 | } 97 | 98 | protected function doExecute(): int|bool 99 | { 100 | return 0; 101 | } 102 | 103 | public function execute(?array $argv = null, ?\Closure $main = null): int 104 | { 105 | $argv = $argv ?? $_SERVER['argv']; 106 | $this->commandName ??= basename($argv[0]); 107 | try { 108 | $this->disableDefaultParameters || ($this->addHelpParameter() && $this->addVerbosityParameter()); 109 | $this->configure(); 110 | $this->params = $this->parser->parse($argv, false); 111 | if (!$this->disableDefaultParameters) { 112 | $this->verbosity = (int) $this->get('verbosity'); 113 | if ($this->get('help')) { 114 | $this->showHelp(); 115 | 116 | return static::SUCCESS; 117 | } 118 | } 119 | $this->params = $this->parser->validateAndCastParams($this->params); 120 | $this->preprocess(); 121 | $exitCode = $main ? $main->call($this, $this) : $this->doExecute(); 122 | if ($exitCode === true || $exitCode === null) { 123 | $exitCode = 0; 124 | } elseif ($exitCode === false) { 125 | $exitCode = 255; 126 | } 127 | 128 | return (int) $exitCode; 129 | } catch (\Throwable $e) { 130 | return $this->handleException($e); 131 | } 132 | } 133 | 134 | public function showHelp(): void 135 | { 136 | $help = ParameterDescriptor::describe($this->parser, $this->commandName, $this->epilog); 137 | $this->writeln(ltrim($this->heading . "\n\n" . $help))->newLine(); 138 | } 139 | 140 | public function write(string $message, bool $err = false): static 141 | { 142 | fwrite($err ? $this->stderr : $this->stdout, $message); 143 | 144 | return $this; 145 | } 146 | 147 | public function writeln(string $message = '', bool $err = false): static 148 | { 149 | return $this->write($message . "\n", $err); 150 | } 151 | 152 | public function newLine(int $lines = 1, bool $err = false): static 153 | { 154 | return $this->write(str_repeat("\n", $lines), $err); 155 | } 156 | 157 | public function in(): string 158 | { 159 | return rtrim(fread(STDIN, 8192), "\n\r"); 160 | } 161 | 162 | public function ask(string $question = '', string $default = ''): string 163 | { 164 | $this->write($question); 165 | $in = rtrim(fread(STDIN, 8192), "\n\r"); 166 | 167 | return $in === '' ? $default : $in; 168 | } 169 | 170 | public function askConfirm(string $question = '', string $default = ''): bool 171 | { 172 | return (bool) $this->mapBoolean($this->ask($question, $default)); 173 | } 174 | 175 | public function mapBoolean($in): bool|null 176 | { 177 | $in = strtolower((string) $in); 178 | if (in_array($in, $this->boolMapping[0], true)) { 179 | return false; 180 | } 181 | if (in_array($in, $this->boolMapping[1], true)) { 182 | return true; 183 | } 184 | 185 | return null; 186 | } 187 | 188 | public function exec(string $cmd, \Closure|null|false $output = null, bool $showCmd = true): ExecResult 189 | { 190 | !$showCmd || $this->writeln('>> ' . $cmd); 191 | [$outFull, $errFull, $code] = ['', '', 255]; 192 | if ($process = proc_open($cmd, [["pipe", "r"], ["pipe", "w"], ["pipe", "w"]], $pipes)) { 193 | $callback = $output ?: fn($data, $err) => ($output === false) || $this->write($data, $err); 194 | while (($out = fgets($pipes[1])) || $err = fgets($pipes[2])) { 195 | if (isset($out[0])) { 196 | $callback($out, false); 197 | $outFull .= $output === false ? $out : ''; 198 | } 199 | if (isset($err[0])) { 200 | $callback($err, false); 201 | $errFull .= $output === false ? $err : ''; 202 | } 203 | } 204 | 205 | $code = proc_close($process); 206 | } 207 | 208 | return new ExecResult($code, $outFull, $errFull); 209 | } 210 | 211 | public function mustExec(string $cmd, ?\Closure $output = null): ExecResult 212 | { 213 | $result = $this->exec($cmd, $output); 214 | $result->success || throw new \RuntimeException('Command "' . $cmd . '" failed with code ' . $result->code); 215 | 216 | return $result; 217 | } 218 | 219 | protected function handleException(\Throwable $e): int 220 | { 221 | if ($e instanceof InvalidParameterException) { 222 | $this->writeln('[Warning] ' . $e->getMessage(), true)->newLine(err: true) 223 | ->writeln( 224 | $this->commandName . ' ' . ParameterDescriptor::synopsis($this->parser, false), 225 | true 226 | ); 227 | } else { 228 | $this->writeln('[Error] ' . $e->getMessage(), true); 229 | } 230 | if ($this->verbosity > 0) { 231 | $this->writeln('[Backtrace]:', true) 232 | ->writeln($e->getTraceAsString(), true); 233 | } 234 | 235 | return $e->getCode() === 0 ? 255 : $e->getCode(); 236 | } 237 | 238 | public function offsetExists(mixed $offset): bool 239 | { 240 | return array_key_exists($offset, $this->params); 241 | } 242 | 243 | public function offsetGet(mixed $offset): mixed 244 | { 245 | return $this->params[$offset] ?? null; 246 | } 247 | 248 | public function offsetSet(mixed $offset, mixed $value): void 249 | { 250 | throw new \BadMethodCallException('Cannot set params.'); 251 | } 252 | 253 | public function offsetUnset(mixed $offset): void 254 | { 255 | throw new \BadMethodCallException('Cannot unset params.'); 256 | } 257 | } 258 | 259 | class ExecResult 260 | { 261 | public bool $success { 262 | get => $this->code === 0; 263 | } 264 | 265 | public function __construct(public int $code = 0, public string $output = '', public string $errOutput = '') 266 | { 267 | } 268 | } 269 | 270 | class ArgvParser 271 | { 272 | private array $params = []; 273 | 274 | private array $tokens = []; 275 | 276 | private array $existsNames = []; 277 | 278 | private bool $parseOptions = false; 279 | 280 | public private(set) int $currentArgument = 0; 281 | 282 | /** @var array */ 283 | public private(set) array $parameters = []; 284 | 285 | /** @var array */ 286 | public array $arguments { 287 | get => array_filter($this->parameters, static fn($parameter) => $parameter->isArg); 288 | } 289 | 290 | /** @var array */ 291 | public array $options { 292 | get => array_filter($this->parameters, static fn($parameter) => !$parameter->isArg); 293 | } 294 | 295 | public function addParameter( 296 | string|array $name, 297 | ParameterType $type, 298 | string $description = '', 299 | bool $required = false, 300 | mixed $default = null, 301 | bool $negatable = false, 302 | ): Parameter { 303 | if (is_string($name) && str_contains($name, '|')) { 304 | $name = explode('|', $name); 305 | foreach ($name as $n) { 306 | if (!str_starts_with($n, '-')) { 307 | throw new \InvalidArgumentException('Argument name cannot contains "|" sign.'); 308 | } 309 | } 310 | } 311 | $parameter = new Parameter($name, $type, $description, $required, $default, $negatable); 312 | foreach ((array) $parameter->name as $n) { 313 | if (in_array($n, $this->existsNames, true)) { 314 | throw new \InvalidArgumentException('Duplicate parameter name "' . $n . '"'); 315 | } 316 | } 317 | array_push($this->existsNames, ...((array) $parameter->name)); 318 | ($this->parameters[$parameter->primaryName] = $parameter) && $parameter->selfValidate(); 319 | 320 | return $parameter; 321 | } 322 | 323 | public function removeParameter(string $name): void 324 | { 325 | unset($this->parameters[$name]); 326 | } 327 | 328 | public function getArgument(string $name): ?Parameter 329 | { 330 | return array_find($this->arguments, static fn($n) => $n === $name); 331 | } 332 | 333 | public function getArgumentByIndex(int $index): ?Parameter 334 | { 335 | return array_values($this->arguments)[$index] ?? null; 336 | } 337 | 338 | public function getLastArgument(): ?Parameter 339 | { 340 | $args = $this->arguments; 341 | 342 | return $args[array_key_last($args)] ?? null; 343 | } 344 | 345 | public function getOption(string $name): ?Parameter 346 | { 347 | return array_find($this->options, static fn(Parameter $option) => $option->hasName($name)); 348 | } 349 | 350 | public function mustGetOption(string $name): Parameter 351 | { 352 | if (!$option = $this->getOption($name)) { 353 | throw new InvalidParameterException(\sprintf('The "-%s" option does not exist.', $name)); 354 | } 355 | 356 | return $option; 357 | } 358 | 359 | public function parse(array $argv, bool $validate = true): array 360 | { 361 | foreach ($this->parameters as $parameter) { 362 | $parameter->selfValidate(); 363 | } 364 | array_shift($argv); 365 | $this->currentArgument = 0; 366 | $this->parseOptions = true; 367 | $this->params = []; 368 | $this->tokens = $argv; 369 | while (null !== $token = array_shift($this->tokens)) { 370 | $this->parseToken((string) $token); 371 | } 372 | 373 | if ($validate) { 374 | return $this->validateAndCastParams($this->params); 375 | } 376 | 377 | return $this->params; 378 | } 379 | 380 | public function validateAndCastParams(array $params): array 381 | { 382 | foreach ($this->parameters as $parameter) { 383 | if (!array_key_exists($parameter->primaryName, $params)) { 384 | $parameter->assertInput( 385 | !$parameter->isArg || !$parameter->required, 386 | "Required argument \"{$parameter->primaryName}\" is missing." 387 | ); 388 | $params[$parameter->primaryName] = $parameter->defaultValue ?? false; 389 | } else { 390 | $parameter->validate($this->params[$parameter->primaryName]); 391 | $params[$parameter->primaryName] = $parameter->castValue($params[$parameter->primaryName]); 392 | } 393 | } 394 | 395 | return $params; 396 | } 397 | 398 | protected function parseToken(string $token): void 399 | { 400 | if ($this->parseOptions && '' === $token) { 401 | $this->parseArgument($token); 402 | } elseif ($this->parseOptions && '--' === $token) { 403 | $this->parseOptions = false; 404 | } elseif ($this->parseOptions && str_starts_with($token, '--')) { 405 | $this->parseLongOption($token); 406 | } elseif ($this->parseOptions && '-' === $token[0] && '-' !== $token) { 407 | $this->parseShortOption($token); 408 | } else { 409 | $this->parseArgument($token); 410 | } 411 | } 412 | 413 | private function parseShortOption(string $token): void 414 | { 415 | $name = substr($token, 1); 416 | if (\strlen($name) > 1) { 417 | $option = $this->getOption($token); 418 | if ($option && $option->acceptValue) { 419 | $this->setOptionValue($name[0], substr($name, 1)); // -n[value] 420 | } else { 421 | $this->parseShortOptionSet($name); 422 | } 423 | } else { 424 | $this->setOptionValue($name, null); 425 | } 426 | } 427 | 428 | private function parseShortOptionSet(string $name): void 429 | { 430 | $len = \strlen($name); 431 | for ($i = 0; $i < $len; ++$i) { 432 | $option = $this->mustGetOption($name[$i]); 433 | if ($option->acceptValue) { 434 | $this->setOptionValue($option->primaryName, $i === $len - 1 ? null : substr($name, $i + 1)); 435 | break; 436 | } 437 | $this->setOptionValue($option->primaryName, null); 438 | } 439 | } 440 | 441 | private function parseLongOption(string $token): void 442 | { 443 | $name = substr($token, 2); 444 | $pos = strpos($name, '='); 445 | if ($pos !== false) { 446 | $value = substr($name, $pos + 1); 447 | $value !== '' || array_unshift($this->params, $value); 448 | $this->setOptionValue(substr($name, 0, $pos), $value); 449 | } else { 450 | $this->setOptionValue($name, null); 451 | } 452 | } 453 | 454 | private function parseArgument(string $token): void 455 | { 456 | if ($arg = $this->getArgumentByIndex($this->currentArgument)) { 457 | $this->params[$arg->primaryName] = $arg->type === ParameterType::ARRAY ? [$token] : $token; 458 | } elseif (($last = $this->getLastArgument()) && $last->type === ParameterType::ARRAY) { 459 | $this->params[$last->primaryName][] = $token; 460 | } else { 461 | throw new InvalidParameterException("Unknown argument \"$token\"."); 462 | } 463 | $this->currentArgument++; 464 | } 465 | 466 | public function setOptionValue(string $name, mixed $value = null): void 467 | { 468 | $option = $this->getOption($name); 469 | // If option not exists, make sure it is negatable 470 | if (!$option) { 471 | if (str_starts_with($name, 'no-')) { 472 | $option = $this->getOption(substr($name, 3)); 473 | if ($option->type === ParameterType::BOOLEAN && $option->negatable) { 474 | $this->params[$option->primaryName] = false; 475 | } 476 | 477 | return; 478 | } 479 | throw new InvalidParameterException(\sprintf('The "-%s" option does not exist.', $name)); 480 | } 481 | $option->assertInput($value === null || $option->acceptValue, 'Option "%s" does not accept value.'); 482 | // Try get option value from next token 483 | if (\in_array($value, ['', null], true) && $option->acceptValue && \count($this->tokens)) { 484 | $next = array_shift($this->tokens); 485 | if ((isset($next[0]) && '-' !== $next[0]) || \in_array($next, ['', null], true)) { 486 | $value = $next; 487 | } else { 488 | array_unshift($this->tokens, $next); 489 | } 490 | } 491 | if ($option->type === ParameterType::BOOLEAN) { 492 | $value = $value === null || $value; 493 | } 494 | if ($option->type === ParameterType::ARRAY) { 495 | $this->params[$option->primaryName][] = $value; 496 | } elseif ($option->type === ParameterType::LEVEL) { 497 | $this->params[$option->primaryName] ??= 0; 498 | $this->params[$option->primaryName]++; 499 | } else { 500 | $this->params[$option->primaryName] = $value; 501 | } 502 | } 503 | } 504 | 505 | /** 506 | * @method self description(string $value) 507 | * @method self required(bool $value) 508 | * @method self negatable(bool $value) 509 | * @method self default(mixed $value) 510 | */ 511 | class Parameter 512 | { 513 | public bool $isArg { 514 | get => is_string($this->name); 515 | } 516 | 517 | public string $primaryName { 518 | get => is_string($this->name) ? $this->name : $this->name[0]; 519 | } 520 | 521 | public string $synopsis { 522 | get { 523 | if (is_string($this->name)) { 524 | return $this->name; 525 | } 526 | $shorts = []; 527 | $fulls = []; 528 | foreach ($this->name as $n) { 529 | if (strlen($n) === 1) { 530 | $shorts[] = '-' . $n; 531 | } else { 532 | $fulls[] = '--' . $n; 533 | } 534 | } 535 | if ($this->negatable) { 536 | $fulls[] = '--no-' . $this->primaryName; 537 | } 538 | 539 | return implode(', ', array_filter([implode('|', $shorts), implode('|', $fulls)])); 540 | } 541 | } 542 | 543 | public bool $acceptValue { 544 | get => $this->type !== ParameterType::BOOLEAN && $this->type !== ParameterType::LEVEL && !$this->negatable; 545 | } 546 | 547 | public mixed $defaultValue { 548 | get => match ($this->type) { 549 | ParameterType::ARRAY => $this->default ?? [], 550 | ParameterType::LEVEL => $this->default ?? 0, 551 | default => $this->default, 552 | }; 553 | } 554 | 555 | public function __construct( 556 | public string|array $name, 557 | public ParameterType $type, 558 | public string $description = '', 559 | public bool $required = false, 560 | public mixed $default = null, 561 | public bool $negatable = false, 562 | ) { 563 | $this->name = is_string($this->name) && str_starts_with($this->name, '-') ? [$this->name] : $this->name; 564 | if (is_array($this->name)) { 565 | foreach ($this->name as $i => $n) { 566 | $this->assertArg(str_starts_with($n, '--') || strlen($n) <= 2); 567 | $this->name[$i] = ltrim($n, '-'); 568 | } 569 | } 570 | } 571 | 572 | public function selfValidate(): void 573 | { 574 | $this->assertArg( 575 | $this->type !== ParameterType::ARRAY || is_array($this->defaultValue), 576 | "Default value of \"%s\" must be an array." 577 | ); 578 | if ($this->isArg) { 579 | $this->assertArg(!$this->negatable, "Argument \"%s\" cannot be negatable."); 580 | $this->assertArg( 581 | $this->type !== ParameterType::BOOLEAN && $this->type !== ParameterType::LEVEL, 582 | "Argument \"%s\" cannot be type: {$this->type->name}." 583 | ); 584 | } else { 585 | $this->assertArg(!$this->negatable || !$this->required, "Negatable option \"%s\" cannot be required."); 586 | } 587 | $this->assertArg( 588 | !$this->required || $this->default === null, 589 | "Default value of \"%s\" cannot be set when required is true." 590 | ); 591 | } 592 | 593 | public function hasName(string $name): bool 594 | { 595 | $name = ltrim($name, '-'); 596 | 597 | return is_string($this->name) ? $this->name === $name : array_any($this->name, fn($n) => $n === $name); 598 | } 599 | 600 | public function castValue(mixed $value): mixed 601 | { 602 | return match ($this->type) { 603 | ParameterType::INT, ParameterType::LEVEL => (int) $value, 604 | ParameterType::NUMERIC, ParameterType::FLOAT => (float) $value, 605 | ParameterType::BOOLEAN => (bool) $value, 606 | ParameterType::ARRAY => (array) $value, 607 | default => $value, 608 | }; 609 | } 610 | 611 | public function validate(mixed $value): void 612 | { 613 | if ($value === null) { 614 | $this->assertInput(!$this->required, "Required value for \"%s\" is missing."); 615 | 616 | return; 617 | } 618 | $passed = match ($this->type) { 619 | ParameterType::INT => is_numeric($value) && ((string) (int) $value) === $value, 620 | ParameterType::FLOAT => is_numeric($value) && ((string) (float) $value) === $value, 621 | ParameterType::NUMERIC => is_numeric($value), 622 | ParameterType::BOOLEAN => is_bool($value) || $value === '1' || $value === '0', 623 | ParameterType::ARRAY => is_array($value), 624 | default => true, 625 | }; 626 | $this->assertInput($passed, "Invalid value type for \"%s\". Expected %s."); 627 | } 628 | 629 | public function assertArg(mixed $value, ?string $message = ''): void 630 | { 631 | $value || throw new \InvalidArgumentException(sprintf($message, $this->primaryName, $this->type->name)); 632 | } 633 | 634 | public function assertInput(mixed $value, ?string $message = ''): void 635 | { 636 | $value || throw new InvalidParameterException(sprintf($message, $this->primaryName, $this->type->name)); 637 | } 638 | 639 | public function __call(string $name, array $args) 640 | { 641 | if (property_exists($this, $name)) { 642 | $this->{$name} = $args[0]; 643 | $this->selfValidate(); 644 | 645 | return $this; 646 | } 647 | throw new \BadMethodCallException("Method $name() does not exist."); 648 | } 649 | } 650 | 651 | class ParameterDescriptor 652 | { 653 | public static function describe(ArgvParser $parser, string $commandName, string $epilog = ''): string 654 | { 655 | $lines[] = sprintf("Usage:\n %s %s", $commandName, static::synopsis($parser, true)); 656 | if (count($parser->arguments)) { 657 | $lines[] = "\nArguments:"; 658 | $maxColWidth = 0; 659 | foreach ($parser->arguments as $argument) { 660 | $argumentLines[] = static::describeArgument($argument, $maxColWidth); 661 | } 662 | foreach ($argumentLines ?? [] as [$start, $end]) { 663 | $lines[] = ' ' . $start . str_repeat(' ', $maxColWidth - strlen($start) + 4) . $end; 664 | } 665 | } 666 | if (count($parser->options)) { 667 | $lines[] = "\nOptions:"; 668 | $maxColWidth = 0; 669 | foreach ($parser->options as $option) { 670 | $optionLines[] = static::describeOption($option, $maxColWidth); 671 | } 672 | foreach ($optionLines ?? [] as [$start, $end]) { 673 | $lines[] = ' ' . $start . str_repeat(' ', $maxColWidth - strlen($start) + 4) . $end; 674 | } 675 | } 676 | $epilog && ($lines[] = "\nHelp:\n$epilog"); 677 | 678 | return implode("\n", $lines); 679 | } 680 | 681 | public static function describeArgument(Parameter $parameter, int &$maxWidth = 0): array 682 | { 683 | $default = !static::noDefault($parameter) ? ' [default: ' . static::format($parameter->default) . ']' : ''; 684 | $maxWidth = max($maxWidth, strlen($parameter->synopsis)); 685 | 686 | return [$parameter->synopsis, $parameter->description . $default]; 687 | } 688 | 689 | public static function describeOption(Parameter $parameter, int &$maxWidth = 0): array 690 | { 691 | $default = ($parameter->acceptValue || $parameter->negatable) && !static::noDefault($parameter) 692 | ? ' [default: ' . static::format($parameter->default) . ']' 693 | : ''; 694 | $value = '=' . strtoupper($parameter->primaryName); 695 | $value = $parameter->required ? $value : '[' . $value . ']'; 696 | $synopsis = $parameter->synopsis . ($parameter->acceptValue ? $value : ''); 697 | $maxWidth = max($maxWidth, strlen($synopsis)); 698 | 699 | return [ 700 | $synopsis, 701 | $parameter->description . $default . ($parameter->type === ParameterType::ARRAY ? ' (multiple values allowed)' : ''), 702 | ]; 703 | } 704 | 705 | public static function noDefault(Parameter $parameter): bool 706 | { 707 | return $parameter->default === null || (is_array($parameter->default) && count($parameter->default) === 0); 708 | } 709 | 710 | public static function format(mixed $value): string 711 | { 712 | return str_replace('\\\\', '\\', json_encode($value, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)); 713 | } 714 | 715 | public static function synopsis(ArgvParser $parser, bool $simple = false): string 716 | { 717 | $elements = []; 718 | if ($simple) { 719 | $elements[] = '[options]'; 720 | } else { 721 | foreach ($parser->options as $option) { 722 | $value = strtoupper($option->primaryName); 723 | $value = !$option->required ? '[' . $value . ']' : $value; 724 | $element = str_replace(', ', '|', $option->synopsis) . ($option->acceptValue ? ' ' . $value : ''); 725 | $elements[] = '[' . $element . ']'; 726 | } 727 | } 728 | if ($elements !== [] && $parser->arguments !== []) { 729 | $elements[] = '[--]'; 730 | } 731 | $tail = ''; 732 | foreach ($parser->arguments as $argument) { 733 | $element = ($argument->type === ParameterType::ARRAY ? '...' : '') . '<' . $argument->primaryName . '>'; 734 | if (!$argument->required) { 735 | $element = '[' . $element; 736 | $tail .= ']'; 737 | } 738 | $elements[] = $element; 739 | } 740 | 741 | return implode(' ', $elements) . $tail; 742 | } 743 | } 744 | 745 | enum ParameterType 746 | { 747 | case STRING; 748 | case INT; 749 | case NUMERIC; 750 | case FLOAT; 751 | case BOOLEAN; 752 | case LEVEL; 753 | case ARRAY; 754 | } 755 | 756 | class InvalidParameterException extends \RuntimeException 757 | { 758 | } 759 | } 760 | --------------------------------------------------------------------------------