aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/linters/src/Linters
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-07-21 08:08:15 +0900
committernsfisis <nsfisis@gmail.com>2026-07-21 08:08:45 +0900
commitc899a4675ca90507a420d901ef7fa26d8b285f23 (patch)
tree405b8049c3ac59c65d56792c96f4a8c316bf605b /scripts/linters/src/Linters
parent6b16a2cd672edcbfeaf7988591db92ed4c594ddc (diff)
downloadphp-shirabe-c899a4675ca90507a420d901ef7fa26d8b285f23.tar.gz
php-shirabe-c899a4675ca90507a420d901ef7fa26d8b285f23.tar.zst
php-shirabe-c899a4675ca90507a420d901ef7fa26d8b285f23.zip
refactor(lint): rewrite structural linters from Ruby to PHP
Fold scripts/lint and scripts/linters/*.rb into a standalone Composer project under scripts/linters/, matching the scripts/plugin-class-classifier/ convention. Uses no external packages, only PHP + Composer autoloading. Verified byte-for-byte identical output against the original Ruby implementation, both on the current repo (all linters pass) and on a synthetic fixture exercising every violation type. Entry point moves from `scripts/lint` to `scripts/linters/lint`.
Diffstat (limited to 'scripts/linters/src/Linters')
-rw-r--r--scripts/linters/src/Linters/CargoWorkspaceDependencies.php83
-rw-r--r--scripts/linters/src/Linters/ContiguousUseBlock.php124
-rw-r--r--scripts/linters/src/Linters/NoBannedUse.php170
-rw-r--r--scripts/linters/src/Linters/NoDecorativeSectionComment.php65
-rw-r--r--scripts/linters/src/Linters/NoFormatTrailingComma.php48
-rw-r--r--scripts/linters/src/Linters/NoModRs.php38
-rw-r--r--scripts/linters/src/Linters/NoStdCollectionsMaps.php80
-rw-r--r--scripts/linters/src/Linters/NoUseAsAlias.php85
-rw-r--r--scripts/linters/src/Linters/SortedDependencies.php100
9 files changed, 793 insertions, 0 deletions
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);
+ }
+}