Completed
Push — master ( b96e2c...9d1331 )
by Cristiano
04:44
created

Docblock::writeLines()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 19
rs 9.6333
c 0
b 0
f 0
ccs 12
cts 12
cp 1
cc 4
nc 6
nop 2
crap 4
1
<?php declare(strict_types=1);
2
/*
3
 * This file is part of the Docblock package.
4
 * For the full copyright and license information, please view the LICENSE
5
 * file that was distributed with this source code.
6
 *
7
 * @license MIT License
8
 */
9
10
namespace gossi\docblock;
11
12
use gossi\docblock\tags\AbstractTag;
13
use gossi\docblock\tags\TagFactory;
14
use InvalidArgumentException;
15
use LogicException;
16
use phootwork\collection\ArrayList;
17
use phootwork\collection\Map;
18
use phootwork\lang\Comparator;
19
use ReflectionClass;
20
use ReflectionFunctionAbstract;
21
use ReflectionProperty;
22
23
class Docblock implements \Stringable {
24
	protected string $shortDescription;
0 ignored issues
show
Bug introduced by
This code did not parse for me. Apparently, there is an error somewhere around this line:

Syntax error, unexpected T_STRING, expecting T_FUNCTION or T_CONST
Loading history...
25
	protected string $longDescription;
26
	protected ArrayList $tags;
27
	protected ?Comparator $comparator = null;
28
29
	const REGEX_TAGNAME = '[\w\-\_\\\\]+';
30
31
	/**
32
	 * Static docblock factory
33
	 * 
34
	 * @param ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock a docblock to parse
35
	 *
36
	 * @return $this
37
	 */
38
	public static function create(ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock = ''): self {
39 1
		return new static($docblock);
40 1
	}
41
42
	/**
43
	 * Creates a new docblock instance and parses the initial string or reflector object if given
44
	 * 
45
	 * @param ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock a docblock to parse
46
	 */
47
	final public function __construct(ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock = '') {
48 13
		$this->tags = new ArrayList();
49 13
		$this->parse($docblock);
50 13
	}
51 11
52
	/**
53
	 * @see https://github.com/phpDocumentor/ReflectionDocBlock/blob/master/src/phpDocumentor/Reflection/DocBlock.php Original Method
54
	 *
55
	 * @param ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock
56
	 *
57
	 * @throws InvalidArgumentException if there is no getDocComment() method available
58
	 */
59
	protected function parse(ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock): void {
60 13
		$docblock = is_object($docblock) ? $docblock->getDocComment() : $docblock;
61 13
		$docblock = $this->cleanInput($docblock);
62 2
63 1
		[$short, $long, $tags] = $this->splitDocBlock($docblock);
64 1
		$this->shortDescription = $short;
65
		$this->longDescription = $long;
66
		$this->parseTags($tags);
67 1
	}
68
69
	/**
70 12
	 * Strips the asterisks from the DocBlock comment.
71
	 * 
72 12
	 * @see https://github.com/phpDocumentor/ReflectionDocBlock/blob/master/src/phpDocumentor/Reflection/DocBlock.php Original Method
73 12
	 *
74 12
	 * @param string $comment String containing the comment text.
75 12
	 *
76 11
	 * @return string
77
	 */
78
	protected function cleanInput(string $comment): string {
79
		$comment = trim(preg_replace('#[ \t]*(?:\/\*\*|\*\/|\*)?[ \t]{0,1}(.*)?#u', '$1', $comment));
80
81
		// reg ex above is not able to remove */ from a single line docblock
82
		if (substr($comment, -2) == '*/') {
83
			$comment = trim(substr($comment, 0, -2));
84
		}
85
86
		// normalize strings
87 12
		$comment = str_replace(["\r\n", "\r"], "\n", $comment);
88 12
89
		return $comment;
90
	}
91 12
92 1
	/**
93
	 * Splits the Docblock into a short description, long description and
94
	 * block of tags.
95
	 * 
96 12
	 * @see https://github.com/phpDocumentor/ReflectionDocBlock/blob/master/src/phpDocumentor/Reflection/DocBlock.php Original Method
97
	 *
98 12
	 * @param string $comment Comment to split into the sub-parts.
99
	 *
100
	 * @author RichardJ Special thanks to RichardJ for the regex responsible
101
	 *     for the split.
102
	 *
103
	 * @return string[] containing the short-, long description and an element
104
	 *     containing the tags.
105
	 */
106
	protected function splitDocBlock(string $comment): array {
107
		$matches = [];
108
109
		if (str_starts_with($comment, '@')) {
110
			$matches = ['', '', $comment];
111
		} else {
112
			// clears all extra horizontal whitespace from the line endings
113
			// to prevent parsing issues
114
			$comment = preg_replace('/\h*$/Sum', '', $comment);
115 11
116 11
			/*
117
			 * Splits the docblock into a short description, long description and
118 11
			 * tags section
119 2
			 * - The short description is started from the first character until
120
			 *   a dot is encountered followed by a newline OR
121
			 *   two consecutive newlines (horizontal whitespace is taken into
122
			 *   account to consider spacing errors)
123 9
			 * - The long description, any character until a new line is
124
			 *   encountered followed by an @ and word characters (a tag).
125
			 *   This is optional.
126
			 * - Tags; the remaining characters
127
			 *
128
			 * Big thanks to RichardJ for contributing this Regular Expression
129
			 */
130
			preg_match(
131
				'/
132
		        \A (
133
		          [^\n.]+
134
		          (?:
135
		            (?! \. \n | \n{2} ) # disallow the first seperator here
136
		            [\n.] (?! [ \t]* @\pL ) # disallow second seperator
137
		            [^\n.]+
138
		          )*
139 9
		          \.?
140 9
		        )
141
		        (?:
142
		          \s* # first seperator (actually newlines but it\'s all whitespace)
143
		          (?! @\pL ) # disallow the rest, to make sure this one doesn\'t match,
144
		          #if it doesn\'t exist
145
		          (
146
		            [^\n]+
147
		            (?: \n+
148
		              (?! [ \t]* @\pL ) # disallow second seperator (@param)
149
		              [^\n]+
150
		            )*
151
		          )
152
		        )?
153
		        (\s+ [\s\S]*)? # everything that follows
154
		        /ux',
155
				$comment,
156
				$matches
157
			);
158
			array_shift($matches);
159
		}
160
161
		while (count($matches) < 3) {
162
			$matches[] = '';
163
		}
164
165
		return $matches;
166
	}
167 9
168
	/**
169
	 * Parses the tags
170 11
	 * 
171 7
	 * @see https://github.com/phpDocumentor/ReflectionDocBlock/blob/master/src/phpDocumentor/Reflection/DocBlock.php Original Method
172
	 *
173
	 * @param string $tags
174 11
	 *
175
	 * @throws LogicException
176
	 * @throws InvalidArgumentException
177
	 */
178
	protected function parseTags(string $tags): void {
179
		$tags = trim($tags);
180
		if ($tags !== '') {
181
182
			// sanitize lines
183
			$result = [];
184
			foreach (explode("\n", $tags) as $line) {
185
				if ($this->isTagLine($line) || count($result) == 0) {
186
					$result[] = $line;
187 12
				} else {
188 12
					$result[count($result) - 1] .= "\n" . $line;
189 12
				}
190
			}
191
192 5
			// create proper Tag objects
193 5
			if (count($result)) {
194 5
				$this->tags->clear();
195 5
				foreach ($result as $line) {
196
					$this->tags->add($this->parseTag($line));
197 1
				}
198
			}
199
		}
200
	}
201
202 5
	/**
203 5
	 * Checks whether the given line is a tag line (= starts with @) or not
204 5
	 * 
205 5
	 * @param string $line
206
	 *
207
	 * @return bool
208
	 */
209 11
	protected function isTagLine(string $line): bool {
210
		return str_starts_with($line, '@');
211
	}
212
213
	/**
214
	 * Parses an individual tag line
215
	 * 
216
	 * @param string $line
217
	 *
218 5
	 * @throws InvalidArgumentException
219 5
	 *
220
	 * @return AbstractTag
221
	 */
222
	protected function parseTag(string $line): AbstractTag {
223
		$matches = [];
224
		if (!preg_match('/^@(' . self::REGEX_TAGNAME . ')(?:\s*([^\s].*)|$)?/us', $line, $matches)) {
225
			throw new InvalidArgumentException('Invalid tag line detected: ' . $line);
226
		}
227
228
		$tagName = $matches[1];
229
		$content = $matches[2] ?? '';
230
231 5
		return TagFactory::create($tagName, $content);
232 5
	}
233 5
234 1
	/**
235
	 * Returns the short description
236
	 * 
237 4
	 * @return string the short description
238 4
	 */
239
	public function getShortDescription(): string {
240 4
		return $this->shortDescription;
241
	}
242
243
	/**
244
	 * Sets the short description
245
	 * 
246
	 * @param string $description the new description     
247
	 *
248 3
	 * @return $this   	
249 3
	 */
250
	public function setShortDescription(string $description = ''): self {
251
		$this->shortDescription = $description;
252
253
		return $this;
254
	}
255
256
	/**
257
	 * Returns the long description
258
	 *
259 2
	 * @return string the long description
260 2
	 */
261
	public function getLongDescription(): string {
262 2
		return $this->longDescription;
263
	}
264
265
	/**
266
	 * Sets the long description
267
	 * 
268
	 * @param string $description the new description
269
	 *
270 2
	 * @return $this
271 2
	 */
272
	public function setLongDescription(string $description = ''): self {
273
		$this->longDescription = $description;
274
275
		return $this;
276
	}
277
278
	/**
279
	 * Adds a tag to this docblock
280
	 * 
281 2
	 * @param AbstractTag $tag
282 2
	 *
283
	 * @return $this
284 2
	 */
285
	public function appendTag(AbstractTag $tag): self {
286
		$this->tags->add($tag);
287
288
		return $this;
289
	}
290
291
	/**
292
	 * Removes tags (by tag name)
293
	 *
294 4
	 * @param string $tagName
295 4
	 */
296
	public function removeTags(string $tagName = ''): void {
297 4
		$this->tags = $this->tags->filter(function (AbstractTag $tag) use ($tagName): bool {
298
			return $tagName !== $tag->getTagName();
299
		});
300
	}
301
302
	/**
303
	 * Checks whether a tag is present
304
	 * 
305 1
	 * @param string $tagName
306
	 *
307 1
	 * @return bool
308 1
	 */
309 1
	public function hasTag(string $tagName): bool {
310
		return $this->tags->search($tagName,
311
			fn (AbstractTag $tag, string $query): bool => $tag->getTagName() === $query
312
		);
313
	}
314
315
	/**
316
	 * Gets tags (by tag name)
317
	 * 
318 2
	 * @param string $tagName
319
	 *
320 2
	 * @return ArrayList the tags
321 2
	 */
322
	public function getTags(string $tagName = ''): ArrayList {
323
		return $tagName === '' ? $this->tags : $this->tags->filter(
324
			fn (AbstractTag $tag): bool => $tag->getTagName() === $tagName
325
		);
326
	}
327
328
	/**
329
	 * A list of tags sorted by tag-name
330
	 * 
331 2
	 * @return ArrayList
332
	 */
333 2
	public function getSortedTags(): ArrayList {
334 2
		$this->comparator = $this->comparator ?? new TagNameComparator();
335
336
		// 1) group by tag name
337
		$group = new Map();
338
		/** @var AbstractTag $tag */
339
		foreach ($this->tags->toArray() as $tag) {
340
			if (!$group->has($tag->getTagName())) {
341
				$group->set($tag->getTagName(), new ArrayList());
342 6
			}
343 6
344 6
			/** @var ArrayList $list */
345
			$list = $group->get($tag->getTagName());
346
			$list->add($tag);
347
		}
348 6
349 6
		// 2) Sort the group by tag name
350 5
		$group->sortKeys(new TagNameComparator());
351 5
352
		// 3) flatten the group
353
		$sorted = new ArrayList();
354 5
		/** @var array $tags */
355
		foreach ($group->values()->toArray() as $tags) {
356
			$sorted->add(...$tags);
357
		}
358 6
359
		return $sorted;
360
	}
361 6
362 6
	/**
363 5
	 * Returns true when there is no content in the docblock
364
	 *  
365
	 * @return bool
366 6
	 */
367
	public function isEmpty(): bool {
368
		return $this->shortDescription === ''
369
				&& $this->longDescription === ''
370
				&& $this->tags->size() === 0;
371
	}
372
373
	/**
374 1
	 * Returns the string version of the docblock
375 1
	 * 
376 1
	 * @return string
377 1
	 */
378
	public function toString(): string {
379
		$docblock = "/**\n";
380
381
		// short description
382
		$short = trim($this->shortDescription);
383
		if ($short !== '') {
384
			$docblock .= $this->writeLines(explode("\n", $short));
385 5
		}
386 5
387
		// short description
388
		$long = trim($this->longDescription);
389 5
		if ($long !== '') {
390 5
			$docblock .= $this->writeLines(explode("\n", $long), !empty($short));
391 3
		}
392
393
		// tags
394
		$tags = $this->getSortedTags()->map(function (AbstractTag $tag): string {
395 5
			return (string) $tag;
396 5
		});
397 2
398
		if (!$tags->isEmpty()) {
399
			/** @psalm-suppress MixedArgumentTypeCoercion */
400
			$docblock .= $this->writeLines($tags->toArray(), $short !== '' || $long !== '');
401
		}
402 4
403 5
		$docblock .= ' */';
404
405 5
		return $docblock;
406 4
	}
407
408
	/**
409 5
	 * Writes multiple lines with ' * ' prefixed for docblock
410
	 * 
411 5
	 * @param string[] $lines the lines to be written
412
	 * @param bool $newline if a new line should be added before
413
	 *
414
	 * @return string the lines as string
415
	 */
416
	protected function writeLines(array $lines, bool $newline = false): string {
417
		$docblock = '';
418
		if ($newline) {
419
			$docblock .= " *\n";
420
		}
421
422 5
		foreach ($lines as $line) {
423 5
			if (str_contains($line, "\n")) {
424 5
				$sublines = explode("\n", $line);
425 3
				$line = array_shift($sublines);
426
				$docblock .= " * $line\n";
427
				$docblock .= $this->writeLines($sublines);
428 5
			} else {
429 5
				$docblock .= " * $line\n";
430 1
			}
431 1
		}
432 1
433 1
		return $docblock;
434
	}
435 5
436
	/**
437
	 * Magic toString() method
438
	 * 
439 5
	 * @return string
440
	 */
441
	public function __toString(): string {
442
		return $this->toString();
443
	}
444
}
445