-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAbstractA11yRule.php
More file actions
186 lines (149 loc) · 6.04 KB
/
AbstractA11yRule.php
File metadata and controls
186 lines (149 loc) · 6.04 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
<?php
declare(strict_types=1);
namespace TwigA11y\Rules;
use TwigA11y\Template\TemplateClassifier;
use TwigA11y\Template\TemplateKind;
use TwigCsFixer\Rules\AbstractRule;
use TwigCsFixer\Token\Token;
use TwigCsFixer\Token\Tokens;
abstract class AbstractA11yRule extends AbstractRule implements EvaluatableRuleInterface
{
use TokenCollectorTrait;
private const KIND_CACHE_MAX = 500;
/**
* Maximum number of rule-file keys in the $emitted map. When exceeded the
* map is reset to prevent unbounded memory growth during long-running
* linting sessions (watch-mode, large CI pipelines, etc.).
*/
private const EMITTED_MAX = 2000;
/** Cached decision for the currently-processed file when rules are reused */
private ?bool $skipThisFile = null;
/**
* Per-instance cache of already-emitted messages keyed by file hash.
*
* Keyed by rule-file => array<string, bool>.
*
* @var array<string, array<string, bool>>
*/
private array $emitted = [];
/**
* Shared cache of TemplateKind decisions keyed by content hash to avoid
* repeatedly classifying the same file across multiple rule instances.
*
* Bounded to 500 entries to prevent unbounded memory growth when linting
* very large projects in a single PHP process (e.g. via a long-running CI
* worker or watch mode). When the limit is reached the cache is reset so
* the next classification starts fresh.
*
* @var array<string, TemplateKind>
*/
private static array $kindCache = [];
/** Per-instance cache of the full template content for the current file. */
private ?string $cachedContent = null;
/**
* @param bool $emitAsWarning Pass true for rules that should report
* accessibility hints as warnings rather than
* hard errors (e.g. AnchorContentRule).
*/
public function __construct(private bool $emitAsWarning = false) {}
// By default rules apply to all template kinds. Rules that should be
// limited to specific kinds can override supportedKinds().
/**
* @return TemplateKind[]
*/
protected function supportedKinds(): array
{
return TemplateKind::cases();
}
// Rules can opt to run only once per file (for page-level scans).
protected function evaluateOncePerFile(): bool
{
return false;
}
// Backwards-compatible helper used by existing rules that used the
// pattern "if (0 !== $tokenIndex) return;". When refactoring rules to
// use evaluateOncePerFile(), replace those guards with a call to
// shouldSkipByTokenIndex().
protected function shouldSkipByTokenIndex(int $tokenIndex): bool
{
return $this->evaluateOncePerFile() && 0 !== $tokenIndex;
}
final protected function process(int $tokenIndex, Tokens $tokens): void
{
// On the first token, determine the template kind and record whether
// this rule applies to the file. This supports rule instances being
// reused across multiple files.
if (0 === $tokenIndex) {
// Reset the per-file cache so the new file's content is used.
$this->cachedContent = null;
$content = $this->getFullContent($tokens);
$hash = md5($content);
if (!isset(self::$kindCache[$hash])) {
if (count(self::$kindCache) >= self::KIND_CACHE_MAX) {
self::$kindCache = [];
}
self::$kindCache[$hash] = TemplateClassifier::classify($content);
}
$kind = self::$kindCache[$hash];
$this->skipThisFile = !in_array($kind, $this->supportedKinds(), true);
}
// If earlier we decided this rule doesn't apply to this file, skip.
if (true === $this->skipThisFile) {
return;
}
// If the rule only runs once per file, only evaluate at tokenIndex 0.
if ($this->evaluateOncePerFile() && 0 !== $tokenIndex) {
return;
}
$this->evaluate($tokens, $tokenIndex, $this->createEmitter($tokens));
}
protected function getFullContent(Tokens $tokens): string
{
if (null !== $this->cachedContent) {
return $this->cachedContent;
}
$content = '';
foreach ($tokens->toArray() as $token) {
$content .= $token->getValue();
}
$this->cachedContent = $content;
return $this->cachedContent;
}
private function createEmitter(Tokens $tokens): callable
{
// Use the file content hash to deduplicate identical emissions from
// the same rule for the same file. This prevents noisy repeated
// messages when rules are evaluated multiple times for the same
// template content.
$hash = md5($this->getFullContent($tokens));
$ruleFileKey = static::class.'::'.$hash;
if (!isset($this->emitted[$ruleFileKey])) {
if (count($this->emitted) >= self::EMITTED_MAX) {
$this->emitted = [];
}
$this->emitted[$ruleFileKey] = [];
}
if ($this->emitAsWarning) {
return function (string $message, Token $token, ?string $id = null) use ($ruleFileKey): void {
$key = $message.'|'.($id ?? '');
if (isset($this->emitted[$ruleFileKey][$key])) {
return;
}
$this->emitted[$ruleFileKey][$key] = true;
if (null === $id) {
$this->addWarning($message, $token);
return;
}
$this->addWarning($message, $token, $id);
};
}
return function (string $message, Token $token, ?string $id = null) use ($ruleFileKey): void {
$key = $message.'|'.($id ?? '');
if (isset($this->emitted[$ruleFileKey][$key])) {
return;
}
$this->emitted[$ruleFileKey][$key] = true;
$this->addError($message, $token, $id);
};
}
}