Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Config/ConfigData.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
final readonly class ConfigData
{
/**
* @param array<string, string> $projectRoots
* @param list<SniffEntry> $sniffs
* @param list<string> $includePaths
* @param list<string> $excludePatterns
* @param list<string> $entityPaths
* @param string $basePath
*/
public function __construct(
private array $projectRoots,
private array $sniffs,
private array $includePaths,
private array $excludePatterns,
Expand All @@ -22,6 +24,12 @@ public function __construct(
) {
}

/** @return array<string, string> */
public function getProjectRoots(): array
{
return $this->projectRoots;
}

/** @return list<SniffEntry> */
public function getSniffs(): array
{
Expand Down
45 changes: 36 additions & 9 deletions src/Config/ConfigParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,47 @@ private function parse(\SimpleXMLElement $root, string $basePath): ConfigData
// Register the namespace for xpath queries.
$root->registerXPathNamespace('d', self::NAMESPACE_URI);

$sniffs = $this->parseSniffs($root);
$includePaths = $this->parsePaths($root, $basePath);
$excludePatterns = $this->parseExcludePatterns($root);
$entityPaths = $this->parseEntityPaths($root, $basePath);

return new ConfigData(
sniffs: $sniffs,
includePaths: $includePaths,
excludePatterns: $excludePatterns,
entityPaths: $entityPaths,
projectRoots: $this->parseProjectRoots($root, $basePath),
sniffs: $this->parseSniffs($root),
includePaths: $this->parsePaths($root, $basePath),
excludePatterns: $this->parseExcludePatterns($root),
entityPaths: $this->parseEntityPaths($root, $basePath),
basePath: $basePath,
);
}

/**
* @return array<string, string>
* @throws ConfigParserException if the <project> element is missing or if
* the base path is not within any of the specified project roots.
*/
private function parseProjectRoots(\SimpleXMLElement $root, string $basePath): array
{
if (!isset($root->project)) {
$name = basename($basePath);

return [
dirname($basePath) . '/' . $name => $name,
$basePath => $name,
];
}

$directories = [];

foreach ($root->project->directory ?: [] as $directory) {
$directoryName = (string) $directory;
$directories[dirname($basePath) . '/' . $directoryName] = $directoryName;

if ($directory->attributes()->alias) {
$alias = (string) $directory->attributes()->alias;
$directories[dirname($basePath) . '/' . $alias] = $directoryName;
}
}

return $directories;
}

/**
* @return list<SniffEntry>
* @throws ConfigParserException if required elements or attributes are missing.
Expand Down
164 changes: 135 additions & 29 deletions src/Path/EntityResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,57 @@

final class EntityResolver
{
/** @var list<string> */
private array $entityPaths;

private string $extension;

/**
* @param array<string, string> $projectRoots
* @param list<string> $entityPaths
*/
public function __construct(array $entityPaths, string $extension = 'ent')
{
$this->entityPaths = $entityPaths;
public function __construct(
private readonly array $projectRoots,
private readonly array $entityPaths,
string $extension = 'ent'
) {
$this->extension = ltrim($extension, '.');
}

/**
* @return list<string>
* @throws \UnexpectedValueException if any of the entity paths is a directory that cannot be read.
* @return array<string, string>
* @throws \UnexpectedValueException if the directory cannot be read.
*/
public function resolve(): array
{
$files = [];
$entities = [];

foreach ($this->entityPaths as $path) {
if (is_file($path) && str_ends_with($path, '.' . $this->extension)) {
$files[] = str_replace('\\', '/', $path);
continue;
foreach ($this->getEntityFiles($path) as $file) {
$entities += $this->resolveFile($file);
}
}

if (!is_dir($path)) {
continue;
}
return $entities;
}

foreach ($this->scanDirectory($path) as $file) {
$files[] = $file;
}
/**
* @return list<string>
* @throws \UnexpectedValueException if the directory cannot be read.
*/
private function getEntityFiles(string $path): array
{
if (is_file($path) && $this->isEntityFile($path)) {
return [$path];
}

sort($files);
if (!is_dir($path)) {
return [];
}

return $this->scanDirectory($path);
}

return array_values(array_unique($files));
private function isEntityFile(string $path): bool
{
return str_ends_with($path, '.' . $this->extension);
}

/**
Expand All @@ -54,24 +65,119 @@ public function resolve(): array
*/
private function scanDirectory(string $directory): array
{
$found = [];
$files = [];

$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(
$directory,
\FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS,
),
\FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS
)
);

/** @var \SplFileInfo $fileInfo */
foreach ($iterator as $fileInfo) {
if (
$fileInfo->isFile()
&& str_ends_with($fileInfo->getPathname(), '.' . $this->extension)
) {
$found[] = str_replace('\\', '/', $fileInfo->getPathname());
if ($fileInfo->isFile() && $this->isEntityFile($fileInfo->getPathname())) {
$files[] = str_replace('\\', '/', $fileInfo->getPathname());
}
}

return $files;
}

/**
* @param array<string, bool> $visited
* @return array<string, string>
*/
private function resolveFile(
string $filePath,
array &$visited = [],
?string $originEntity = null
): array {
if (isset($visited[$filePath]) || !is_readable($filePath)) {
return [];
}

$visited[$filePath] = true;

$content = file_get_contents($filePath);

if ($content === false) {
return []; // @codeCoverageIgnore
}

$entities = $this->extractEntities($content, $filePath, $visited);

if ($originEntity !== null) {
$entities[$originEntity] = $this->normalize($content);
}

return $entities;
}

/**
* @param array<string, bool> $visited
* @return array<string, string>
*/
private function extractEntities(
string $content,
string $filePath,
array &$visited
): array {
$result = [];

if (!str_contains($content, '<!ENTITY')) {
return $result;
}

if (
!preg_match_all(
'/<!ENTITY\s+(?:%\s*)?([A-Za-z0-9_\-:.]+)\s+(?:(SYSTEM)\s+)?(["\'])([\s\S]*?)\3\s*>/',
$content,
$matches,
PREG_SET_ORDER
)
) {
return $result;
}

foreach ($matches as $match) {
$name = $match[1];
$type = $match[2] ?: null;
$value = trim($match[4]);

if ($type === 'SYSTEM') {
$resolvedPath = $this->resolvePath($filePath, $value);
$result += $this->resolveFile($resolvedPath, $visited, $name);

continue;
}

$result[$name] = $this->normalize($value);
}

return $result;
}

private function normalize(string $value): string
{
return preg_replace('/\s+/', ' ', trim($value)) ?: $value;
}

private function resolvePath(string $path, string $reference): string
{
foreach ($this->projectRoots as $root => $directory) {
if (!str_contains($reference, '/' . $directory . '/')) {
continue;
}

[$prefix] = explode('/' . $directory . '/', $reference);
$reference = str_replace($prefix . '/' . $directory, $root, $reference);
}

if (str_starts_with($reference, '/')) {
return $reference;
}

return $found;
return dirname($path) . DIRECTORY_SEPARATOR . $reference;
}
}
12 changes: 6 additions & 6 deletions src/Path/PathMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@

final readonly class PathMatcher
{
/** @var list<string> */
private array $excludePatterns;

/**
* @param list<string> $excludePatterns
*/
public function __construct(array $excludePatterns)
{
$this->excludePatterns = $excludePatterns;
public function __construct(
private string $basePath,
private array $excludePatterns
) {
}

public function isExcluded(string $filePath): bool
{
$filePath = str_replace($this->basePath . '/', '', $filePath);

// Normalize to forward slashes for consistent matching.
$normalized = str_replace('\\', '/', $filePath);

Expand Down
Loading
Loading