Completed
Push — master ( d7e2f2...f8494a )
by Cristiano
01:25
created

Docblock::parseTag()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 11
rs 9.9
c 0
b 0
f 0
ccs 7
cts 7
cp 1
cc 3
nc 3
nop 1
crap 3
1
<?php declare(strict_types=1);
2
3
namespace gossi\docblock;
4
5
use gossi\docblock\tags\AbstractTag;
6
use gossi\docblock\tags\TagFactory;
7
use InvalidArgumentException;
8
use LogicException;
9
use phootwork\collection\ArrayList;
10
use phootwork\collection\Map;
11
use phootwork\lang\Comparator;
12
use ReflectionClass;
13
use ReflectionFunctionAbstract;
14
use ReflectionProperty;
15
16
class Docblock {
17
18
	/** @var string */
19
	protected $shortDescription;
20
21
	/** @var string */
22
	protected $longDescription;
23
24
	/** @var ArrayList */
25
	protected $tags;
26
27
	/** @var null|Comparator */
28
	protected $comparator = null;
29
30
	const REGEX_TAGNAME = '[\w\-\_\\\\]+';
31
32
	/**
33
	 * Static docblock factory
34
	 * 
35
	 * @param ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock a docblock to parse
36
	 *
37
	 * @return $this
38
	 */
39 1
	public static function create($docblock = ''): self {
40 1
		return new static($docblock);
41
	}
42
43
	/**
44
	 * Creates a new docblock instance and parses the initial string or reflector object if given
45
	 * 
46
	 * @param ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock a docblock to parse
47
	 */
48 12
	public function __construct($docblock = '') {
49 12
		$this->tags = new ArrayList();
50 12
		$this->parse($docblock);
51 10
	}
52
53
	/**
54
	 * @see https://github.com/phpDocumentor/ReflectionDocBlock/blob/master/src/phpDocumentor/Reflection/DocBlock.php Original Method
55
	 *
56
	 * @param ReflectionFunctionAbstract|ReflectionClass|ReflectionProperty|string $docblock
57
	 *
58
	 * @throws InvalidArgumentException if there is no getDocCommect() method available
59
	 */
60 12
	protected function parse($docblock): void {
61 12
		if (is_object($docblock)) {
62 2
			if (!method_exists($docblock, 'getDocComment')) {
63 1
				throw new InvalidArgumentException('Invalid object passed; the given ' .
64 1
						'reflector must support the getDocComment method');
65
			}
66
67 1
			$docblock = $docblock->getDocComment();
68
		}
69
70 11
		$docblock = $this->cleanInput($docblock);
71
72 11
		[$short, $long, $tags] = $this->splitDocBlock($docblock);
0 ignored issues
show
Bug introduced by
The variable $short does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $long does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $tags does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

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