2026-05-13 09:10:43 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace YakNet\AccessibilityConsole\Source;
|
|
|
|
|
|
|
|
|
|
use RecursiveDirectoryIterator;
|
|
|
|
|
use RecursiveIteratorIterator;
|
|
|
|
|
use SplFileInfo;
|
|
|
|
|
|
|
|
|
|
class SourceLocator
|
|
|
|
|
{
|
2026-05-31 23:00:56 +03:00
|
|
|
/** @var array<int, string> */
|
2026-05-13 09:10:43 +03:00
|
|
|
private array $extensions = ['php', 'html', 'twig', 'blade.php'];
|
|
|
|
|
|
|
|
|
|
public function __construct(
|
|
|
|
|
private readonly string $searchPath
|
|
|
|
|
) {}
|
|
|
|
|
|
2026-05-31 23:00:56 +03:00
|
|
|
/**
|
|
|
|
|
* @return array{file: string, line: int}|null
|
|
|
|
|
*/
|
2026-05-13 09:10:43 +03:00
|
|
|
public function locate(string $snippet): ?array
|
|
|
|
|
{
|
|
|
|
|
if (!is_dir($this->searchPath) || !is_readable($this->searchPath)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 09:36:46 +03:00
|
|
|
$replaced = preg_replace('/\s+/', ' ', $snippet);
|
|
|
|
|
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
|
2026-05-13 09:10:43 +03:00
|
|
|
if ($cleanSnippet === '') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$searchSnippet = mb_strlen($cleanSnippet) > 150 ? mb_substr($cleanSnippet, 0, 150) : $cleanSnippet;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$directory = new RecursiveDirectoryIterator($this->searchPath, \FilesystemIterator::SKIP_DOTS);
|
2026-06-06 10:15:24 +03:00
|
|
|
$filter = new \RecursiveCallbackFilterIterator($directory, function ($current) {
|
|
|
|
|
/** @var SplFileInfo $current */
|
|
|
|
|
if ($current->isDir()) {
|
|
|
|
|
$filename = $current->getFilename();
|
|
|
|
|
if ($filename === 'vendor' || $filename === '.git' || $filename === 'node_modules') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
|
2026-05-13 09:10:43 +03:00
|
|
|
} catch (\UnexpectedValueException) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$bestFuzzyMatch = null;
|
|
|
|
|
$highestScore = 0;
|
|
|
|
|
|
|
|
|
|
foreach ($iterator as $file) {
|
|
|
|
|
/** @var SplFileInfo $file */
|
|
|
|
|
if ($file->isDir()) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$this->isValidExtension($file->getFilename())) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$result = $this->searchInFile($file->getPathname(), $searchSnippet, $cleanSnippet);
|
|
|
|
|
|
|
|
|
|
if ($result['type'] === 'exact') {
|
|
|
|
|
return $result['location'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($result['type'] === 'fuzzy' && $result['score'] > $highestScore) {
|
|
|
|
|
$highestScore = $result['score'];
|
|
|
|
|
$bestFuzzyMatch = $result['location'];
|
|
|
|
|
}
|
|
|
|
|
} catch (\Throwable) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ($bestFuzzyMatch && $highestScore >= 2) ? $bestFuzzyMatch : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function isValidExtension(string $filename): bool
|
|
|
|
|
{
|
|
|
|
|
foreach ($this->extensions as $ext) {
|
|
|
|
|
if (str_ends_with($filename, $ext)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 23:00:56 +03:00
|
|
|
/**
|
2026-06-06 09:36:46 +03:00
|
|
|
* @return array{type: 'exact', location: array{file: string, line: int}}|array{type: 'fuzzy', score: float, location: array{file: string, line: int}}|array{type: 'none'}
|
2026-05-31 23:00:56 +03:00
|
|
|
*/
|
2026-05-13 09:10:43 +03:00
|
|
|
private function searchInFile(string $filepath, string $searchSnippet, string $fullSnippet): array
|
|
|
|
|
{
|
|
|
|
|
$content = file_get_contents($filepath);
|
|
|
|
|
if ($content === false) {
|
|
|
|
|
return ['type' => 'none'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$lines = explode("\n", $content);
|
|
|
|
|
$parsedSnippet = $this->parseSnippet($fullSnippet);
|
|
|
|
|
|
|
|
|
|
$bestLine = 0;
|
|
|
|
|
$maxScore = 0;
|
|
|
|
|
|
|
|
|
|
foreach ($lines as $index => $line) {
|
|
|
|
|
$lineNumber = $index + 1;
|
2026-06-06 09:36:46 +03:00
|
|
|
$replacedLine = preg_replace('/\s+/', ' ', $line);
|
|
|
|
|
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
|
2026-05-13 09:10:43 +03:00
|
|
|
|
|
|
|
|
if (str_contains($cleanLine, $searchSnippet)) {
|
|
|
|
|
return [
|
|
|
|
|
'type' => 'exact',
|
|
|
|
|
'location' => ['file' => $filepath, 'line' => $lineNumber]
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$score = $this->calculateFuzzyScore($cleanLine, $parsedSnippet);
|
|
|
|
|
if ($score > $maxScore) {
|
|
|
|
|
$maxScore = $score;
|
|
|
|
|
$bestLine = $lineNumber;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($maxScore > 0) {
|
|
|
|
|
return [
|
|
|
|
|
'type' => 'fuzzy',
|
|
|
|
|
'score' => $maxScore,
|
|
|
|
|
'location' => ['file' => $filepath, 'line' => $bestLine]
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ['type' => 'none'];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 23:00:56 +03:00
|
|
|
/**
|
|
|
|
|
* @return array{tag: string|null, attributes: array<string, string>}
|
|
|
|
|
*/
|
2026-05-13 09:10:43 +03:00
|
|
|
private function parseSnippet(string $snippet): array
|
|
|
|
|
{
|
|
|
|
|
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
|
|
|
|
|
$tagName = $matches[1] ?? null;
|
|
|
|
|
|
|
|
|
|
preg_match_all('/([a-zA-Z0-9-]+)=["\']([^"\']*)["\']/', $snippet, $matches, PREG_SET_ORDER);
|
|
|
|
|
|
|
|
|
|
$attributes = [];
|
|
|
|
|
foreach ($matches as $match) {
|
|
|
|
|
$attributes[$match[1]] = $match[2];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ['tag' => $tagName, 'attributes' => $attributes];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 23:00:56 +03:00
|
|
|
/**
|
|
|
|
|
* @param array{tag: string|null, attributes: array<string, string>} $parsed
|
|
|
|
|
*/
|
2026-05-13 09:10:43 +03:00
|
|
|
private function calculateFuzzyScore(string $line, array $parsed): float
|
|
|
|
|
{
|
|
|
|
|
if (!$parsed['tag'] || !str_contains(strtolower($line), '<' . strtolower($parsed['tag']))) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$score = 1.0;
|
|
|
|
|
|
|
|
|
|
if (in_array(strtolower($parsed['tag']), ['html', 'head', 'body', 'title'])) {
|
|
|
|
|
$score += 1.0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($parsed['attributes'] as $name => $value) {
|
|
|
|
|
if (str_contains($line, "$name=\"$value\"") || str_contains($line, "$name='$value'")) {
|
|
|
|
|
$score += 2.0;
|
|
|
|
|
} elseif (str_contains($line, $name . '=')) {
|
|
|
|
|
$score += 0.5;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $score;
|
|
|
|
|
}
|
|
|
|
|
}
|