misogi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/misogi/cli.rb ADDED
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Misogi
6
+ # コマンドラインインターフェース
7
+ class CLI
8
+ attr_reader :options
9
+
10
+ def initialize(argv = ARGV)
11
+ @argv = argv
12
+ @options = {
13
+ rules: nil, # nilの場合は設定ファイルを使用
14
+ base_path: nil,
15
+ pattern: nil,
16
+ config_path: ".misogi.yml"
17
+ }
18
+ end
19
+
20
+ # CLIを実行
21
+ # @return [Integer] 終了コード
22
+ def run
23
+ parse_options
24
+
25
+ # Railsルールが必要な場合はRails環境を読み込む
26
+ load_rails_environment_if_needed
27
+
28
+ files = collect_files
29
+ if files.empty?
30
+ warn "検証対象のファイルが見つかりませんでした"
31
+ return 1
32
+ end
33
+
34
+ # CLIオプションでルールが明示的に指定されている場合は従来通りの動作
35
+ violations = if @options[:rules]
36
+ validate_with_cli_rules(files)
37
+ else
38
+ validate_with_config(files)
39
+ end
40
+
41
+ display_violations(violations)
42
+
43
+ violations.empty? ? 0 : 1
44
+ end
45
+
46
+ private
47
+
48
+ # Railsルールが使用される場合にRails環境を読み込む
49
+ def load_rails_environment_if_needed
50
+ needs_rails = if @options[:rules]
51
+ @options[:rules].include?(:rails)
52
+ else
53
+ # 設定ファイルをチェック
54
+ config = load_configuration
55
+ config.rules.key?(:rails) || config.rules.key?("rails")
56
+ end
57
+
58
+ return unless needs_rails
59
+
60
+ load_rails_environment
61
+ end
62
+
63
+ # Rails環境を読み込む
64
+ def load_rails_environment
65
+ # config/boot.rbが存在するかチェック
66
+ boot_file = "config/boot.rb"
67
+ environment_file = "config/environment.rb"
68
+
69
+ unless File.exist?(boot_file)
70
+ warn "警告: #{boot_file} が見つかりません。Railsプロジェクトのルートディレクトリで実行してください。"
71
+ return
72
+ end
73
+
74
+ unless File.exist?(environment_file)
75
+ warn "警告: #{environment_file} が見つかりません。Railsプロジェクトのルートディレクトリで実行してください。"
76
+ return
77
+ end
78
+
79
+ # Rails環境を読み込む
80
+ require File.expand_path(boot_file)
81
+ require File.expand_path(environment_file)
82
+
83
+ # Rails アプリケーションを初期化(既に初期化されていない場合)
84
+ ::Rails.application.eager_load! if defined?(::Rails)
85
+ rescue StandardError => e
86
+ warn "警告: Rails環境の読み込みに失敗しました: #{e.message}"
87
+ end
88
+
89
+ # オプションをパース
90
+ def parse_options
91
+ parser = OptionParser.new do |opts|
92
+ opts.banner = "使い方: misogi [オプション] [ファイル...]"
93
+
94
+ opts.on("-r", "--rules RULES", Array, "使用するルール (ruby_standard,rails,rspec)") do |rules|
95
+ @options[:rules] = rules.map(&:to_sym)
96
+ end
97
+
98
+ opts.on("-b", "--base-path PATH", "ベースパス (デフォルト: lib)") do |path|
99
+ @options[:base_path] = path
100
+ end
101
+
102
+ opts.on("-p", "--pattern PATTERN", "検証するファイルパターン") do |pattern|
103
+ @options[:pattern] = pattern
104
+ end
105
+
106
+ opts.on("-c", "--config PATH", "設定ファイルのパス (デフォルト: .misogi.yml)") do |path|
107
+ @options[:config_path] = path
108
+ end
109
+
110
+ opts.on("-h", "--help", "ヘルプを表示") do
111
+ puts opts
112
+ exit 0
113
+ end
114
+
115
+ opts.on("-v", "--version", "バージョンを表示") do
116
+ puts "Misogi #{Misogi::VERSION}"
117
+ exit 0
118
+ end
119
+ end
120
+
121
+ parser.parse!(@argv)
122
+ rescue OptionParser::InvalidOption => e
123
+ warn "エラー: #{e.message}"
124
+ warn parser.help
125
+ exit 1
126
+ end
127
+
128
+ # 検証対象のファイルを収集
129
+ # @return [Array<String>] ファイルパスのリスト
130
+ def collect_files
131
+ if @argv.any?
132
+ # コマンドライン引数で指定されたファイル
133
+ @argv
134
+ elsif @options[:pattern]
135
+ # パターンで指定されたファイル
136
+ Dir.glob(@options[:pattern])
137
+ else
138
+ # デフォルト: lib, app, spec配下のRubyファイル
139
+ default_pattern = "{lib,app,spec}/**/*.rb"
140
+ Dir.glob(default_pattern)
141
+ end
142
+ end
143
+
144
+ # CLIルールで検証
145
+ # @param files [Array<String>] ファイルパスのリスト
146
+ # @return [Array<Violation>] 違反のリスト
147
+ def validate_with_cli_rules(files)
148
+ rules = @options[:rules].map do |rule_name|
149
+ create_rule(rule_name)
150
+ end.compact
151
+
152
+ validator = Validator.new(rules: rules)
153
+ validator.validate_files(files)
154
+ end
155
+
156
+ # 設定ファイルベースで検証
157
+ # @param files [Array<String>] ファイルパスのリスト
158
+ # @return [Array<Violation>] 違反のリスト
159
+ def validate_with_config(files)
160
+ config = load_configuration
161
+
162
+ # 除外パターンを適用
163
+ files = files.reject { |file| config.excluded?(file) }
164
+
165
+ # ファイルごとに適用するルールを決定して検証
166
+ files.flat_map do |file|
167
+ applicable_rules_config = config.rules_for(file)
168
+ next [] if applicable_rules_config.empty?
169
+
170
+ rules = applicable_rules_config.map do |rule_name, rule_config|
171
+ create_rule_from_config(rule_name, rule_config[:config])
172
+ end.compact
173
+
174
+ validator = Validator.new(rules: rules)
175
+ validator.validate_file(file)
176
+ end
177
+ end
178
+
179
+ # 設定ファイルをロード
180
+ # @return [Configuration]
181
+ def load_configuration
182
+ config = if File.exist?(@options[:config_path])
183
+ Configuration.new(config_path: @options[:config_path])
184
+ else
185
+ Configuration.default
186
+ end
187
+
188
+ # CLIオプションでbase_pathが指定されている場合は設定を上書き
189
+ if @options[:base_path] && config.rules.key?(:ruby_standard)
190
+ config.rules[:ruby_standard][:config]["base_path"] = @options[:base_path]
191
+ end
192
+
193
+ config
194
+ end
195
+
196
+ # ルールを作成(CLIオプションから)
197
+ # @param rule_name [Symbol] ルール名
198
+ # @return [Rule::Base, nil] ルールインスタンス
199
+ def create_rule(rule_name)
200
+ case rule_name
201
+ when :ruby_standard
202
+ Rule::RubyStandard.new(base_path: @options[:base_path] || "lib")
203
+ when :rails
204
+ Rule::Rails.new
205
+ when :rspec
206
+ Rule::RSpec.new
207
+ else
208
+ warn "警告: 不明なルール '#{rule_name}' がスキップされました"
209
+ nil
210
+ end
211
+ end
212
+
213
+ # ルールを作成(設定ファイルから)
214
+ # @param rule_name [String, Symbol] ルール名
215
+ # @param config [Hash] ルール設定
216
+ # @return [Rule::Base, nil] ルールインスタンス
217
+ def create_rule_from_config(rule_name, config)
218
+ rule_name_sym = rule_name.to_sym
219
+
220
+ case rule_name_sym
221
+ when :ruby_standard
222
+ base_path = config["base_path"] || config[:base_path] || "lib"
223
+ Rule::RubyStandard.new(base_path: base_path)
224
+ when :rails
225
+ inflections_path = config["inflections_path"] || config[:inflections_path]
226
+ Rule::Rails.new(inflections_path: inflections_path)
227
+ when :rspec
228
+ Rule::RSpec.new
229
+ else
230
+ warn "警告: 不明なルール '#{rule_name}' がスキップされました"
231
+ nil
232
+ end
233
+ end
234
+
235
+ # 違反を表示
236
+ # @param violations [Array<Violation>] 違反のリスト
237
+ def display_violations(violations)
238
+ if violations.empty?
239
+ puts "✓ 違反は見つかりませんでした"
240
+ else
241
+ puts "✗ #{violations.size}件の違反が見つかりました:\n\n"
242
+ violations.each do |violation|
243
+ puts violation
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Misogi
6
+ # 設定を管理するクラス
7
+ class Configuration
8
+ DEFAULT_CONFIG = {
9
+ "rules" => {
10
+ "ruby_standard" => {
11
+ "patterns" => ["lib/**/*.rb"],
12
+ "base_path" => "lib"
13
+ }
14
+ },
15
+ "exclude" => []
16
+ }.freeze
17
+
18
+ attr_reader :rules, :exclude
19
+
20
+ # @param config_path [String, nil] 設定ファイルのパス
21
+ def initialize(config_path: ".misogi.yml")
22
+ @config_path = config_path
23
+ @config = load_config
24
+ @rules = parse_rules(@config["rules"] || {})
25
+ @exclude = @config["exclude"] || []
26
+ end
27
+
28
+ # ファイルが除外対象かどうかをチェック
29
+ # @param file_path [String] ファイルパス
30
+ # @return [Boolean]
31
+ def excluded?(file_path)
32
+ @exclude.any? { |pattern| File.fnmatch?(pattern, file_path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
33
+ end
34
+
35
+ # 指定されたファイルに適用すべきルールを取得
36
+ # @param file_path [String] ファイルパス
37
+ # @return [Hash] ルール名とその設定のハッシュ
38
+ def rules_for(file_path)
39
+ @rules.select do |_rule_name, rule_config|
40
+ rule_config[:patterns].any? do |pattern|
41
+ File.fnmatch?(pattern, file_path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
42
+ end
43
+ end
44
+ end
45
+
46
+ # デフォルト設定を使用
47
+ # @return [Configuration]
48
+ def self.default
49
+ config = new(config_path: nil)
50
+ config.instance_variable_set(:@config, DEFAULT_CONFIG)
51
+ config.instance_variable_set(:@rules, parse_rules(DEFAULT_CONFIG["rules"]))
52
+ config.instance_variable_set(:@exclude, DEFAULT_CONFIG["exclude"])
53
+ config
54
+ end
55
+
56
+ # ルール設定をパース
57
+ # @param rules_config [Hash] ルール設定
58
+ # @return [Hash] パースされたルール設定
59
+ def self.parse_rules(rules_config)
60
+ rules_config.transform_keys(&:to_sym).transform_values do |config|
61
+ {
62
+ patterns: Array(config["patterns"] || config[:patterns]),
63
+ config: config.except("patterns", :patterns)
64
+ }
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # 設定ファイルを読み込む
71
+ # @return [Hash]
72
+ def load_config
73
+ if @config_path && File.exist?(@config_path)
74
+ YAML.load_file(@config_path) || {}
75
+ else
76
+ DEFAULT_CONFIG.dup
77
+ end
78
+ rescue Psych::SyntaxError => e
79
+ warn "警告: 設定ファイルの読み込みに失敗しました: #{e.message}"
80
+ warn "デフォルト設定を使用します"
81
+ DEFAULT_CONFIG.dup
82
+ end
83
+
84
+ def parse_rules(rules_config)
85
+ self.class.parse_rules(rules_config)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Misogi
4
+ # ファイルのパース結果を保持するクラス
5
+ class ParsedContent
6
+ attr_reader :namespaces
7
+
8
+ # @param namespaces [Array<String>] ファイル内で定義されている名前空間のリスト
9
+ # 例: ["Misogi::Rule::Base"] は Misogi::Rule::Base クラスが定義されていることを示す
10
+ def initialize(namespaces: [])
11
+ @namespaces = namespaces
12
+ end
13
+
14
+ # 指定された名前空間が定義されているかチェック
15
+ # @param namespace [String] チェックする名前空間
16
+ # @return [Boolean]
17
+ def include?(namespace)
18
+ namespaces.include?(namespace)
19
+ end
20
+
21
+ # パース結果が空かどうか
22
+ # @return [Boolean]
23
+ def empty?
24
+ namespaces.empty?
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Misogi
4
+ module Parser
5
+ # すべてのパーサーの基底クラス
6
+ class Base
7
+ # ファイル内容をパースする
8
+ # @param content [String] ファイルの内容
9
+ # @return [ParsedContent] パース結果
10
+ def parse(content)
11
+ raise NotImplementedError, "#{self.class}#parseを実装してください"
12
+ end
13
+
14
+ # ファイルをパースできるか判定する
15
+ # @param file_path [String] ファイルパス
16
+ # @return [Boolean]
17
+ def parsable?(file_path)
18
+ raise NotImplementedError, "#{self.class}#parsable?を実装してください"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module Misogi
6
+ module Parser
7
+ # Rubyファイルのパーサー
8
+ # Ripperを使用してRubyコードを解析し、定義されているクラス/モジュールを抽出する
9
+ class Ruby < Base
10
+ # @param file_path [String] ファイルパス
11
+ # @return [Boolean] .rbファイルかどうか
12
+ def parsable?(file_path)
13
+ File.extname(file_path) == ".rb"
14
+ end
15
+
16
+ # @param content [String] Rubyファイルの内容
17
+ # @return [ParsedContent] パース結果
18
+ def parse(content)
19
+ namespaces = extract_namespaces(content)
20
+ ParsedContent.new(namespaces: namespaces)
21
+ end
22
+
23
+ private
24
+
25
+ # Rubyコードから名前空間を抽出する
26
+ # @param content [String] Rubyコードの内容
27
+ # @return [Array<String>] 名前空間のリスト
28
+ def extract_namespaces(content)
29
+ sexp = Ripper.sexp(content)
30
+ return [] unless sexp
31
+
32
+ namespaces = []
33
+ traverse_sexp(sexp, [], namespaces)
34
+ namespaces
35
+ end
36
+
37
+ # S式を再帰的に走査して、クラス/モジュール定義を見つける
38
+ # @param node [Array] S式のノード
39
+ # @param current_namespace [Array<String>] 現在の名前空間のスタック
40
+ # @param namespaces [Array<String>] 見つかった名前空間を格納する配列
41
+ def traverse_sexp(node, current_namespace, namespaces)
42
+ return unless node.is_a?(Array)
43
+
44
+ case node[0]
45
+ when :class
46
+ # クラス定義: [:class, [:const_ref, [:@const, "ClassName", ...]], ...]
47
+ class_name = extract_const_path(node[1])
48
+ if class_name
49
+ full_name = (current_namespace + [class_name]).join("::")
50
+ namespaces << full_name
51
+ # クラス本体を再帰的に処理
52
+ traverse_sexp(node[3], current_namespace + [class_name], namespaces) if node[3]
53
+ end
54
+ when :module
55
+ # モジュール定義: [:module, [:const_ref, [:@const, "ModuleName", ...]], ...]
56
+ module_name = extract_const_path(node[1])
57
+ if module_name
58
+ full_name = (current_namespace + [module_name]).join("::")
59
+ namespaces << full_name
60
+ # モジュール本体を再帰的に処理
61
+ traverse_sexp(node[2], current_namespace + [module_name], namespaces) if node[2]
62
+ end
63
+ else
64
+ # その他のノードは子ノードを再帰的に処理
65
+ node.each do |child|
66
+ traverse_sexp(child, current_namespace, namespaces) if child.is_a?(Array)
67
+ end
68
+ end
69
+ end
70
+
71
+ # 定数パスから名前を抽出する
72
+ # @param node [Array] 定数パスのノード
73
+ # @return [String, nil] 定数名
74
+ def extract_const_path(node)
75
+ return nil unless node.is_a?(Array)
76
+
77
+ case node[0]
78
+ when :const_ref
79
+ # [:const_ref, [:@const, "Name", ...]]
80
+ node[1][1] if node[1] && node[1][0] == :@const
81
+ when :const_path_ref
82
+ # [:const_path_ref, parent, [:@const, "Name", ...]]
83
+ # 例: Foo::Bar の場合
84
+ parent = extract_const_path(node[1])
85
+ child = node[2][1] if node[2] && node[2][0] == :@const
86
+ parent && child ? "#{parent}::#{child}" : nil
87
+ when :const_path_field
88
+ # [:const_path_field, parent, [:@const, "Name", ...]]
89
+ # クラス定義でのコンパクト記法の場合
90
+ parent = extract_const_path(node[1])
91
+ child = node[2][1] if node[2] && node[2][0] == :@const
92
+ parent && child ? "#{parent}::#{child}" : nil
93
+ when :top_const_ref
94
+ # [:top_const_ref, [:@const, "Name", ...]]
95
+ # 例: ::Foo の場合
96
+ node[1][1] if node[1] && node[1][0] == :@const
97
+ when :top_const_field
98
+ # [:top_const_field, [:@const, "Name", ...]]
99
+ # トップレベル定数のフィールド
100
+ node[1][1] if node[1] && node[1][0] == :@const
101
+ when :var_ref
102
+ # [:var_ref, [:@const, "Name", ...]]
103
+ # 定数の参照
104
+ node[1][1] if node[1] && node[1][0] == :@const
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Misogi
4
+ module Rule
5
+ # すべてのルールの基底クラス
6
+ # カスタムルールを作成する場合は、このクラスを継承して#validateメソッドを実装する
7
+ class Base
8
+ # ルール名を返す(デフォルトはクラス名)
9
+ # @return [String]
10
+ def name
11
+ self.class.name.split("::").last
12
+ end
13
+
14
+ # ファイルパスとパース結果を検証する
15
+ # @param file_path [String] 検証対象のファイルパス
16
+ # @param parsed_content [ParsedContent] パース結果
17
+ # @return [Array<Violation>] 検出された違反のリスト
18
+ def validate(file_path, parsed_content)
19
+ raise NotImplementedError, "#{self.class}#validateを実装してください"
20
+ end
21
+
22
+ protected
23
+
24
+ # 違反を作成するヘルパーメソッド
25
+ # @param file_path [String] ファイルパス
26
+ # @param message [String] 違反メッセージ
27
+ # @return [Violation]
28
+ def violation(file_path:, message:)
29
+ Violation.new(file_path: file_path, message: message, rule_name: name)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Misogi
4
+ module Rule
5
+ # Railsの規約に従ってファイルパスとクラス/モジュール名の対応をチェックするルール
6
+ # 例:
7
+ # app/models/user.rb -> User
8
+ # app/controllers/users_controller.rb -> UsersController
9
+ # app/services/foo/bar_service.rb -> Foo::BarService
10
+ class Rails < Base
11
+ # Railsのディレクトリとその規約のマッピング
12
+ DIRECTORY_PATTERNS = {
13
+ "app/models" => { suffix: "" },
14
+ "app/controllers" => { suffix: "" },
15
+ "app/helpers" => { suffix: "" },
16
+ "app/mailers" => { suffix: "" },
17
+ "app/jobs" => { suffix: "" },
18
+ "app/services" => { suffix: "" },
19
+ "app/decorators" => { suffix: "" },
20
+ "app/presenters" => { suffix: "" },
21
+ "app/validators" => { suffix: "" },
22
+ "app/policies" => { suffix: "" },
23
+ "app/channels" => { suffix: "" },
24
+ "app/mailboxes" => { suffix: "" }
25
+ }.freeze
26
+
27
+ def initialize(inflections_path: nil)
28
+ super()
29
+ @inflections_path = inflections_path # 後方互換性のため残すが使用しない
30
+ @active_support_available = check_active_support
31
+ end
32
+
33
+ # @param file_path [String] 検証対象のファイルパス
34
+ # @param parsed_content [ParsedContent] パース結果
35
+ # @return [Array<Violation>] 検出された違反のリスト
36
+ def validate(file_path, parsed_content)
37
+ pattern_info = find_matching_pattern(file_path)
38
+ return [] unless pattern_info
39
+
40
+ violations = []
41
+
42
+ if parsed_content.empty?
43
+ violations << violation(
44
+ file_path: file_path,
45
+ message: "ファイルにクラスまたはモジュールが定義されていません"
46
+ )
47
+ return violations
48
+ end
49
+
50
+ # concernsディレクトリ内のファイルでは、名前空間に"Concerns::"を含んではいけない
51
+ if file_path.include?("/concerns/")
52
+ concerns_violation = check_concerns_namespace(file_path, parsed_content)
53
+ violations << concerns_violation if concerns_violation
54
+ return violations if concerns_violation
55
+ end
56
+
57
+ # 定義されている各名前空間について期待されるパスを計算
58
+ base_path = pattern_info[:base_path]
59
+ expected_paths = parsed_content.namespaces.flat_map do |namespace|
60
+ namespace_to_paths(namespace, base_path, file_path)
61
+ end
62
+
63
+ # 実際のファイルパスが期待されるパスのいずれかと一致するか確認
64
+ unless expected_paths.include?(file_path)
65
+ expected_paths_str = expected_paths.map { |p| "`#{p}`" }.join(" または ")
66
+ defined_namespaces = parsed_content.namespaces.join(", ")
67
+ violations << violation(
68
+ file_path: file_path,
69
+ message: "名前空間 '#{defined_namespaces}' は #{expected_paths_str} に配置すべきです"
70
+ )
71
+ end
72
+
73
+ violations
74
+ end
75
+
76
+ private
77
+
78
+ # concernsディレクトリ内のファイルの名前空間をチェック
79
+ # @param file_path [String] ファイルパス
80
+ # @param parsed_content [ParsedContent] パース結果
81
+ # @return [Violation, nil] 違反がある場合はViolationオブジェクト
82
+ def check_concerns_namespace(file_path, parsed_content)
83
+ # concernsディレクトリ内のファイルでは、名前空間に"Concerns::"を含んではいけない
84
+ invalid_namespaces = parsed_content.namespaces.select do |namespace|
85
+ namespace.start_with?("Concerns::") || namespace.include?("::Concerns::")
86
+ end
87
+
88
+ return nil if invalid_namespaces.empty?
89
+
90
+ violation(
91
+ file_path: file_path,
92
+ message: "concernsディレクトリ内のファイルでは、名前空間に 'Concerns::' を含めるべきではありません。" \
93
+ "定義されている名前空間: #{invalid_namespaces.join(", ")}"
94
+ )
95
+ end
96
+
97
+ # ファイルパスに一致するパターンを見つける
98
+ # @param file_path [String] ファイルパス
99
+ # @return [Hash, nil] パターン情報
100
+ def find_matching_pattern(file_path)
101
+ DIRECTORY_PATTERNS.each do |base_path, options|
102
+ return { base_path: base_path, **options } if file_path.start_with?(base_path)
103
+ end
104
+ nil
105
+ end
106
+
107
+ # 名前空間から期待されるファイルパスを生成する(複数の可能性を返す)
108
+ # @param namespace [String] 名前空間(例: "Foo::Bar")
109
+ # @param base_path [String] ベースパス(例: "app/models")
110
+ # @param current_file_path [String] 現在検証中のファイルパス
111
+ # @return [Array<String>] 期待されるファイルパスのリスト
112
+ def namespace_to_paths(namespace, base_path, current_file_path)
113
+ # 名前空間をパーツに分割
114
+ parts = namespace.split("::")
115
+
116
+ # 各パーツをスネークケースに変換
117
+ snake_parts = parts.map { |part| underscore(part) }
118
+
119
+ paths = []
120
+
121
+ # 通常のパス
122
+ paths << "#{File.join(base_path, *snake_parts)}.rb"
123
+
124
+ # concernsディレクトリ内のパス
125
+ # 現在のファイルがconcernsディレクトリ内にある場合、
126
+ # かつ名前空間が"Concerns::"で始まらない場合のみ、concernsパスも候補に含める
127
+ # (concernsディレクトリは名前空間に含めないRailsの規約のため)
128
+ if current_file_path.include?("/concerns/") && !namespace.start_with?("Concerns::")
129
+ paths << "#{File.join(base_path, "concerns", *snake_parts)}.rb"
130
+ end
131
+
132
+ paths
133
+ end
134
+
135
+ # ActiveSupportが利用可能かチェック
136
+ # @return [Boolean] ActiveSupportが利用可能かどうか
137
+ def check_active_support
138
+ # Rails環境が読み込まれている場合は、ActiveSupportも利用可能
139
+ return true if defined?(::Rails)
140
+
141
+ # Rails環境がない場合は、ActiveSupportを直接読み込んでみる
142
+ require "active_support/inflector"
143
+ true
144
+ rescue LoadError
145
+ false
146
+ end
147
+
148
+ # キャメルケースをスネークケースに変換
149
+ # @param str [String] キャメルケースの文字列
150
+ # @return [String] スネークケースの文字列
151
+ def underscore(str)
152
+ if @active_support_available
153
+ # ActiveSupportが利用可能な場合はそれを使用(inflectionsを考慮)
154
+ ActiveSupport::Inflector.underscore(str)
155
+ else
156
+ # フォールバック: 単純な変換
157
+ str
158
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
159
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
160
+ .downcase
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end