diff options
Diffstat (limited to 'scripts/linters/src')
| -rw-r--r-- | scripts/linters/src/Linter.php | 19 | ||||
| -rw-r--r-- | scripts/linters/src/Linters/CargoWorkspaceDependencies.php | 83 | ||||
| -rw-r--r-- | scripts/linters/src/Linters/ContiguousUseBlock.php | 124 | ||||
| -rw-r--r-- | scripts/linters/src/Linters/NoBannedUse.php | 170 | ||||
| -rw-r--r-- | scripts/linters/src/Linters/NoDecorativeSectionComment.php | 65 | ||||
| -rw-r--r-- | scripts/linters/src/Linters/NoFormatTrailingComma.php | 48 | ||||
| -rw-r--r-- | scripts/linters/src/Linters/NoModRs.php | 38 | ||||
| -rw-r--r-- | scripts/linters/src/Linters/NoStdCollectionsMaps.php | 80 | ||||
| -rw-r--r-- | scripts/linters/src/Linters/NoUseAsAlias.php | 85 | ||||
| -rw-r--r-- | scripts/linters/src/Linters/SortedDependencies.php | 100 | ||||
| -rw-r--r-- | scripts/linters/src/Runner.php | 39 | ||||
| -rw-r--r-- | scripts/linters/src/Support/FileFinder.php | 68 | ||||
| -rw-r--r-- | scripts/linters/src/Support/Paths.php | 15 |
13 files changed, 934 insertions, 0 deletions
diff --git a/scripts/linters/src/Linter.php b/scripts/linters/src/Linter.php new file mode 100644 index 00000000..3fb6038f --- /dev/null +++ b/scripts/linters/src/Linter.php @@ -0,0 +1,19 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint; + +interface Linter +{ + public function name(): string; + + /** + * @param list<string> $excludes root-relative paths to skip + * @return list<string> formatted violation lines; empty when the linter passes + */ + public function check(string $rootDir, array $excludes): array; + + /** Printed once, above the violation list, when violations are found. */ + public function failureIntro(): string; +} diff --git a/scripts/linters/src/Linters/CargoWorkspaceDependencies.php b/scripts/linters/src/Linters/CargoWorkspaceDependencies.php new file mode 100644 index 00000000..3ad86b6f --- /dev/null +++ b/scripts/linters/src/Linters/CargoWorkspaceDependencies.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Linters; + +use Shirabe\Lint\Linter; +use Shirabe\Lint\Support\FileFinder; +use Shirabe\Lint\Support\Paths; + +final class CargoWorkspaceDependencies implements Linter +{ + private const SECTION_NAMES = ['dependencies', 'dev-dependencies', 'build-dependencies']; + + public function name(): string + { + return 'cargo_workspace_dependencies'; + } + + public function failureIntro(): string + { + return "Found `[dependencies]` / `[dev-dependencies]` entries that do not use `workspace = true`.\n" + . 'In a crate `Cargo.toml`, only `name.workspace = true` or `name = { workspace = true, ... }` is allowed:'; + } + + public function check(string $rootDir, array $excludes): array + { + $errors = []; + + foreach (FileFinder::cargoTomls($rootDir) as $path) { + $relative = Paths::relativeTo($rootDir, $path); + if (in_array($relative, $excludes, true)) { + continue; + } + + array_push($errors, ...$this->findNonWorkspaceDeps($path, $relative)); + } + + return $errors; + } + + /** @return list<string> */ + private function findNonWorkspaceDeps(string $path, string $relative): array + { + $errors = []; + $currentSection = null; + + foreach (file($path) as $idx => $rawLine) { + $stripped = trim($rawLine); + + if (preg_match('/\A\[([^\]]+)\]\z/', $stripped, $m)) { + $currentSection = $m[1]; + continue; + } + + if ($currentSection === null || !in_array($currentSection, self::SECTION_NAMES, true)) { + continue; + } + if ($stripped === '' || str_starts_with($stripped, '#')) { + continue; + } + + if (preg_match('/\A([A-Za-z0-9_-]+)\.workspace\s*=\s*true\b/', $stripped)) { + continue; + } + + if (preg_match('/\A([A-Za-z0-9_-]+)\s*=\s*\{(.+)\}\s*\z/', $stripped, $m)) { + [, $name, $inner] = $m; + if (preg_match('/\bworkspace\s*=\s*true\b/', $inner)) { + continue; + } + $errors[] = "{$relative}:" . ($idx + 1) . ": `{$name}` does not use `workspace = true`"; + continue; + } + + if (preg_match('/\A([A-Za-z0-9_-]+)\s*=/', $stripped, $m)) { + $errors[] = "{$relative}:" . ($idx + 1) . ": `{$m[1]}` does not use `workspace = true`"; + } + } + + return $errors; + } +} diff --git a/scripts/linters/src/Linters/ContiguousUseBlock.php b/scripts/linters/src/Linters/ContiguousUseBlock.php new file mode 100644 index 00000000..c73ea6f8 --- /dev/null +++ b/scripts/linters/src/Linters/ContiguousUseBlock.php @@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Linters; + +use Shirabe\Lint\Linter; +use Shirabe\Lint\Support\FileFinder; +use Shirabe\Lint\Support\Paths; + +final class ContiguousUseBlock implements Linter +{ + private const USE_START_RE = '/\A(?:pub(?:\([^)]*\))?\s+)?use\b/'; + + public function name(): string + { + return 'contiguous_use_block'; + } + + public function failureIntro(): string + { + return "Found blank lines splitting the leading `use` block into sections.\n" + . 'All `use` statements at the top of the file must be contiguous (no blank lines between them):'; + } + + public function check(string $rootDir, array $excludes): array + { + $errors = []; + + foreach (FileFinder::rustFiles($rootDir) as $path) { + $relative = Paths::relativeTo($rootDir, $path); + if (in_array($relative, $excludes, true)) { + continue; + } + + array_push($errors, ...$this->findSplitUseBlock($path, $relative)); + } + + return $errors; + } + + /** @return list<string> */ + private function findSplitUseBlock(string $path, string $relative): array + { + $lines = file($path); + $errors = []; + $count = count($lines); + + $i = $this->skipPreamble($lines); + if ($i === null) { + return []; + } + + while (true) { + $i = $this->consumeUseStatement($lines, $i); + if ($i >= $count) { + break; + } + + $blanks = []; + $j = $i; + while ($j < $count) { + $stripped = trim($lines[$j]); + if ($stripped === '') { + $blanks[] = $j; + $j++; + } elseif (str_starts_with($stripped, '//') || str_starts_with($stripped, '#[')) { + $j++; + } else { + break; + } + } + + if ($j < $count && preg_match(self::USE_START_RE, trim($lines[$j]))) { + foreach ($blanks as $bi) { + $errors[] = "{$relative}:" . ($bi + 1) . ': blank line splits the leading `use` block'; + } + $i = $j; + } else { + break; + } + } + + return $errors; + } + + /** @param list<string> $lines */ + private function skipPreamble(array $lines): ?int + { + foreach ($lines as $idx => $raw) { + $stripped = trim($raw); + if (preg_match(self::USE_START_RE, $stripped)) { + return $idx; + } + if ($stripped === '' || str_starts_with($stripped, '//') || str_starts_with($stripped, '#![') || str_starts_with($stripped, '#[')) { + continue; + } + + return null; + } + + return null; + } + + /** @param list<string> $lines */ + private function consumeUseStatement(array $lines, int $startIdx): int + { + $braceDepth = 0; + $i = $startIdx; + $count = count($lines); + + while ($i < $count) { + $line = $lines[$i]; + $braceDepth += substr_count($line, '{') - substr_count($line, '}'); + $done = $braceDepth <= 0 && str_ends_with(rtrim($line), ';'); + $i++; + if ($done) { + return $i; + } + } + + return $i; + } +} diff --git a/scripts/linters/src/Linters/NoBannedUse.php b/scripts/linters/src/Linters/NoBannedUse.php new file mode 100644 index 00000000..a6bda77e --- /dev/null +++ b/scripts/linters/src/Linters/NoBannedUse.php @@ -0,0 +1,170 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Linters; + +use Shirabe\Lint\Linter; +use Shirabe\Lint\Support\FileFinder; +use Shirabe\Lint\Support\Paths; + +final class NoBannedUse implements Linter +{ + private const BANNED_USE_PATHS = [ + 'anyhow::Result', + 'std::any::Any', + 'std::cell::RefCell', + 'std::io::Read', + 'std::io::Write', + 'std::process::Command', + 'std::rc::Rc', + ]; + + private const USE_START_RE = '/\A(?:pub(?:\([^)]*\))?\s+)?use\b/'; + + public function name(): string + { + return 'no_banned_use'; + } + + public function failureIntro(): string + { + return "Found banned `use` imports.\n" + . "These items must always be referenced by their fully-qualified path.\n" + . 'For imports to use trait methods, use `as _` (e.g., `use std::io::Write as _;`).'; + } + + public function check(string $rootDir, array $excludes): array + { + $errors = []; + + foreach (FileFinder::rustFiles($rootDir) as $path) { + $relative = Paths::relativeTo($rootDir, $path); + if (in_array($relative, $excludes, true)) { + continue; + } + + array_push($errors, ...$this->findBannedUses($path, $relative)); + } + + return $errors; + } + + /** @return list<string> */ + private function findBannedUses(string $path, string $relative): array + { + $errors = []; + $buffer = null; + $startIdx = null; + + foreach (file($path) as $idx => $raw) { + $code = explode('//', $raw, 2)[0]; + $stripped = trim($code); + + if ($buffer === null) { + if (!preg_match(self::USE_START_RE, $stripped)) { + continue; + } + $buffer = ''; + $startIdx = $idx; + } + + $buffer .= ' ' . $stripped; + if (!str_contains($buffer, ';')) { + continue; + } + + preg_match('/\buse\s+(.*?);/s', $buffer, $m); + $tree = $m[1] ?? null; + + foreach ($this->expandUseTree($tree) as $full) { + if (!in_array($full, self::BANNED_USE_PATHS, true)) { + continue; + } + $errors[] = "{$relative}:" . ($startIdx + 1) . ": `use {$full}` is banned (fully qualify as `{$full}` instead)"; + } + + $buffer = null; + } + + return array_values(array_unique($errors)); + } + + /** @return list<string> */ + private function expandUseTree(?string $tree): array + { + if ($tree === null) { + return []; + } + + $tree = trim($tree); + $brace = strpos($tree, '{'); + + if ($brace === false) { + $stripped = $this->stripUseAlias($tree); + + return $stripped === null || $stripped === '' ? [] : [$stripped]; + } + + $prefix = trim(preg_replace('/::\s*\z/', '', substr($tree, 0, $brace))); + $inner = preg_replace('/\}\s*\z/', '', substr($tree, $brace + 1)); + + $result = []; + foreach ($this->splitTopLevel($inner) as $child) { + foreach ($this->expandUseTree($child) as $sub) { + if ($sub === '' || $sub === 'self') { + $result[] = $prefix; + } elseif ($prefix === '') { + $result[] = $sub; + } else { + $result[] = "{$prefix}::{$sub}"; + } + } + } + + return $result; + } + + private function stripUseAlias(string $segment): ?string + { + if (preg_match('/\s+as\s+_\s*\z/', $segment)) { + return null; + } + + return trim(preg_replace('/\s+as\s+\S+\s*\z/', '', $segment)); + } + + /** @return list<string> */ + private function splitTopLevel(string $str): array + { + $parts = []; + $current = ''; + $depth = 0; + + foreach (str_split($str) as $ch) { + switch ($ch) { + case '{': + $depth++; + $current .= $ch; + break; + case '}': + $depth--; + $current .= $ch; + break; + case ',': + if ($depth === 0) { + $parts[] = $current; + $current = ''; + } else { + $current .= $ch; + } + break; + default: + $current .= $ch; + } + } + $parts[] = $current; + + return array_values(array_filter(array_map('trim', $parts), static fn (string $p): bool => $p !== '')); + } +} diff --git a/scripts/linters/src/Linters/NoDecorativeSectionComment.php b/scripts/linters/src/Linters/NoDecorativeSectionComment.php new file mode 100644 index 00000000..dba24063 --- /dev/null +++ b/scripts/linters/src/Linters/NoDecorativeSectionComment.php @@ -0,0 +1,65 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Linters; + +use Shirabe\Lint\Linter; +use Shirabe\Lint\Support\FileFinder; +use Shirabe\Lint\Support\Paths; + +final class NoDecorativeSectionComment implements Linter +{ + // 4+ consecutive ASCII `-`/`=`, or 4+ consecutive Unicode box-drawing characters (U+2500-U+257F). + private const DECORATIVE_RUN_RE = '/[-=]{4,}|[\x{2500}-\x{257F}]{4,}/u'; + + public function name(): string + { + return 'no_decorative_section_comment'; + } + + public function failureIntro(): string + { + return "Found decorative section comments (4+ consecutive `=`, `-`, or Unicode box-drawing characters).\n" + . 'These section dividers are unnecessarily noisy — remove them:'; + } + + public function check(string $rootDir, array $excludes): array + { + $errors = []; + + foreach (FileFinder::rustFiles($rootDir) as $path) { + $relative = Paths::relativeTo($rootDir, $path); + if (in_array($relative, $excludes, true)) { + continue; + } + + array_push($errors, ...$this->findDecorativeComments($path, $relative)); + } + + return $errors; + } + + /** @return list<string> */ + private function findDecorativeComments(string $path, string $relative): array + { + $errors = []; + + foreach (file($path) as $idx => $raw) { + $stripped = ltrim($raw); + if (!str_starts_with($stripped, '//')) { + continue; + } + if (str_starts_with($stripped, '///') || str_starts_with($stripped, '//!')) { + continue; + } + if (!preg_match(self::DECORATIVE_RUN_RE, $stripped)) { + continue; + } + + $errors[] = "{$relative}:" . ($idx + 1) . ': decorative section comment'; + } + + return $errors; + } +} diff --git a/scripts/linters/src/Linters/NoFormatTrailingComma.php b/scripts/linters/src/Linters/NoFormatTrailingComma.php new file mode 100644 index 00000000..dc364846 --- /dev/null +++ b/scripts/linters/src/Linters/NoFormatTrailingComma.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Linters; + +use Shirabe\Lint\Linter; +use Shirabe\Lint\Support\FileFinder; +use Shirabe\Lint\Support\Paths; + +final class NoFormatTrailingComma implements Linter +{ + public function name(): string + { + return 'no_format_trailing_comma'; + } + + public function failureIntro(): string + { + return 'Found `,)` introduced by formatting. Remove it.'; + } + + public function check(string $rootDir, array $excludes): array + { + $errors = []; + + foreach (FileFinder::rustFiles($rootDir) as $path) { + $relative = Paths::relativeTo($rootDir, $path); + if (in_array($relative, $excludes, true)) { + continue; + } + + foreach (file($path) as $idx => $raw) { + if (!str_contains($raw, ',)')) { + continue; + } + // `(,)` is a macro repetition fragment (e.g. `$(,)?`). + if (str_contains($raw, '(,)')) { + continue; + } + + $errors[] = "{$relative}:" . ($idx + 1) . ': trailing `,)` before a closing paren'; + } + } + + return $errors; + } +} diff --git a/scripts/linters/src/Linters/NoModRs.php b/scripts/linters/src/Linters/NoModRs.php new file mode 100644 index 00000000..248958e1 --- /dev/null +++ b/scripts/linters/src/Linters/NoModRs.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Linters; + +use Shirabe\Lint\Linter; +use Shirabe\Lint\Support\FileFinder; +use Shirabe\Lint\Support\Paths; + +final class NoModRs implements Linter +{ + public function name(): string + { + return 'no_mod_rs'; + } + + public function failureIntro(): string + { + return 'Found `mod.rs` file(s). Use `src/<submodule>.rs` instead of `<submodule>/mod.rs`:'; + } + + public function check(string $rootDir, array $excludes): array + { + $errors = []; + + foreach (FileFinder::modRsFiles($rootDir) as $path) { + $relative = Paths::relativeTo($rootDir, $path); + if (in_array($relative, $excludes, true)) { + continue; + } + + $errors[] = $relative; + } + + return $errors; + } +} diff --git a/scripts/linters/src/Linters/NoStdCollectionsMaps.php b/scripts/linters/src/Linters/NoStdCollectionsMaps.php new file mode 100644 index 00000000..c2f9c32e --- /dev/null +++ b/scripts/linters/src/Linters/NoStdCollectionsMaps.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Linters; + +use Shirabe\Lint\Linter; +use Shirabe\Lint\Support\FileFinder; +use Shirabe\Lint\Support\Paths; + +final class NoStdCollectionsMaps implements Linter +{ + private const BANNED_MAP_NAMES = ['HashMap', 'HashSet', 'BTreeMap', 'BTreeSet']; + + public function name(): string + { + return 'no_std_collections_maps'; + } + + public function failureIntro(): string + { + return "Found uses of `std::collections::{HashMap, HashSet, BTreeMap, BTreeSet}`.\n" + . 'Use `indexmap::IndexMap` / `indexmap::IndexSet` instead:'; + } + + public function check(string $rootDir, array $excludes): array + { + $errors = []; + + foreach (FileFinder::rustFiles($rootDir) as $path) { + $relative = Paths::relativeTo($rootDir, $path); + if (in_array($relative, $excludes, true)) { + continue; + } + + array_push($errors, ...$this->findStdMapUsages($path, $relative)); + } + + return $errors; + } + + /** @return list<string> */ + private function findStdMapUsages(string $path, string $relative): array + { + $errors = []; + + foreach (file($path) as $idx => $raw) { + $code = explode('//', $raw, 2)[0]; + + if (preg_match_all('/\bstd::collections::(HashMap|HashSet|BTreeMap|BTreeSet)\b/', $code, $m)) { + foreach ($m[1] as $name) { + $errors[] = "{$relative}:" . ($idx + 1) . ": use of `std::collections::{$name}` (use `indexmap::" . self::indexmapReplacement($name) . '` instead)'; + } + } + + if (preg_match_all('/\bstd::collections::\{([^}]*)\}/', $code, $m)) { + foreach ($m[1] as $group) { + foreach (explode(',', $group) as $entry) { + $name = preg_split('/\s+as\s+/', trim($entry))[0]; + if (!in_array($name, self::BANNED_MAP_NAMES, true)) { + continue; + } + $errors[] = "{$relative}:" . ($idx + 1) . ": import of `std::collections::{$name}` (use `indexmap::" . self::indexmapReplacement($name) . '` instead)'; + } + } + } + } + + return array_values(array_unique($errors)); + } + + private static function indexmapReplacement(string $name): string + { + return match ($name) { + 'HashMap', 'BTreeMap' => 'IndexMap', + 'HashSet', 'BTreeSet' => 'IndexSet', + default => throw new \LogicException("unexpected map name: {$name}"), + }; + } +} diff --git a/scripts/linters/src/Linters/NoUseAsAlias.php b/scripts/linters/src/Linters/NoUseAsAlias.php new file mode 100644 index 00000000..c725630c --- /dev/null +++ b/scripts/linters/src/Linters/NoUseAsAlias.php @@ -0,0 +1,85 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Linters; + +use Shirabe\Lint\Linter; +use Shirabe\Lint\Support\FileFinder; +use Shirabe\Lint\Support\Paths; + +final class NoUseAsAlias implements Linter +{ + private const USE_ALIAS_START_RE = '/\A(?:pub(?:\([^)]*\))?\s+)?use\b/'; + private const PASCAL_CASE_RE = '/\A[A-Z][A-Za-z0-9]*\z/'; + + public function name(): string + { + return 'no_use_as_alias'; + } + + public function failureIntro(): string + { + return "Found `use ... as name` aliases.\n" + . "Aliasing imports merely to shorten a namespace is forbidden.\n" + . "Only `as _` (e.g. `use std::io::Write as _;`) and PascalCase renames\n" + . '(for collision avoidance, e.g. `use foo::Error as FooError;`) are allowed:'; + } + + public function check(string $rootDir, array $excludes): array + { + $errors = []; + + foreach (FileFinder::rustFiles($rootDir) as $path) { + $relative = Paths::relativeTo($rootDir, $path); + if (in_array($relative, $excludes, true)) { + continue; + } + + array_push($errors, ...$this->findUseAliases($path, $relative)); + } + + return $errors; + } + + /** @return list<string> */ + private function findUseAliases(string $path, string $relative): array + { + $errors = []; + $inUse = false; + $braceDepth = 0; + + foreach (file($path) as $idx => $raw) { + $code = explode('//', $raw, 2)[0]; + $stripped = trim($code); + + if (!$inUse) { + if (!preg_match(self::USE_ALIAS_START_RE, $stripped)) { + continue; + } + $inUse = true; + $braceDepth = 0; + } + + if (preg_match_all('/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/', $code, $m)) { + foreach ($m[1] as $name) { + if ($name === '_') { + continue; + } + if (preg_match(self::PASCAL_CASE_RE, $name)) { + continue; + } + $errors[] = "{$relative}:" . ($idx + 1) . ": `as {$name}` aliasing in `use` statement"; + } + } + + $braceDepth += substr_count($code, '{') - substr_count($code, '}'); + if ($braceDepth <= 0 && str_ends_with(rtrim($code), ';')) { + $inUse = false; + $braceDepth = 0; + } + } + + return $errors; + } +} diff --git a/scripts/linters/src/Linters/SortedDependencies.php b/scripts/linters/src/Linters/SortedDependencies.php new file mode 100644 index 00000000..78ea0eb2 --- /dev/null +++ b/scripts/linters/src/Linters/SortedDependencies.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Linters; + +use Shirabe\Lint\Linter; +use Shirabe\Lint\Support\FileFinder; +use Shirabe\Lint\Support\Paths; + +final class SortedDependencies implements Linter +{ + public function name(): string + { + return 'sorted_dependencies'; + } + + public function failureIntro(): string + { + return "Found unsorted `[dependencies]` / `[dev-dependencies]` in Cargo.toml.\n" + . 'Entries must be alphabetical, with `shirabe-*` crates listed before others:'; + } + + public function check(string $rootDir, array $excludes): array + { + $errors = []; + + foreach (FileFinder::cargoTomls($rootDir) as $path) { + $relative = Paths::relativeTo($rootDir, $path); + if (in_array($relative, $excludes, true)) { + continue; + } + + $sections = $this->parseDepSections(file_get_contents($path)); + + foreach (['dependencies', 'dev-dependencies'] as $section) { + $deps = $sections[$section] ?? []; + if ($deps === []) { + continue; + } + + $expected = $this->sortDepNames($deps); + if ($deps === $expected) { + continue; + } + + $errors[] = "{$relative} [{$section}]\n" + . ' actual: ' . implode(', ', $deps) . "\n" + . ' expected: ' . implode(', ', $expected); + } + } + + return $errors; + } + + /** @return array<string, list<string>> */ + private function parseDepSections(string $content): array + { + $sections = []; + $current = null; + + foreach (explode("\n", $content) as $line) { + $stripped = rtrim($line, "\r\n"); + + if (preg_match('/\A\s*\[([^\]]+)\]\s*\z/', $stripped, $m)) { + $current = $m[1]; + $sections[$current] ??= []; + continue; + } + + if ($current !== null && preg_match('/\A([A-Za-z0-9_-]+)\s*[.=]/', $stripped, $m)) { + $sections[$current][] = $m[1]; + } + } + + return $sections; + } + + /** + * @param list<string> $deps + * @return list<string> + */ + private function sortDepNames(array $deps): array + { + $shirabe = []; + $other = []; + + foreach ($deps as $dep) { + if (str_starts_with($dep, 'shirabe-')) { + $shirabe[] = $dep; + } else { + $other[] = $dep; + } + } + sort($shirabe); + sort($other); + + return array_merge($shirabe, $other); + } +} diff --git a/scripts/linters/src/Runner.php b/scripts/linters/src/Runner.php new file mode 100644 index 00000000..ae2d84ed --- /dev/null +++ b/scripts/linters/src/Runner.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint; + +final class Runner +{ + /** @param list<array{0: Linter, 1: list<string>}> $linters */ + public function __construct( + private readonly string $rootDir, + private readonly array $linters, + ) { + } + + public function run(): bool + { + $allPassed = true; + + foreach ($this->linters as [$linter, $excludes]) { + echo "===== {$linter->name()} =====\n"; + + $errors = $linter->check($this->rootDir, $excludes); + if ($errors === []) { + echo "Passed.\n"; + } else { + echo $linter->failureIntro(), "\n"; + foreach ($errors as $error) { + echo " {$error}\n"; + } + $allPassed = false; + } + + echo "\n"; + } + + return $allPassed; + } +} diff --git a/scripts/linters/src/Support/FileFinder.php b/scripts/linters/src/Support/FileFinder.php new file mode 100644 index 00000000..50dabcd8 --- /dev/null +++ b/scripts/linters/src/Support/FileFinder.php @@ -0,0 +1,68 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Support; + +final class FileFinder +{ + // Returns absolute paths to each crate's Cargo.toml (crates/*/Cargo.toml), sorted. + public static function cargoTomls(string $rootDir): array + { + $paths = glob("{$rootDir}/crates/*/Cargo.toml") ?: []; + sort($paths); + + return $paths; + } + + /** @return list<string> absolute paths to *.rs files under crates/, sorted */ + public static function rustFiles(string $rootDir): array + { + return self::walk("{$rootDir}/crates", static fn (string $path): bool => str_ends_with($path, '.rs')); + } + + // Returns absolute paths to mod.rs files under each crate's src/ tree + // (crates/*/src/**/mod.rs), sorted. + public static function modRsFiles(string $rootDir): array + { + $found = []; + + foreach (glob("{$rootDir}/crates/*", GLOB_ONLYDIR) ?: [] as $crateDir) { + $srcDir = "{$crateDir}/src"; + if (!is_dir($srcDir)) { + continue; + } + + foreach (self::walk($srcDir, static fn (string $path): bool => basename($path) === 'mod.rs') as $path) { + $found[] = $path; + } + } + + sort($found); + + return $found; + } + + /** @return list<string> */ + private static function walk(string $baseDir, callable $predicate): array + { + if (!is_dir($baseDir)) { + return []; + } + + $found = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($baseDir, \FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if ($file->isFile() && $predicate($file->getPathname())) { + $found[] = $file->getPathname(); + } + } + + sort($found); + + return $found; + } +} diff --git a/scripts/linters/src/Support/Paths.php b/scripts/linters/src/Support/Paths.php new file mode 100644 index 00000000..2e500570 --- /dev/null +++ b/scripts/linters/src/Support/Paths.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Shirabe\Lint\Support; + +final class Paths +{ + public static function relativeTo(string $rootDir, string $path): string + { + $root = rtrim($rootDir, '/') . '/'; + + return str_starts_with($path, $root) ? substr($path, strlen($root)) : $path; + } +} |
