aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/linters
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-28 17:45:06 +0900
committernsfisis <nsfisis@gmail.com>2026-06-28 17:45:26 +0900
commit53f1fb395f33e0fb8db9aebd09ea9082f650f9f1 (patch)
treef9e8bf0e9d4b1e98cce383574fb6a13b684fff08 /scripts/linters
parent212f5cd75b1403ee75ffa44d7ebdb181174340c0 (diff)
downloadphp-shirabe-53f1fb395f33e0fb8db9aebd09ea9082f650f9f1.tar.gz
php-shirabe-53f1fb395f33e0fb8db9aebd09ea9082f650f9f1.tar.zst
php-shirabe-53f1fb395f33e0fb8db9aebd09ea9082f650f9f1.zip
refactor: add linter
Diffstat (limited to 'scripts/linters')
-rw-r--r--scripts/linters/cargo_workspace_dependencies.rb50
-rw-r--r--scripts/linters/contiguous_use_block.rb82
-rw-r--r--scripts/linters/no_decorative_section_comment.rb36
-rw-r--r--scripts/linters/no_format_trailing_comma.rb27
-rwxr-xr-xscripts/linters/no_mod_rs.rb17
-rw-r--r--scripts/linters/no_std_collections_maps.rb50
-rw-r--r--scripts/linters/no_use_as_alias.rb57
-rw-r--r--scripts/linters/sorted_dependencies.rb52
8 files changed, 371 insertions, 0 deletions
diff --git a/scripts/linters/cargo_workspace_dependencies.rb b/scripts/linters/cargo_workspace_dependencies.rb
new file mode 100644
index 0000000..6e4278f
--- /dev/null
+++ b/scripts/linters/cargo_workspace_dependencies.rb
@@ -0,0 +1,50 @@
+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/contiguous_use_block.rb b/scripts/linters/contiguous_use_block.rb
new file mode 100644
index 0000000..b535a03
--- /dev/null
+++ b/scripts/linters/contiguous_use_block.rb
@@ -0,0 +1,82 @@
+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/no_decorative_section_comment.rb b/scripts/linters/no_decorative_section_comment.rb
new file mode 100644
index 0000000..c9e30fe
--- /dev/null
+++ b/scripts/linters/no_decorative_section_comment.rb
@@ -0,0 +1,36 @@
+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
new file mode 100644
index 0000000..3e474a7
--- /dev/null
+++ b/scripts/linters/no_format_trailing_comma.rb
@@ -0,0 +1,27 @@
+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
new file mode 100755
index 0000000..c165637
--- /dev/null
+++ b/scripts/linters/no_mod_rs.rb
@@ -0,0 +1,17 @@
+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/<submodule>.rs` instead of `<submodule>/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
new file mode 100644
index 0000000..3701d75
--- /dev/null
+++ b/scripts/linters/no_std_collections_maps.rb
@@ -0,0 +1,50 @@
+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
new file mode 100644
index 0000000..8e0c86b
--- /dev/null
+++ b/scripts/linters/no_use_as_alias.rb
@@ -0,0 +1,57 @@
+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
new file mode 100644
index 0000000..f7ae816
--- /dev/null
+++ b/scripts/linters/sorted_dependencies.rb
@@ -0,0 +1,52 @@
+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