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 | 
4 | [
5 | ](https://packagist.org/packages/asika/autolink)
6 | [](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:
',
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 |
--------------------------------------------------------------------------------