Skip to content

Commit e2aaf8c

Browse files
committed
Fix and enhance NonExistingBladeTemplateSniff
for PHP 8.4 that creates $templatePaths as an array with 0 numeric key instead of an empty string
1 parent 5257fef commit e2aaf8c

File tree

16 files changed

+338
-70
lines changed

16 files changed

+338
-70
lines changed

IxDFCodingStandard/Sniffs/Laravel/NonExistingBladeTemplateSniff.php

Lines changed: 153 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,40 @@
22

33
namespace IxDFCodingStandard\Sniffs\Laravel;
44

5-
use BadMethodCallException;
65
use PHP_CodeSniffer\Files\File;
76
use PHP_CodeSniffer\Sniffs\Sniff;
87

98
final class NonExistingBladeTemplateSniff implements Sniff
109
{
1110
private const INVALID_METHOD_CALL = 'Invalid method call';
1211

12+
public const CODE_TEMPLATE_NOT_FOUND = 'TemplateNotFound';
13+
public const CODE_UNKNOWN_VIEW_NAMESPACE = 'UnknownViewNamespace';
14+
1315
// @include
1416
private const INCLUDE_BLADE_DIRECTIVE = '/@(include|component|extends)\(\'([^\']++)\'/';
1517

1618
// @includeIf
1719
private const CONDITIONAL_INCLUDE_BLADE_DIRECTIVE = '/@(includeIf|includeWhen)\([^,]++,\s*+\'([^\']++)\'/';
1820

21+
/** @var list<non-empty-string> The same as for config('view.paths') */
22+
public array $viewPaths = [
23+
'resources/views',
24+
];
25+
26+
/** @var array<non-empty-string, non-empty-string> Example: <element key="googletagmanager" value="resources/views/vendor/googletagmanager"/> */
27+
public array $viewNamespaces = [];
28+
29+
/** @var non-empty-string|null Custom base directory, defaults to auto-detection */
30+
public ?string $baseDir = null;
31+
1932
/** @var array<string, bool> */
2033
private array $checkedFiles = [];
2134

22-
/** @var array<string, non-empty-string> */
23-
public array $templatePaths = [];
24-
2535
/** @inheritDoc */
2636
public function register(): array
2737
{
28-
return [\T_OPEN_TAG, \T_INLINE_HTML];
38+
return [\T_OPEN_TAG, \T_INLINE_HTML, \T_STRING];
2939
}
3040

3141
/** @inheritDoc */
@@ -43,16 +53,23 @@ public function process(File $phpcsFile, $stackPtr): int // phpcs:ignore Generic
4353
$this->checkedFiles[$hash] = true;
4454

4555
foreach ($tokens as $position => $token) {
46-
if ($this->isBladeIncludeDirective($token['content'])) {
47-
$this->validateTemplateName($this->getBladeTemplateName($token['content']), $phpcsFile, $position);
48-
} elseif ($this->isConditionalBladeIncludeDirective($token['content'])) {
49-
$this->validateTemplateName($this->getConditionalBladeTemplateName($token['content']), $phpcsFile, $position);
50-
} elseif ($this->isViewFacade($tokens, $position)) {
51-
$this->validateTemplateName($this->getViewFacadeTemplateName($tokens, $position), $phpcsFile, $position);
52-
} elseif ($this->isViewFunctionFactory($tokens, $position)) {
53-
$this->validateTemplateName($this->getViewFunctionFactoryTemplateName($tokens, $position), $phpcsFile, $position);
54-
} elseif ($this->isViewFunction($tokens, $position)) {
55-
$this->validateTemplateName($this->getViewFunctionTemplateName($tokens, $position), $phpcsFile, $position);
56+
$tokenContent = $token['content'];
57+
58+
// Handle Blade directives (found in T_INLINE_HTML tokens)
59+
if ($this->isBladeIncludeDirective($tokenContent)) {
60+
$this->validateTemplateName($this->getBladeTemplateName($tokenContent), $phpcsFile, $position);
61+
} elseif ($this->isConditionalBladeIncludeDirective($tokenContent)) {
62+
$this->validateTemplateName($this->getConditionalBladeTemplateName($tokenContent), $phpcsFile, $position);
63+
}
64+
// Handle PHP code (found in T_STRING tokens)
65+
elseif ($token['type'] === 'T_STRING') {
66+
if ($this->isViewFacade($tokens, $position)) {
67+
$this->validateTemplateName($this->getViewFacadeTemplateName($tokens, $position), $phpcsFile, $position);
68+
} elseif ($this->isViewFunctionFactory($tokens, $position)) {
69+
$this->validateTemplateName($this->getViewFunctionFactoryTemplateName($tokens, $position), $phpcsFile, $position);
70+
} elseif ($this->isViewFunction($tokens, $position)) {
71+
$this->validateTemplateName($this->getViewFunctionTemplateName($tokens, $position), $phpcsFile, $position);
72+
}
5673
}
5774
}
5875

@@ -72,14 +89,14 @@ private function isConditionalBladeIncludeDirective(string $tokenContent): bool
7289
private function getBladeTemplateName(string $tokenContent): string
7390
{
7491
if (!$this->isBladeIncludeDirective($tokenContent)) {
75-
throw new BadMethodCallException(self::INVALID_METHOD_CALL);
92+
throw new \BadMethodCallException(self::INVALID_METHOD_CALL);
7693
}
7794

7895
$matches = [];
7996
preg_match(self::INCLUDE_BLADE_DIRECTIVE, $tokenContent, $matches);
8097

8198
if (!isset($matches[2])) {
82-
throw new BadMethodCallException(self::INVALID_METHOD_CALL);
99+
throw new \BadMethodCallException(self::INVALID_METHOD_CALL);
83100
}
84101

85102
return (string) $matches[2];
@@ -88,76 +105,142 @@ private function getBladeTemplateName(string $tokenContent): string
88105
private function getConditionalBladeTemplateName(string $tokenContent): string
89106
{
90107
if (!$this->isConditionalBladeIncludeDirective($tokenContent)) {
91-
throw new BadMethodCallException(self::INVALID_METHOD_CALL);
108+
throw new \BadMethodCallException(self::INVALID_METHOD_CALL);
92109
}
93110

94111
$matches = [];
95112
preg_match(self::CONDITIONAL_INCLUDE_BLADE_DIRECTIVE, $tokenContent, $matches);
96113

97114
if (!isset($matches[2])) {
98-
throw new BadMethodCallException(self::INVALID_METHOD_CALL);
115+
throw new \BadMethodCallException(self::INVALID_METHOD_CALL);
99116
}
100117

101118
return (string) $matches[2];
102119
}
103120

121+
/**
122+
* @param string $templateName In dot notation
123+
* @throws \OutOfBoundsException
124+
*/
104125
private function templateIsMissing(string $templateName): bool
105126
{
106-
return ! file_exists($this->getTemplatePath($templateName));
127+
foreach ($this->getTemplatePathCandidates($templateName) as $candidateFilePath) {
128+
if (\file_exists($candidateFilePath)) {
129+
return false;
130+
}
131+
}
132+
133+
return true;
134+
}
135+
136+
private function resolveLaravelBaseDir(): string
137+
{
138+
return $this->baseDir ?? dirname(__DIR__, 6); // assume this file in the classic vendor dir
107139
}
108140

109-
private function getTemplatePath(string $name): string
141+
/**
142+
* @param string $templatePath In dot notation
143+
* @return list<non-empty-string>
144+
* @throws \OutOfBoundsException
145+
*/
146+
private function getTemplatePathCandidates(string $templatePath): array
110147
{
111-
$namespace = preg_match('/^(\w++)::/', $name, $matches)
112-
? $matches[1]
113-
: '';
148+
/**
149+
* Here, we have 2 cases:
150+
* 1. No custom namespace, e.g. 'dashboard.index' -> resources/views/dashboard/index.blade.php
151+
* 2. Custom namespace, e.g. 'googletagmanager::head' -> resources/views/vendor/googletagmanager ({@see \Illuminate\Support\ServiceProvider::loadViewsFrom})
152+
*/
153+
154+
$hasCustomNamespace = preg_match('/^(\w++)::\w+/', $templatePath, $matches);
155+
156+
// 2. Custom namespace
157+
if ($hasCustomNamespace) {
158+
$namespace = (string) $matches[1];
159+
160+
if (!isset($this->viewNamespaces[$namespace])) {
161+
throw new \OutOfBoundsException("Unable to find view namespace “{$namespace}” in viewNamespaces property.");
162+
}
163+
164+
$namespacePath = $this->viewNamespaces[$namespace];
114165

115-
if (! isset($this->templatePaths[$namespace])) {
116-
throw new \InvalidArgumentException("Unable to find namespace {$namespace} in configurations!");
166+
$templateNameWithoutNamespace = str_replace("{$namespace}::", '', $templatePath);
167+
168+
return [
169+
sprintf(
170+
'%s/%s/%s.blade.php',
171+
$this->resolveLaravelBaseDir(),
172+
$namespacePath,
173+
str_replace('.', '/', $templateNameWithoutNamespace)
174+
),
175+
];
117176
}
118177

119-
$withoutNamespace = ! empty($namespace)
120-
? str_replace("$namespace::", '', $name)
121-
: $name;
178+
// 1. No custom namespace
179+
$candidates = [];
180+
foreach ($this->viewPaths as $viewPath) {
181+
$candidates[] = sprintf(
182+
'%s/%s/%s.blade.php',
183+
$this->resolveLaravelBaseDir(),
184+
$viewPath,
185+
str_replace('.', '/', $templatePath)
186+
);
187+
}
122188

123-
return sprintf(
124-
'%s/%s/%s.blade.php',
125-
dirname(__DIR__, 6),
126-
$this->templatePaths[$namespace],
127-
str_replace('.', '/', $withoutNamespace)
189+
return $candidates;
190+
}
191+
192+
private function reportTemplateNotFound(File $phpcsFile, int $stackPtr, string $templateName): void
193+
{
194+
$phpcsFile->addError(
195+
'Blade template "%s" not found. Expected location(s): "%s"',
196+
$stackPtr,
197+
self::CODE_TEMPLATE_NOT_FOUND,
198+
[
199+
$templateName,
200+
implode(', ', $this->getTemplatePathCandidates($templateName)),
201+
]
128202
);
129203
}
130204

131-
private function reportMissingTemplate(File $phpcsFile, int $stackPtr, string $templateName): void
205+
private function reportUnknownViewNamespace(File $phpcsFile, int $stackPtr, string $templateName): void
132206
{
133207
$phpcsFile->addWarning(
134-
'Template "%s" (%s) does not exist in "%s"',
208+
'Blade template namespace for the "%s" is not registered via "viewNamespaces" property.',
135209
$stackPtr,
136-
'TemplateNotFound',
137-
[$templateName, $this->getTemplatePath($templateName), $phpcsFile->getFilename()]
210+
self::CODE_UNKNOWN_VIEW_NAMESPACE,
211+
[
212+
$templateName,
213+
]
138214
);
139215
}
140216

141217
private function validateTemplateName(string $templateName, File $phpcsFile, int $stackPtr): void
142218
{
143-
if (rtrim($templateName, '-_.') !== $templateName) {
219+
if (mb_rtrim($templateName, '-_.') !== $templateName) {
144220
return;
145221
}
146222

147223
if (str_contains($templateName, '$')) {
148224
return;
149225
}
150226

151-
if ($this->templateIsMissing($templateName)) {
152-
$this->reportMissingTemplate($phpcsFile, $stackPtr, $templateName);
227+
try {
228+
if ($this->templateIsMissing($templateName)) {
229+
$this->reportTemplateNotFound($phpcsFile, $stackPtr, $templateName);
230+
}
231+
} catch (\OutOfBoundsException) {
232+
$this->reportUnknownViewNamespace($phpcsFile, $stackPtr, $templateName);
153233
}
154234
}
155235

156236
/** @param array<array<string>> $tokens */
157237
private function isViewFacade(array $tokens, int $position): bool
158238
{
159-
return isset($tokens[$position + 2]) &&
160-
($tokens[$position]['content'] === 'View' || $tokens[$position]['content'] === 'ViewFacade') &&
239+
if (!isset($tokens[$position + 3])) {
240+
return false;
241+
}
242+
243+
return ($tokens[$position]['content'] === 'View' || $tokens[$position]['content'] === 'ViewFacade') &&
161244
$tokens[$position + 1]['type'] === 'T_DOUBLE_COLON' &&
162245
$tokens[$position + 2]['content'] === 'make' &&
163246
$tokens[$position + 3]['type'] === 'T_CONSTANT_ENCAPSED_STRING';
@@ -166,27 +249,28 @@ private function isViewFacade(array $tokens, int $position): bool
166249
/** @param array<array<string>> $tokens */
167250
private function getViewFacadeTemplateName(array $tokens, int $position): string // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
168251
{
169-
if (! $this->isViewFacade($tokens, $position)) {
170-
throw new BadMethodCallException(self::INVALID_METHOD_CALL);
252+
if (!$this->isViewFacade($tokens, $position)) {
253+
throw new \BadMethodCallException(self::INVALID_METHOD_CALL);
171254
}
172255

173-
$lookupPosition = $position + 4;
174-
do {
256+
$maxLookupPosition = $position + 14;
257+
for ($lookupPosition = $position + 4; $lookupPosition < $maxLookupPosition && isset($tokens[$lookupPosition]); $lookupPosition++) {
175258
if ($tokens[$lookupPosition]['type'] !== 'T_WHITESPACE') {
176-
return trim($tokens[$lookupPosition]['content'], '\'');
259+
return mb_trim($tokens[$lookupPosition]['content'], '\'"');
177260
}
261+
}
178262

179-
$lookupPosition++;
180-
} while (isset($tokens[$lookupPosition]) && $lookupPosition < $position + 14);
181-
182-
throw new BadMethodCallException('Unable to find the template name');
263+
throw new \BadMethodCallException('Unable to find the template name');
183264
}
184265

185266
/** @param array<array<string>> $tokens */
186267
private function isViewFunctionFactory(array $tokens, int $position): bool
187268
{
188-
return isset($tokens[$position + 4]) &&
189-
$tokens[$position]['content'] === 'view' &&
269+
if (!isset($tokens[$position + 6])) {
270+
return false;
271+
}
272+
273+
return $tokens[$position]['content'] === 'view' &&
190274
$tokens[$position + 1]['content'] === '(' &&
191275
$tokens[$position + 2]['content'] === ')' &&
192276
$tokens[$position + 3]['content'] === '->' &&
@@ -197,27 +281,28 @@ private function isViewFunctionFactory(array $tokens, int $position): bool
197281
/** @param array<array<string>> $tokens */
198282
private function getViewFunctionFactoryTemplateName(array $tokens, int $position): string // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
199283
{
200-
if (! $this->isViewFunctionFactory($tokens, $position)) {
201-
throw new BadMethodCallException(self::INVALID_METHOD_CALL);
284+
if (!$this->isViewFunctionFactory($tokens, $position)) {
285+
throw new \BadMethodCallException(self::INVALID_METHOD_CALL);
202286
}
203287

204-
$lookupPosition = $position + 6;
205-
do {
288+
$maxLookupPosition = $position + 16;
289+
for ($lookupPosition = $position + 6; $lookupPosition < $maxLookupPosition && isset($tokens[$lookupPosition]); $lookupPosition++) {
206290
if ($tokens[$lookupPosition]['type'] !== 'T_WHITESPACE') {
207-
return trim($tokens[$lookupPosition]['content'], '\'');
291+
return mb_trim($tokens[$lookupPosition]['content'], '\'"');
208292
}
293+
}
209294

210-
$lookupPosition++;
211-
} while (isset($tokens[$lookupPosition]) && $lookupPosition < $position + 16);
212-
213-
throw new BadMethodCallException('Unable to find the template name');
295+
throw new \BadMethodCallException('Unable to find the template name');
214296
}
215297

216298
/** @param array<array<string>> $tokens */
217299
private function isViewFunction(array $tokens, int $position): bool
218300
{
219-
return isset($tokens[$position - 1], $tokens[$position + 4]) &&
220-
$tokens[$position - 1]['type'] === 'T_WHITESPACE' &&
301+
if (!isset($tokens[$position - 1], $tokens[$position + 2])) {
302+
return false;
303+
}
304+
305+
return $tokens[$position - 1]['type'] === 'T_WHITESPACE' &&
221306
$tokens[$position]['content'] === 'view' &&
222307
$tokens[$position + 1]['content'] === '(' &&
223308
$tokens[$position + 2]['type'] === 'T_CONSTANT_ENCAPSED_STRING';
@@ -226,10 +311,10 @@ private function isViewFunction(array $tokens, int $position): bool
226311
/** @param array<array<string>> $tokens */
227312
private function getViewFunctionTemplateName(array $tokens, int $position): string
228313
{
229-
if (! $this->isViewFunction($tokens, $position)) {
230-
throw new BadMethodCallException(self::INVALID_METHOD_CALL);
314+
if (!$this->isViewFunction($tokens, $position)) {
315+
throw new \BadMethodCallException(self::INVALID_METHOD_CALL);
231316
}
232317

233-
return trim($tokens[$position + 2]['content'], '\'');
318+
return mb_trim($tokens[$position + 2]['content'], '\'"');
234319
}
235320
}

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
An opinionated ruleset focused on strict types.
77
Suitable for both applications and packages.
88

9-
109
## Installation
1110

1211
1. Install the package via composer by running:

docs/laravel.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,25 @@
33
### IxDFCodingStandard.Laravel.DisallowGuardedAttribute
44
Do not allow using [Mass Assignment](https://laravel.com/docs/master/eloquent#mass-assignment).
55

6+
67
### IxDFCodingStandard.Laravel.NonExistingBladeTemplate
7-
Check whether blade view exist.
8+
9+
Detects missing Blade templates in `@include`, `view()`, and `View::make()` calls.
10+
11+
```xml
12+
<rule ref="IxDFCodingStandard.Laravel.NonExistingBladeTemplate">
13+
<properties>
14+
<!-- the same as you config('view.paths') output -->
15+
<property name="viewPaths" type="array">
16+
<element value="resources/views"/>
17+
</property>
18+
<!-- for cases like @include('package::someView') -->
19+
<property name="viewNamespaces" type="array">
20+
<element key="package" value="resources/views/vendor/package"/>
21+
</property>
22+
</properties>
23+
</rule>
24+
```
825

926
### IxDFCodingStandard.Laravel.RequireCustomAbortMessage
1027
Force using custom exception messages when use `abort`, `abort_if` and `abort_unless`.

0 commit comments

Comments
 (0)