From c899a4675ca90507a420d901ef7fa26d8b285f23 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Tue, 21 Jul 2026 08:08:15 +0900 Subject: 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`. --- scripts/lint | 32 ---- scripts/linters/.gitignore | 1 + scripts/linters/cargo_workspace_dependencies.rb | 50 ------ scripts/linters/composer.json | 13 ++ scripts/linters/composer.lock | 20 +++ scripts/linters/contiguous_use_block.rb | 82 ---------- scripts/linters/lint | 38 +++++ scripts/linters/no_banned_use.rb | 124 --------------- scripts/linters/no_decorative_section_comment.rb | 36 ----- scripts/linters/no_format_trailing_comma.rb | 27 ---- scripts/linters/no_mod_rs.rb | 17 --- scripts/linters/no_std_collections_maps.rb | 50 ------ scripts/linters/no_use_as_alias.rb | 57 ------- scripts/linters/sorted_dependencies.rb | 52 ------- scripts/linters/src/Linter.php | 19 +++ .../src/Linters/CargoWorkspaceDependencies.php | 83 ++++++++++ scripts/linters/src/Linters/ContiguousUseBlock.php | 124 +++++++++++++++ scripts/linters/src/Linters/NoBannedUse.php | 170 +++++++++++++++++++++ .../src/Linters/NoDecorativeSectionComment.php | 65 ++++++++ .../linters/src/Linters/NoFormatTrailingComma.php | 48 ++++++ scripts/linters/src/Linters/NoModRs.php | 38 +++++ .../linters/src/Linters/NoStdCollectionsMaps.php | 80 ++++++++++ scripts/linters/src/Linters/NoUseAsAlias.php | 85 +++++++++++ scripts/linters/src/Linters/SortedDependencies.php | 100 ++++++++++++ scripts/linters/src/Runner.php | 39 +++++ scripts/linters/src/Support/FileFinder.php | 68 +++++++++ scripts/linters/src/Support/Paths.php | 15 ++ 27 files changed, 1006 insertions(+), 527 deletions(-) delete mode 100755 scripts/lint create mode 100644 scripts/linters/.gitignore delete mode 100644 scripts/linters/cargo_workspace_dependencies.rb create mode 100644 scripts/linters/composer.json create mode 100644 scripts/linters/composer.lock delete mode 100644 scripts/linters/contiguous_use_block.rb create mode 100755 scripts/linters/lint delete mode 100644 scripts/linters/no_banned_use.rb delete mode 100644 scripts/linters/no_decorative_section_comment.rb delete mode 100644 scripts/linters/no_format_trailing_comma.rb delete mode 100755 scripts/linters/no_mod_rs.rb delete mode 100644 scripts/linters/no_std_collections_maps.rb delete mode 100644 scripts/linters/no_use_as_alias.rb delete mode 100644 scripts/linters/sorted_dependencies.rb create mode 100644 scripts/linters/src/Linter.php create mode 100644 scripts/linters/src/Linters/CargoWorkspaceDependencies.php create mode 100644 scripts/linters/src/Linters/ContiguousUseBlock.php create mode 100644 scripts/linters/src/Linters/NoBannedUse.php create mode 100644 scripts/linters/src/Linters/NoDecorativeSectionComment.php create mode 100644 scripts/linters/src/Linters/NoFormatTrailingComma.php create mode 100644 scripts/linters/src/Linters/NoModRs.php create mode 100644 scripts/linters/src/Linters/NoStdCollectionsMaps.php create mode 100644 scripts/linters/src/Linters/NoUseAsAlias.php create mode 100644 scripts/linters/src/Linters/SortedDependencies.php create mode 100644 scripts/linters/src/Runner.php create mode 100644 scripts/linters/src/Support/FileFinder.php create mode 100644 scripts/linters/src/Support/Paths.php (limited to 'scripts') diff --git a/scripts/lint b/scripts/lint deleted file mode 100755 index c9e33049..00000000 --- a/scripts/lint +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env ruby - -require 'pathname' - -LINTERS = { - cargo_workspace_dependencies: [], - contiguous_use_block: [], - no_banned_use: [], - no_decorative_section_comment: %w[ - crates/shirabe-semver/src/version_parser.rs - ], - no_format_trailing_comma: %w[ - crates/shirabe/src/package/loader/root_package_loader.rs - crates/shirabe-spdx-licenses/src/spdx_licenses.rs - ], - no_mod_rs: [], - no_std_collections_maps: [], - no_use_as_alias: [], - sorted_dependencies: [], -} - -root_dir = Pathname.new(__dir__).join('..').expand_path - -results = LINTERS.map do |linter, excludes| - require_relative "linters/#{linter}" - puts "===== #{linter} =====" - ok = send(linter, root_dir, excludes) - puts "Passed." if ok - puts - ok -end -exit(results.all? ? 0 : 1) diff --git a/scripts/linters/.gitignore b/scripts/linters/.gitignore new file mode 100644 index 00000000..48b8bf90 --- /dev/null +++ b/scripts/linters/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/scripts/linters/cargo_workspace_dependencies.rb b/scripts/linters/cargo_workspace_dependencies.rb deleted file mode 100644 index 6e4278fb..00000000 --- a/scripts/linters/cargo_workspace_dependencies.rb +++ /dev/null @@ -1,50 +0,0 @@ -def cargo_workspace_dependencies(root_dir, excludes = []) - pattern = root_dir.join('crates', '*', 'Cargo.toml').to_s - errors = Dir.glob(pattern).sort.flat_map do |path| - relative = Pathname.new(path).relative_path_from(root_dir).to_s - next [] if excludes.include?(relative) - - find_non_workspace_deps(path, relative) - end - - return true if errors.empty? - - puts 'Found `[dependencies]` / `[dev-dependencies]` entries that do not use `workspace = true`.' - puts 'In a crate `Cargo.toml`, only `name.workspace = true` or `name = { workspace = true, ... }` is allowed:' - errors.each do |err| - puts " #{err}" - end - false -end - -def find_non_workspace_deps(path, relative) - errors = [] - current_section = nil - - File.read(path).each_line.with_index do |raw_line, idx| - stripped = raw_line.chomp.strip - - if stripped =~ /\A\[([^\]]+)\]\z/ - current_section = $1 - next - end - - next unless %w[dependencies dev-dependencies build-dependencies].include?(current_section) - next if stripped.empty? || stripped.start_with?('#') - - if stripped =~ /\A([A-Za-z0-9_-]+)\.workspace\s*=\s*true\b/ - next - elsif stripped =~ /\A([A-Za-z0-9_-]+)\s*=\s*\{(.+)\}\s*\z/ - name = $1 - inner = $2 - next if inner =~ /\bworkspace\s*=\s*true\b/ - - errors << "#{relative}:#{idx + 1}: `#{name}` does not use `workspace = true`" - elsif stripped =~ /\A([A-Za-z0-9_-]+)\s*=/ - name = $1 - errors << "#{relative}:#{idx + 1}: `#{name}` does not use `workspace = true`" - end - end - - errors -end diff --git a/scripts/linters/composer.json b/scripts/linters/composer.json new file mode 100644 index 00000000..fddb624d --- /dev/null +++ b/scripts/linters/composer.json @@ -0,0 +1,13 @@ +{ + "name": "shirabe/lint", + "description": "Structural linters enforcing this repository's Rust source conventions", + "license": "MIT", + "require": { + "php": ">=8.1" + }, + "autoload": { + "psr-4": { + "Shirabe\\Lint\\": "src/" + } + } +} diff --git a/scripts/linters/composer.lock b/scripts/linters/composer.lock new file mode 100644 index 00000000..dcee9166 --- /dev/null +++ b/scripts/linters/composer.lock @@ -0,0 +1,20 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "f113cbf87cb1a110c764ffabac1d9167", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/scripts/linters/contiguous_use_block.rb b/scripts/linters/contiguous_use_block.rb deleted file mode 100644 index b535a038..00000000 --- a/scripts/linters/contiguous_use_block.rb +++ /dev/null @@ -1,82 +0,0 @@ -def contiguous_use_block(root_dir, excludes = []) - pattern = root_dir.join('crates', '**', '*.rs').to_s - errors = Dir.glob(pattern).sort.flat_map do |path| - relative = Pathname.new(path).relative_path_from(root_dir).to_s - next [] if excludes.include?(relative) - - find_split_use_block(path, relative) - end - - return true if errors.empty? - - puts 'Found blank lines splitting the leading `use` block into sections.' - puts 'All `use` statements at the top of the file must be contiguous (no blank lines between them):' - errors.each do |err| - puts " #{err}" - end - false -end - -USE_START_RE = /\A(?:pub(?:\([^)]*\))?\s+)?use\b/ - -def find_split_use_block(path, relative) - lines = File.readlines(path) - errors = [] - - i = skip_preamble(lines) - return [] if i.nil? - - loop do - i = consume_use_statement(lines, i) - break if i >= lines.length - - blanks = [] - j = i - while j < lines.length - stripped = lines[j].strip - if stripped.empty? - blanks << j - j += 1 - elsif stripped.start_with?('//') || stripped.start_with?('#[') - j += 1 - else - break - end - end - - if j < lines.length && lines[j].strip =~ USE_START_RE - blanks.each do |bi| - errors << "#{relative}:#{bi + 1}: blank line splits the leading `use` block" - end - i = j - else - break - end - end - - errors -end - -def skip_preamble(lines) - lines.each_with_index do |raw, idx| - stripped = raw.strip - return idx if stripped =~ USE_START_RE - next if stripped.empty? || stripped.start_with?('//') || stripped.start_with?('#![') || stripped.start_with?('#[') - - return nil - end - nil -end - -def consume_use_statement(lines, start_idx) - brace_depth = 0 - i = start_idx - while i < lines.length - line = lines[i] - brace_depth += line.count('{') - line.count('}') - done = brace_depth <= 0 && line.rstrip.end_with?(';') - i += 1 - return i if done - end - i -end diff --git a/scripts/linters/lint b/scripts/linters/lint new file mode 100755 index 00000000..bcdf1c67 --- /dev/null +++ b/scripts/linters/lint @@ -0,0 +1,38 @@ +#!/usr/bin/env php +run() ? 0 : 1); diff --git a/scripts/linters/no_banned_use.rb b/scripts/linters/no_banned_use.rb deleted file mode 100644 index 74e50d06..00000000 --- a/scripts/linters/no_banned_use.rb +++ /dev/null @@ -1,124 +0,0 @@ -BANNED_USE_PATHS = %w[ - anyhow::Result - std::any::Any - std::cell::RefCell - std::io::Read - std::io::Write - std::process::Command - std::rc::Rc -].freeze - -def no_banned_use(root_dir, excludes = []) - pattern = root_dir.join('crates', '**', '*.rs').to_s - errors = Dir.glob(pattern).sort.flat_map do |path| - relative = Pathname.new(path).relative_path_from(root_dir).to_s - next [] if excludes.include?(relative) - - find_banned_uses(path, relative) - end - - return true if errors.empty? - - puts 'Found banned `use` imports.' - puts 'These items must always be referenced by their fully-qualified path.' - puts 'For imports to use trait methods, use `as _` (e.g., `use std::io::Write as _;`).' - errors.each do |err| - puts " #{err}" - end - false -end - -BANNED_USE_START_RE = /\A(?:pub(?:\([^)]*\))?\s+)?use\b/ - -def find_banned_uses(path, relative) - errors = [] - lines = File.readlines(path) - buffer = nil - start_idx = nil - - lines.each_with_index do |raw, idx| - code = raw.split('//', 2).first || raw - stripped = code.strip - - if buffer.nil? - next unless stripped =~ BANNED_USE_START_RE - - buffer = +'' - start_idx = idx - end - - buffer << ' ' << stripped - next unless buffer.include?(';') - - tree = buffer[/\buse\s+(.*?);/m, 1] - expand_use_tree(tree).each do |full| - next unless BANNED_USE_PATHS.include?(full) - - errors << "#{relative}:#{start_idx + 1}: `use #{full}` is banned (fully qualify as `#{full}` instead)" - end - - buffer = nil - end - - errors.uniq -end - -def expand_use_tree(tree) - return [] if tree.nil? - - tree = tree.strip - brace = tree.index('{') - - if brace.nil? - stripped = strip_use_alias(tree) - return [] if stripped.nil? - - return [stripped].reject(&:empty?) - end - - prefix = tree[0...brace].sub(/::\s*\z/, '').strip - inner = tree[(brace + 1)..].sub(/\}\s*\z/, '') - - split_top_level(inner).flat_map do |child| - expand_use_tree(child).map do |sub| - if sub.empty? || sub == 'self' - prefix - elsif prefix.empty? - sub - else - "#{prefix}::#{sub}" - end - end - end -end - -def strip_use_alias(segment) - return nil if segment =~ /\s+as\s+_\s*\z/ - - segment.sub(/\s+as\s+\S+\s*\z/, '').strip -end - -def split_top_level(str) - parts = [] - current = +'' - depth = 0 - - str.each_char do |ch| - case ch - when '{' then depth += 1; current << ch - when '}' then depth -= 1; current << ch - when ',' - if depth.zero? - parts << current - current = +'' - else - current << ch - end - else - current << ch - end - end - parts << current - - parts.map(&:strip).reject(&:empty?) -end diff --git a/scripts/linters/no_decorative_section_comment.rb b/scripts/linters/no_decorative_section_comment.rb deleted file mode 100644 index c9e30fe1..00000000 --- a/scripts/linters/no_decorative_section_comment.rb +++ /dev/null @@ -1,36 +0,0 @@ -def no_decorative_section_comment(root_dir, excludes = []) - pattern = root_dir.join('crates', '**', '*.rs').to_s - errors = Dir.glob(pattern).sort.flat_map do |path| - relative = Pathname.new(path).relative_path_from(root_dir).to_s - next [] if excludes.include?(relative) - - find_decorative_comments(path, relative) - end - - return true if errors.empty? - - puts 'Found decorative section comments (4+ consecutive `=`, `-`, or Unicode box-drawing characters).' - puts 'These section dividers are unnecessarily noisy — remove them:' - errors.each do |err| - puts " #{err}" - end - false -end - -DECORATIVE_RUN_RE = /[-=]{4,}|[─-╿]{4,}/ - -def find_decorative_comments(path, relative) - errors = [] - - File.readlines(path).each_with_index do |raw, idx| - stripped = raw.lstrip - next unless stripped.start_with?('//') - next if stripped.start_with?('///') || stripped.start_with?('//!') - - next unless stripped.match?(DECORATIVE_RUN_RE) - - errors << "#{relative}:#{idx + 1}: decorative section comment" - end - - errors -end diff --git a/scripts/linters/no_format_trailing_comma.rb b/scripts/linters/no_format_trailing_comma.rb deleted file mode 100644 index 3e474a76..00000000 --- a/scripts/linters/no_format_trailing_comma.rb +++ /dev/null @@ -1,27 +0,0 @@ -def no_format_trailing_comma(root_dir, excludes = []) - crates_dir = root_dir.join('crates') - raw = `grep -rn --include='*.rs' -e ',)' #{crates_dir}` - - errors = [] - raw.each_line do |line| - line = line.chomp - path, lineno, content = line.split(':', 3) - next if content.nil? - - relative = Pathname.new(path).relative_path_from(root_dir).to_s - next if excludes.include?(relative) - - # `(,)` is a macro repetition fragment (e.g. `$(,)?`). - next if content.match?(/\(,\)/) - - errors << "#{relative}:#{lineno}: trailing `,)` before a closing paren" - end - - return true if errors.empty? - - puts 'Found `,)` introduced by formatting. Remove it.' - errors.each do |err| - puts " #{err}" - end - false -end diff --git a/scripts/linters/no_mod_rs.rb b/scripts/linters/no_mod_rs.rb deleted file mode 100755 index c1656378..00000000 --- a/scripts/linters/no_mod_rs.rb +++ /dev/null @@ -1,17 +0,0 @@ -def no_mod_rs(root_dir, excludes = []) - pattern = root_dir.join('crates', '*', 'src', '**', 'mod.rs').to_s - errors = Dir.glob(pattern).sort.filter_map do |path| - relative = Pathname.new(path).relative_path_from(root_dir).to_s - next if excludes.include?(relative) - - relative - end - - return true if errors.empty? - - puts 'Found `mod.rs` file(s). Use `src/.rs` instead of `/mod.rs`:' - errors.each do |path| - puts " #{path}" - end - false -end diff --git a/scripts/linters/no_std_collections_maps.rb b/scripts/linters/no_std_collections_maps.rb deleted file mode 100644 index 3701d753..00000000 --- a/scripts/linters/no_std_collections_maps.rb +++ /dev/null @@ -1,50 +0,0 @@ -def no_std_collections_maps(root_dir, excludes = []) - pattern = root_dir.join('crates', '**', '*.rs').to_s - errors = Dir.glob(pattern).sort.flat_map do |path| - relative = Pathname.new(path).relative_path_from(root_dir).to_s - next [] if excludes.include?(relative) - - find_std_map_usages(path, relative) - end - - return true if errors.empty? - - puts 'Found uses of `std::collections::{HashMap, HashSet, BTreeMap, BTreeSet}`.' - puts 'Use `indexmap::IndexMap` / `indexmap::IndexSet` instead:' - errors.each do |err| - puts " #{err}" - end - false -end - -BANNED_MAP_NAMES = %w[HashMap HashSet BTreeMap BTreeSet].freeze - -def find_std_map_usages(path, relative) - errors = [] - - File.readlines(path).each_with_index do |raw, idx| - code = raw.split('//', 2).first || raw - - code.scan(/\bstd::collections::(HashMap|HashSet|BTreeMap|BTreeSet)\b/) do |m| - errors << "#{relative}:#{idx + 1}: use of `std::collections::#{m[0]}` (use `indexmap::#{indexmap_replacement(m[0])}` instead)" - end - - code.scan(/\bstd::collections::\{([^}]*)\}/) do |m| - m[0].split(',').each do |entry| - name = entry.strip.split(/\s+as\s+/).first - next unless BANNED_MAP_NAMES.include?(name) - - errors << "#{relative}:#{idx + 1}: import of `std::collections::#{name}` (use `indexmap::#{indexmap_replacement(name)}` instead)" - end - end - end - - errors.uniq -end - -def indexmap_replacement(name) - case name - when 'HashMap', 'BTreeMap' then 'IndexMap' - when 'HashSet', 'BTreeSet' then 'IndexSet' - end -end diff --git a/scripts/linters/no_use_as_alias.rb b/scripts/linters/no_use_as_alias.rb deleted file mode 100644 index 8e0c86b3..00000000 --- a/scripts/linters/no_use_as_alias.rb +++ /dev/null @@ -1,57 +0,0 @@ -def no_use_as_alias(root_dir, excludes = []) - pattern = root_dir.join('crates', '**', '*.rs').to_s - errors = Dir.glob(pattern).sort.flat_map do |path| - relative = Pathname.new(path).relative_path_from(root_dir).to_s - next [] if excludes.include?(relative) - - find_use_aliases(path, relative) - end - - return true if errors.empty? - - puts 'Found `use ... as name` aliases.' - puts 'Aliasing imports merely to shorten a namespace is forbidden.' - puts 'Only `as _` (e.g. `use std::io::Write as _;`) and PascalCase renames' - puts '(for collision avoidance, e.g. `use foo::Error as FooError;`) are allowed:' - errors.each do |err| - puts " #{err}" - end - false -end - -USE_ALIAS_START_RE = /\A(?:pub(?:\([^)]*\))?\s+)?use\b/ -PASCAL_CASE_RE = /\A[A-Z][A-Za-z0-9]*\z/ - -def find_use_aliases(path, relative) - errors = [] - in_use = false - brace_depth = 0 - - File.readlines(path).each_with_index do |raw, idx| - code = raw.split('//', 2).first || raw - stripped = code.strip - - unless in_use - next unless stripped =~ USE_ALIAS_START_RE - - in_use = true - brace_depth = 0 - end - - code.scan(/\bas\s+([A-Za-z_][A-Za-z0-9_]*)/) do |m| - name = m[0] - next if name == '_' - next if name =~ PASCAL_CASE_RE - - errors << "#{relative}:#{idx + 1}: `as #{name}` aliasing in `use` statement" - end - - brace_depth += code.count('{') - code.count('}') - if brace_depth <= 0 && code.rstrip.end_with?(';') - in_use = false - brace_depth = 0 - end - end - - errors -end diff --git a/scripts/linters/sorted_dependencies.rb b/scripts/linters/sorted_dependencies.rb deleted file mode 100644 index f7ae816e..00000000 --- a/scripts/linters/sorted_dependencies.rb +++ /dev/null @@ -1,52 +0,0 @@ -def sorted_dependencies(root_dir, excludes = []) - pattern = root_dir.join('crates', '*', 'Cargo.toml').to_s - errors = Dir.glob(pattern).sort.flat_map do |path| - relative = Pathname.new(path).relative_path_from(root_dir).to_s - next [] if excludes.include?(relative) - - sections = parse_dep_sections(File.read(path)) - - %w[dependencies dev-dependencies].filter_map do |section| - deps = sections[section] - next if deps.nil? || deps.empty? - - expected = sort_dep_names(deps) - next if deps == expected - - { path: relative, section: section, actual: deps, expected: expected } - end - end - - return true if errors.empty? - - puts 'Found unsorted `[dependencies]` / `[dev-dependencies]` in Cargo.toml.' - puts 'Entries must be alphabetical, with `shirabe-*` crates listed before others:' - errors.each do |err| - puts " #{err[:path]} [#{err[:section]}]" - puts " actual: #{err[:actual].join(', ')}" - puts " expected: #{err[:expected].join(', ')}" - end - false -end - -def parse_dep_sections(content) - sections = {} - current = nil - - content.each_line do |line| - stripped = line.chomp - if stripped =~ /\A\s*\[([^\]]+)\]\s*\z/ - current = $1 - sections[current] ||= [] - elsif current && stripped =~ /\A([A-Za-z0-9_-]+)\s*[.=]/ - sections[current] << $1 - end - end - - sections -end - -def sort_dep_names(deps) - shirabe, other = deps.partition { |d| d.start_with?('shirabe-') } - shirabe.sort + other.sort -end 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 @@ + $excludes root-relative paths to skip + * @return list 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 @@ +findNonWorkspaceDeps($path, $relative)); + } + + return $errors; + } + + /** @return list */ + 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 @@ +findSplitUseBlock($path, $relative)); + } + + return $errors; + } + + /** @return list */ + 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 $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 $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 @@ +findBannedUses($path, $relative)); + } + + return $errors; + } + + /** @return list */ + 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 */ + 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 */ + 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 @@ +findDecorativeComments($path, $relative)); + } + + return $errors; + } + + /** @return list */ + 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 @@ + $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 @@ +.rs` instead of `/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 @@ +findStdMapUsages($path, $relative)); + } + + return $errors; + } + + /** @return list */ + 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 @@ +findUseAliases($path, $relative)); + } + + return $errors; + } + + /** @return list */ + 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 @@ +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> */ + 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 $deps + * @return list + */ + 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 @@ +}> $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 @@ + 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 */ + 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 @@ +