1: <?php declare(strict_types=1);
2:
3: namespace Salient\PHPDoc\Tag;
4:
5: use Salient\Contract\Core\Immutable;
6: use Salient\Core\Concern\HasMutator;
7: use Salient\PHPDoc\Exception\InvalidTagValueException;
8: use Salient\PHPDoc\PHPDoc;
9: use Salient\PHPDoc\PHPDocRegex;
10: use Salient\Utility\Regex;
11: use Salient\Utility\Test;
12:
13: /**
14: * Base class for PHPDoc tags
15: */
16: abstract class AbstractTag implements Immutable
17: {
18: use HasMutator;
19:
20: protected string $Tag;
21: protected string $Name;
22: protected string $Type;
23: protected ?string $Description;
24: /** @var class-string|null */
25: protected ?string $Class;
26: protected ?string $Member;
27:
28: /**
29: * @param class-string|null $class
30: */
31: protected function __construct(
32: string $tag,
33: ?string $name = null,
34: ?string $type = null,
35: ?string $description = null,
36: ?string $class = null,
37: ?string $member = null
38: ) {
39: // Apply values least likely to be invalid--and most likely to be useful
40: // in debug output--first
41: $this->Class = $this->filterClass($class);
42: $this->Member = $this->filterMember($member);
43: $this->Tag = $this->filterTag($tag);
44: if ($name !== null) {
45: $this->Name = $this->filterString($name, 'name');
46: }
47: if ($type !== null) {
48: $this->Type = $this->filterType($type);
49: }
50: $this->Description = $this->filterString($description, 'description');
51: }
52:
53: /**
54: * Get the name of the tag
55: */
56: public function getTag(): string
57: {
58: return $this->Tag;
59: }
60:
61: /**
62: * Get the name of the entity associated with the tag
63: */
64: public function getName(): ?string
65: {
66: return $this->Name ?? null;
67: }
68:
69: /**
70: * Get the PHPDoc type of the entity associated with the tag
71: */
72: public function getType(): ?string
73: {
74: return $this->Type ?? null;
75: }
76:
77: /**
78: * Get the description of the tag
79: */
80: public function getDescription(): ?string
81: {
82: return $this->Description;
83: }
84:
85: /**
86: * Get the name of the class associated with the tag's PHPDoc
87: *
88: * @return class-string|null
89: */
90: public function getClass(): ?string
91: {
92: return $this->Class;
93: }
94:
95: /**
96: * Get the class member associated with the tag's PHPDoc
97: */
98: public function getMember(): ?string
99: {
100: return $this->Member;
101: }
102:
103: /**
104: * Get an instance with the given description
105: *
106: * @return static
107: */
108: public function withDescription(?string $description)
109: {
110: return $this->with('Description', $this->filterString($description, 'description'));
111: }
112:
113: /**
114: * Add missing values from an instance that represents the same entity in a
115: * parent class or interface
116: *
117: * @param static $parent
118: * @return static
119: */
120: public function inherit($parent)
121: {
122: return $this
123: ->maybeInheritValue($parent, 'Type')
124: ->maybeInheritValue($parent, 'Description');
125: }
126:
127: /**
128: * @param static $parent
129: * @return static
130: */
131: final protected function maybeInheritValue($parent, string $property)
132: {
133: if (!isset($parent->$property)) {
134: return $this;
135: }
136:
137: if (!isset($this->$property)) {
138: return $this->with($property, $parent->$property);
139: }
140:
141: return $this;
142: }
143:
144: final protected function filterTag(string $tag): string
145: {
146: if (!Regex::match(
147: '/^' . PHPDocRegex::PHPDOC_TAG . '$/D',
148: '@' . $tag,
149: )) {
150: $this->throw("Invalid tag '%s'", $tag);
151: }
152: return $tag;
153: }
154:
155: /**
156: * @template T of string|null
157: *
158: * @param T $class
159: * @return T
160: */
161: final protected function filterClass(?string $class): ?string
162: {
163: if ($class !== null && !Test::isFqcn($class)) {
164: $this->throw("Invalid class '%s'", $class);
165: }
166: return $class;
167: }
168:
169: /**
170: * @template T of string|null
171: *
172: * @param T $member
173: * @return T
174: */
175: final protected function filterMember(?string $member): ?string
176: {
177: if ($member !== null && !Regex::match(
178: '/^(\$?' . Regex::PHP_IDENTIFIER
179: . '|' . Regex::PHP_IDENTIFIER . '(?:\(\))?)$/D',
180: $member,
181: )) {
182: $this->throw("Invalid member '%s'", $member);
183: }
184: return $member;
185: }
186:
187: /**
188: * @template T of string|null
189: *
190: * @param T $type
191: * @return T
192: */
193: final protected function filterType(?string $type): ?string
194: {
195: if ($type === null) {
196: return null;
197: }
198:
199: try {
200: return PHPDoc::normaliseType($type, true);
201: } catch (\InvalidArgumentException $ex) {
202: $this->throw('%s', $ex->getMessage());
203: }
204: }
205:
206: /**
207: * @template T of string|null
208: *
209: * @param T $value
210: * @return T
211: */
212: final protected function filterString(?string $value, string $name): ?string
213: {
214: if ($value !== null && trim($value) === '') {
215: $this->throw("Invalid %s '%s'", $name, $value);
216: }
217: return $value;
218: }
219:
220: /**
221: * @param string|int|float ...$args
222: * @return never
223: */
224: final protected function throw(string $message, ...$args): void
225: {
226: if (isset($this->Tag)) {
227: $message .= ' for @%s';
228: $args[] = $this->Tag;
229: }
230:
231: $message .= ' in DocBlock';
232:
233: if (isset($this->Class)) {
234: $message .= ' of %s';
235: $args[] = $this->Class;
236: if (isset($this->Member)) {
237: $message .= '::%s';
238: $args[] = $this->Member;
239: }
240: }
241:
242: throw new InvalidTagValueException(sprintf($message, ...$args));
243: }
244: }
245: