rubyzen-lint 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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/README.md +110 -0
  4. data/lib/rubyzen/cache/parse_cache.rb +36 -0
  5. data/lib/rubyzen/collections/attributes_collection.rb +32 -0
  6. data/lib/rubyzen/collections/base_collection.rb +30 -0
  7. data/lib/rubyzen/collections/blocks_collection.rb +27 -0
  8. data/lib/rubyzen/collections/call_site_collection.rb +52 -0
  9. data/lib/rubyzen/collections/classes_collection.rb +77 -0
  10. data/lib/rubyzen/collections/constants_collection.rb +11 -0
  11. data/lib/rubyzen/collections/declaration_collection.rb +11 -0
  12. data/lib/rubyzen/collections/file_collection.rb +81 -0
  13. data/lib/rubyzen/collections/macros_collection.rb +11 -0
  14. data/lib/rubyzen/collections/methods_collection.rb +67 -0
  15. data/lib/rubyzen/collections/modules_collection.rb +36 -0
  16. data/lib/rubyzen/collections/parameters_collection.rb +11 -0
  17. data/lib/rubyzen/collections/raises_collection.rb +26 -0
  18. data/lib/rubyzen/collections/requires_collection.rb +32 -0
  19. data/lib/rubyzen/collections/rescues_collection.rb +19 -0
  20. data/lib/rubyzen/declarations/attribute_declaration.rb +62 -0
  21. data/lib/rubyzen/declarations/block_declaration.rb +49 -0
  22. data/lib/rubyzen/declarations/call_site_declaration.rb +98 -0
  23. data/lib/rubyzen/declarations/class_declaration.rb +168 -0
  24. data/lib/rubyzen/declarations/constant_declaration.rb +155 -0
  25. data/lib/rubyzen/declarations/file_declaration.rb +69 -0
  26. data/lib/rubyzen/declarations/if_statement_declaration.rb +44 -0
  27. data/lib/rubyzen/declarations/macro_declaration.rb +81 -0
  28. data/lib/rubyzen/declarations/method_declaration.rb +63 -0
  29. data/lib/rubyzen/declarations/module_declaration.rb +115 -0
  30. data/lib/rubyzen/declarations/parameter_declaration.rb +43 -0
  31. data/lib/rubyzen/declarations/raise_declaration.rb +87 -0
  32. data/lib/rubyzen/declarations/require_declaration.rb +61 -0
  33. data/lib/rubyzen/declarations/rescue_declaration.rb +54 -0
  34. data/lib/rubyzen/lint.rb +1 -0
  35. data/lib/rubyzen/matchers/matcher_helpers.rb +176 -0
  36. data/lib/rubyzen/matchers/zen_empty_matcher.rb +54 -0
  37. data/lib/rubyzen/matchers/zen_false_matcher.rb +63 -0
  38. data/lib/rubyzen/matchers/zen_true_matcher.rb +57 -0
  39. data/lib/rubyzen/parsers/a_s_t_parser.rb +33 -0
  40. data/lib/rubyzen/project.rb +69 -0
  41. data/lib/rubyzen/providers/attributes_provider.rb +19 -0
  42. data/lib/rubyzen/providers/blocks_provider.rb +15 -0
  43. data/lib/rubyzen/providers/call_site_provider.rb +15 -0
  44. data/lib/rubyzen/providers/class_name_provider.rb +36 -0
  45. data/lib/rubyzen/providers/collection_filter_provider.rb +64 -0
  46. data/lib/rubyzen/providers/constants_provider.rb +17 -0
  47. data/lib/rubyzen/providers/file_path_provider.rb +26 -0
  48. data/lib/rubyzen/providers/if_statements_provider.rb +11 -0
  49. data/lib/rubyzen/providers/line_number_provider.rb +11 -0
  50. data/lib/rubyzen/providers/lines_of_code_provider.rb +11 -0
  51. data/lib/rubyzen/providers/macros_provider.rb +17 -0
  52. data/lib/rubyzen/providers/raises_provider.rb +19 -0
  53. data/lib/rubyzen/providers/requires_provider.rb +19 -0
  54. data/lib/rubyzen/providers/rescues_provider.rb +17 -0
  55. data/lib/rubyzen/providers/source_code_provider.rb +11 -0
  56. data/lib/rubyzen/providers/visibility_provider.rb +49 -0
  57. data/lib/rubyzen/version.rb +3 -0
  58. data/lib/rubyzen-lint.rb +1 -0
  59. data/lib/rubyzen.rb +98 -0
  60. data/rubyzen-lint.gemspec +28 -0
  61. metadata +148 -0
@@ -0,0 +1,61 @@
1
+ module Rubyzen
2
+ module Declarations
3
+ # Represents a +require+, +require_relative+, or +load+ statement.
4
+ #
5
+ # @example
6
+ # req = file.requires.first
7
+ # req.required_path #=> "json"
8
+ # req.require? #=> true
9
+ # req.require_relative? #=> false
10
+ #
11
+ class RequireDeclaration
12
+ include Rubyzen::Providers::FilePathProvider
13
+ include Rubyzen::Providers::LineNumberProvider
14
+
15
+ # @return [RuboCop::AST::Node]
16
+ attr_reader :node
17
+
18
+ # @return [FileDeclaration]
19
+ attr_reader :parent_file
20
+ alias :parent :parent_file
21
+
22
+ # @param node [RuboCop::AST::Node] the AST node
23
+ # @param parent_file [FileDeclaration] the parent file declaration
24
+ def initialize(node, parent_file)
25
+ @node = node
26
+ @parent_file = parent_file
27
+ end
28
+
29
+ # Returns the statement type.
30
+ #
31
+ # @return [String] one of +"require"+, +"require_relative"+, +"load"+
32
+ def name
33
+ node.method_name.to_s
34
+ end
35
+
36
+ # Returns the required path string.
37
+ #
38
+ # @return [String, nil]
39
+ def required_path
40
+ first_arg = node.arguments.first
41
+ return nil unless first_arg&.type == :str
42
+ first_arg.value
43
+ end
44
+
45
+ # @return [Boolean]
46
+ def require?
47
+ name == 'require'
48
+ end
49
+
50
+ # @return [Boolean]
51
+ def require_relative?
52
+ name == 'require_relative'
53
+ end
54
+
55
+ # @return [Boolean]
56
+ def load?
57
+ name == 'load'
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,54 @@
1
+ module Rubyzen
2
+ module Declarations
3
+ # Represents a +rescue+ clause within a method or block.
4
+ #
5
+ # @example
6
+ # rescue_decl = method.rescues.first
7
+ # rescue_decl.exception_types #=> ["ArgumentError", "TypeError"]
8
+ #
9
+ class RescueDeclaration
10
+ include Rubyzen::Providers::FilePathProvider
11
+ include Rubyzen::Providers::LineNumberProvider
12
+ include Rubyzen::Providers::ClassNameProvider
13
+
14
+ # @return [RuboCop::AST::Node]
15
+ attr_reader :node
16
+
17
+ # @return [MethodDeclaration, BlockDeclaration]
18
+ attr_reader :parent
19
+
20
+ # @param node [RuboCop::AST::Node] the AST node
21
+ # @param parent [MethodDeclaration, BlockDeclaration] the parent declaration
22
+ def initialize(node, parent)
23
+ @node = node
24
+ @parent = parent
25
+ end
26
+
27
+ # Returns the rescued exception class names.
28
+ # Defaults to +["StandardError"]+ for bare +rescue+.
29
+ #
30
+ # @return [Array<String>]
31
+ def exception_types
32
+ extract_exception_types
33
+ end
34
+
35
+ private
36
+
37
+ def extract_exception_types
38
+ exception_array_node = node.children[0]
39
+
40
+ return ['StandardError'] if exception_array_node.nil?
41
+
42
+ exception_array_node.children.map do |const_node|
43
+ extract_const_name(const_node)
44
+ end.compact
45
+ end
46
+
47
+ def extract_const_name(node)
48
+ return nil unless node&.type == :const
49
+
50
+ node.const_name
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1 @@
1
+ require_relative '../rubyzen'
@@ -0,0 +1,176 @@
1
+ module Rubyzen
2
+ module Matchers
3
+ # Shared helper methods used by Rubyzen's custom RSpec matchers.
4
+ #
5
+ # Provides utilities for normalizing exception lists, extracting item
6
+ # details, matching items against allowlist/baseline entries, and
7
+ # formatting failure messages.
8
+ module MatcherHelpers
9
+ # Normalizes a list of exception entries into unique, non-blank strings.
10
+ #
11
+ # @param entries [Array<String>, String, nil] raw exception entries
12
+ # @return [Array<String>] deduplicated, stripped, non-empty strings
13
+ def normalize_exception_entries(entries)
14
+ Array(entries).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
15
+ end
16
+
17
+ # Extracts identifying details from a declaration item.
18
+ #
19
+ # @param item [Object] a declaration object (e.g., FileDeclaration, ClassDeclaration)
20
+ # @return [Hash{Symbol => String, nil}] hash with :name, :class_name, :file_path, :line
21
+ def item_details(item)
22
+ {
23
+ name: item.respond_to?(:name) ? item.name : nil,
24
+ class_name: item.respond_to?(:class_name) ? item.class_name : nil,
25
+ file_path: item.respond_to?(:file_path) ? item.file_path : 'Unknown file',
26
+ line: item.respond_to?(:line) ? item.line : nil
27
+ }
28
+ end
29
+
30
+ # Returns a list of unique identifier strings for an item, used for matching.
31
+ #
32
+ # @param item [Object] a declaration object
33
+ # @return [Array<String>] identifiers such as name, class name, file path, and file:line
34
+ def item_identifiers(item)
35
+ details = item_details(item)
36
+ identifiers = [details[:name], details[:class_name], details[:file_path]]
37
+
38
+ if details[:line]
39
+ identifiers << "#{details[:file_path]}:#{details[:line]}"
40
+ end
41
+
42
+ identifiers.compact.uniq
43
+ end
44
+
45
+ # Checks whether a given exception entry string matches an item.
46
+ #
47
+ # @param entry [String] an allowlist or baseline entry
48
+ # @param item [Object] a declaration object
49
+ # @return [Boolean] true if the entry matches the item by name, class, or path
50
+ def exception_entry_matches_item?(entry, item)
51
+ normalized_entry = entry.to_s.strip
52
+ return false if normalized_entry.empty?
53
+
54
+ details = item_details(item)
55
+ return true if item_identifiers(item).include?(normalized_entry)
56
+
57
+ file_path = details[:file_path]
58
+ file_path && (file_path.end_with?(normalized_entry) || file_path.end_with?("/#{normalized_entry}"))
59
+ end
60
+
61
+ # Classifies items into violations, baseline matches, allowlist matches,
62
+ # and detects stale entries in either list.
63
+ #
64
+ # @param subject_collection [Array, Object] items to classify
65
+ # @param allowlist [Array<String>, nil] allowed exception entries
66
+ # @param baseline [Array<String>, nil] baseline exception entries
67
+ # @return [Hash{Symbol => Array<String>}] keys: :violations, :baseline, :allowlist,
68
+ # :stale_baseline, :stale_allowlist
69
+ def classify_items(subject_collection, allowlist: nil, baseline: nil)
70
+ items = Array(subject_collection).compact
71
+ normalized_allowlist = normalize_exception_entries(allowlist)
72
+ normalized_baseline = normalize_exception_entries(baseline)
73
+ matched_baseline_entries = []
74
+ matched_allowlist_entries = []
75
+
76
+ grouped_items = items.group_by do |item|
77
+ matching_baseline_entry = normalized_baseline.find do |entry|
78
+ exception_entry_matches_item?(entry, item)
79
+ end
80
+
81
+ if matching_baseline_entry
82
+ matched_baseline_entries << matching_baseline_entry
83
+ :baseline
84
+ else
85
+ matching_allowlist_entry = normalized_allowlist.find do |entry|
86
+ exception_entry_matches_item?(entry, item)
87
+ end
88
+
89
+ if matching_allowlist_entry
90
+ matched_allowlist_entries << matching_allowlist_entry
91
+ :allowlist
92
+ else
93
+ :violations
94
+ end
95
+ end
96
+ end
97
+
98
+ classifications = {
99
+ baseline: Array(grouped_items[:baseline]).map { |item| element_name(item) },
100
+ allowlist: Array(grouped_items[:allowlist]).map { |item| element_name(item) },
101
+ violations: Array(grouped_items[:violations]).map { |item| element_name(item) }
102
+ }
103
+
104
+ classifications.merge(
105
+ stale_baseline: normalized_baseline - matched_baseline_entries.uniq,
106
+ stale_allowlist: normalized_allowlist - matched_allowlist_entries.uniq
107
+ )
108
+ end
109
+
110
+ # Formats a human-readable description of an item for failure messages.
111
+ #
112
+ # @param item [Object] a declaration object
113
+ # @return [String] formatted multi-line description
114
+ def element_name(item)
115
+ details = item_details(item)
116
+ location = [details[:file_path], details[:line]].compact.join(':')
117
+
118
+ case
119
+ when details[:name] && details[:class_name]
120
+ " - element: #{details[:name]}\n - class: #{details[:class_name]}\n - file: #{location}"
121
+ when details[:name]
122
+ " - element: #{details[:name]}\n - file: #{location}"
123
+ when details[:class_name]
124
+ " - class: #{details[:class_name]}\n - file: #{location}"
125
+ else
126
+ " - unknown element in #{location}"
127
+ end
128
+ end
129
+
130
+ # Builds a formatted string of violations and stale entries for failure output.
131
+ #
132
+ # @return [String, nil] formatted sections or nil if no classified items
133
+ def formatted_matcher_groups
134
+ return unless defined?(@classified_items) && @classified_items
135
+
136
+ sections = []
137
+
138
+ if @classified_items[:violations].any?
139
+ sections << "Violations:\n#{@classified_items[:violations].join("\n")}"
140
+ end
141
+
142
+ if @classified_items[:stale_baseline].any?
143
+ stale_entries = @classified_items[:stale_baseline].map { |entry| " - #{entry}" }
144
+ sections << "Stale baseline entries:\n#{stale_entries.join("\n")}"
145
+ end
146
+
147
+ if @classified_items[:stale_allowlist].any?
148
+ stale_entries = @classified_items[:stale_allowlist].map { |entry| " - #{entry}" }
149
+ sections << "Stale allowlist entries:\n#{stale_entries.join("\n")}"
150
+ end
151
+
152
+ sections.join("\n")
153
+ end
154
+
155
+ def self.included(base)
156
+ base.define_method(:message_for_failure) do |base_message|
157
+ return @failure_message if @failure_message
158
+
159
+ details = formatted_matcher_groups
160
+
161
+ if @custom_message
162
+ if details && !details.empty?
163
+ "#{@custom_message}\n#{details}"
164
+ else
165
+ @custom_message
166
+ end
167
+ elsif details && !details.empty?
168
+ "#{base_message}\n#{details}"
169
+ else
170
+ base_message
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,54 @@
1
+ # Custom RSpec matcher that asserts a Rubyzen collection is empty.
2
+ #
3
+ # Used in architectural lint rules to verify that no items match
4
+ # a forbidden pattern (e.g., no controllers call +.where+ directly).
5
+ #
6
+ # @example Ensure no controllers use .where
7
+ # expect(controllers.all_methods.call_sites.with_name('where')).to zen_empty
8
+ #
9
+ # @example With a custom failure message
10
+ # expect(violations).to zen_empty("Controllers should not call .where directly")
11
+ RSpec::Matchers.define :zen_empty do |custom_message=nil, allowlist: nil, baseline: nil|
12
+ include Rubyzen::Matchers::MatcherHelpers
13
+
14
+ match do |subject_collection|
15
+ options = custom_message.is_a?(Hash) ? custom_message : {}
16
+ resolved_allowlist = allowlist || options[:allowlist] || options['allowlist']
17
+ resolved_baseline = baseline || options[:baseline] || options['baseline']
18
+ @custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash))
19
+
20
+ @classified_items = classify_items(
21
+ subject_collection,
22
+ allowlist: resolved_allowlist,
23
+ baseline: resolved_baseline
24
+ )
25
+ @offenders = @classified_items[:violations]
26
+ stale_exception_groups = []
27
+ stale_exception_groups << 'baseline entries' if @classified_items[:stale_baseline].any?
28
+ stale_exception_groups << 'allowlist entries' if @classified_items[:stale_allowlist].any?
29
+
30
+ @failure_reason = if @classified_items[:violations].any? && stale_exception_groups.any?
31
+ "Expected to be empty, but found live violations and stale #{stale_exception_groups.join(' and ')}."
32
+ elsif @classified_items[:violations].any?
33
+ if resolved_baseline || resolved_allowlist
34
+ 'Expected to be empty, but found live violations.'
35
+ else
36
+ 'Expected to be empty, but had elements.'
37
+ end
38
+ elsif stale_exception_groups.any?
39
+ "Expected to be empty, but found stale #{stale_exception_groups.join(' and ')}."
40
+ end
41
+
42
+ @classified_items[:violations].empty? &&
43
+ @classified_items[:stale_baseline].empty? &&
44
+ @classified_items[:stale_allowlist].empty?
45
+ end
46
+
47
+ failure_message do |_|
48
+ message_for_failure(@failure_reason || 'Expected to be empty, but had elements.')
49
+ end
50
+
51
+ failure_message_when_negated do |_|
52
+ message_for_failure('Expected not to be empty, but had no elements.')
53
+ end
54
+ end
@@ -0,0 +1,63 @@
1
+ # Custom RSpec matcher that asserts a block returns false for every item in a collection.
2
+ #
3
+ # Supports +allowlist:+ and +baseline:+ for gradual adoption, matching items
4
+ # where the block returns true against exception lists.
5
+ #
6
+ # @example Ensure no methods have more than 5 parameters
7
+ # expect(methods).to zen_false { |m| m.parameters.size > 5 }
8
+ #
9
+ # @example With a custom failure message
10
+ # expect(controllers.all_methods.call_sites).to zen_false("Controllers must not call .where directly") { |cs| cs.name == 'where' }
11
+ #
12
+ # @example With a baseline for gradual adoption
13
+ # expect(classes).to zen_false(baseline: ['LegacyModel']) { |k| k.lines_of_code > 200 }
14
+ RSpec::Matchers.define :zen_false do |custom_message=nil, allowlist: nil, baseline: nil|
15
+ include Rubyzen::Matchers::MatcherHelpers
16
+
17
+ match do |subject_collection|
18
+ options = custom_message.is_a?(Hash) ? custom_message : {}
19
+ resolved_allowlist = allowlist || options[:allowlist] || options['allowlist']
20
+ resolved_baseline = baseline || options[:baseline] || options['baseline']
21
+ @custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash))
22
+ @offenders = []
23
+
24
+ if block_arg != nil
25
+ items = Array(subject_collection)
26
+
27
+ failing_items = items.filter { |item| block_arg.call(item) }
28
+ @classified_items = classify_items(
29
+ failing_items,
30
+ allowlist: resolved_allowlist,
31
+ baseline: resolved_baseline
32
+ )
33
+ @offenders = @classified_items[:violations]
34
+
35
+ stale_exception_groups = []
36
+ stale_baseline = @classified_items[:stale_baseline]
37
+ stale_allowlist = @classified_items[:stale_allowlist]
38
+ stale_exception_groups << 'baseline entries' if stale_baseline.any?
39
+ stale_exception_groups << 'allowlist entries' if stale_allowlist.any?
40
+
41
+ @failure_reason = if @offenders.any? && stale_exception_groups.any?
42
+ "Expected to return false for all elements, but found live violations and stale #{stale_exception_groups.join(' and ')}."
43
+ elsif @offenders.any?
44
+ "Expected to return false for all elements."
45
+ elsif stale_exception_groups.any?
46
+ "Expected to return false for all elements, but found stale #{stale_exception_groups.join(' and ')}."
47
+ end
48
+
49
+ @offenders.empty? && stale_baseline.empty? && stale_allowlist.empty?
50
+ else
51
+ @failure_reason = "Expected a block, but got nil."
52
+ false
53
+ end
54
+ end
55
+
56
+ failure_message do |_|
57
+ message_for_failure(@failure_reason || "Expected to return false for all elements.")
58
+ end
59
+
60
+ failure_message_when_negated do |_|
61
+ message_for_failure("Expected to return true for at least one element, but all elements returned false.")
62
+ end
63
+ end
@@ -0,0 +1,57 @@
1
+ # Custom RSpec matcher that asserts a block returns true for every item in a collection.
2
+ #
3
+ # @example Ensure all methods have parameters
4
+ # expect(methods).to zen_true { |m| m.parameters? }
5
+ #
6
+ # @example With a custom failure message
7
+ # expect(services).to zen_true("All services must inherit from BaseService") { |s| s.superclass_name == 'BaseService' }
8
+ RSpec::Matchers.define :zen_true do |custom_message=nil, allowlist: nil, baseline: nil|
9
+ include Rubyzen::Matchers::MatcherHelpers
10
+
11
+ match do |subject_collection|
12
+ options = custom_message.is_a?(Hash) ? custom_message : {}
13
+ resolved_allowlist = allowlist || options[:allowlist] || options['allowlist']
14
+ resolved_baseline = baseline || options[:baseline] || options['baseline']
15
+ @custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash))
16
+ @offenders = []
17
+
18
+ if block_arg != nil
19
+ items = Array(subject_collection) # to handle one or multiple subjects
20
+
21
+ failing_items = items.filter { |item| !block_arg.call(item) }
22
+ @classified_items = classify_items(
23
+ failing_items,
24
+ allowlist: resolved_allowlist,
25
+ baseline: resolved_baseline
26
+ )
27
+ @offenders = @classified_items[:violations]
28
+
29
+ stale_exception_groups = []
30
+ stale_baseline = @classified_items[:stale_baseline]
31
+ stale_allowlist = @classified_items[:stale_allowlist]
32
+ stale_exception_groups << 'baseline entries' if stale_baseline.any?
33
+ stale_exception_groups << 'allowlist entries' if stale_allowlist.any?
34
+
35
+ @failure_reason = if @offenders.any? && stale_exception_groups.any?
36
+ "Expected to return true for all elements, but found live violations and stale #{stale_exception_groups.join(' and ')}."
37
+ elsif @offenders.any?
38
+ "Expected to return true for all elements."
39
+ elsif stale_exception_groups.any?
40
+ "Expected to return true for all elements, but found stale #{stale_exception_groups.join(' and ')}."
41
+ end
42
+
43
+ @offenders.empty? && stale_baseline.empty? && stale_allowlist.empty?
44
+ else
45
+ @failure_reason = "Expected a block, but got nil."
46
+ false
47
+ end
48
+ end
49
+
50
+ failure_message do |_|
51
+ message_for_failure(@failure_reason || "Expected to return true for all elements.")
52
+ end
53
+
54
+ failure_message_when_negated do |_|
55
+ message_for_failure("Expected to return false for at least one element, but all elements returned true.")
56
+ end
57
+ end
@@ -0,0 +1,33 @@
1
+ require 'rubocop-ast'
2
+
3
+ module Rubyzen
4
+ module Parsers
5
+ # Singleton parser that converts Ruby source files into Rubyzen declarations
6
+ # using RuboCop's AST processing. Results are cached via {Cache::ParseCache}.
7
+ class ASTParser
8
+ # Returns the singleton instance of the parser.
9
+ #
10
+ # @return [ASTParser]
11
+ def self.instance
12
+ @instance ||= new
13
+ end
14
+
15
+ def initialize
16
+ @cache = Rubyzen::Cache::ParseCache.new
17
+ end
18
+
19
+ # Parses a Ruby source file and returns its declaration, using the cache.
20
+ #
21
+ # @param file_path [String] absolute path to the Ruby file
22
+ # @return [Declarations::FileDeclaration, nil] the parsed file declaration, or nil if unparseable
23
+ def parse_file(file_path)
24
+ @cache.fetch_or_parse(file_path) do
25
+ source = File.read(file_path)
26
+ processed_source = RuboCop::AST::ProcessedSource.new(source, RUBY_VERSION.to_f, file_path)
27
+ next nil unless processed_source.ast
28
+ Rubyzen::Declarations::FileDeclaration.new(file_path, processed_source.ast)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,69 @@
1
+ module Rubyzen
2
+ # Main entry point for analyzing a Ruby project. Parses all +.rb+ files
3
+ # in the given paths and provides access to files, classes, and modules.
4
+ #
5
+ # @example Analyzing specific directories
6
+ # project = Rubyzen::Project.new(["app/models", "app/controllers"])
7
+ # project.files.with_paths("controllers/").classes
8
+ #
9
+ # @example Using auto-discovery
10
+ # project = Rubyzen::Project.new # scans app/, lib/, src/, spec/
11
+ # project.classes.with_name("UsersController")
12
+ #
13
+ class Project
14
+ # @param paths [String, Array<String>, nil] directories or file paths to analyze.
15
+ # Falls back to {Configuration#project_paths} (auto-discovery) if nil.
16
+ def initialize(paths = nil)
17
+ paths ||= Rubyzen.configuration.project_paths
18
+ @root_paths = Array(paths).map { |p| File.expand_path(p) }
19
+
20
+ @root_paths.each do |path|
21
+ unless File.exist?(path)
22
+ raise Rubyzen::Error, "Path does not exist: #{path}"
23
+ end
24
+ end
25
+
26
+ @file_paths = @root_paths.flat_map do |path|
27
+ if File.directory?(path)
28
+ Dir[File.join(path, '**', '*.rb')]
29
+ else
30
+ [path]
31
+ end
32
+ end.uniq
33
+
34
+ @parser = Rubyzen::Parsers::ASTParser.instance
35
+ end
36
+
37
+ # Returns all parsed files as a filterable collection.
38
+ #
39
+ # @return [Collections::FileCollection]
40
+ def files
41
+ all_files = file_declarations
42
+ Collections::FileCollection.new(all_files)
43
+ end
44
+
45
+ # Returns all classes found across all parsed files.
46
+ #
47
+ # @return [Collections::ClassesCollection]
48
+ def classes
49
+ all_classes = file_declarations.flat_map(&:classes)
50
+ Collections::ClassesCollection.new(all_classes)
51
+ end
52
+
53
+ # Returns all modules found across all parsed files.
54
+ #
55
+ # @return [Collections::ModulesCollection]
56
+ def modules
57
+ all_modules = file_declarations.flat_map(&:modules)
58
+ Collections::ModulesCollection.new(all_modules)
59
+ end
60
+
61
+ private
62
+
63
+ def file_declarations
64
+ @file_declarations ||= @file_paths.map do |file_path|
65
+ @parser.parse_file(file_path)
66
+ end.compact
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,19 @@
1
+ module Rubyzen
2
+ module Providers
3
+ # Provides access to attr_reader, attr_writer, and attr_accessor declarations.
4
+ module AttributesProvider
5
+ # @return [Rubyzen::Collections::AttributesCollection] collection of attribute declarations
6
+ def attributes
7
+ attribute_nodes = node.each_descendant(:send).select do |send_node|
8
+ %w[attr_reader attr_writer attr_accessor].include?(send_node.method_name.to_s)
9
+ end
10
+
11
+ attribute_declarations = attribute_nodes.map do |attr_node|
12
+ Rubyzen::Declarations::AttributeDeclaration.new(attr_node, self)
13
+ end
14
+
15
+ Rubyzen::Collections::AttributesCollection.new(attribute_declarations)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ module Rubyzen
2
+ module Providers
3
+ # Provides access to block expressions (do..end / {..}) within a declaration.
4
+ module BlocksProvider
5
+ # @return [Rubyzen::Collections::BlocksCollection] collection of block declarations
6
+ def blocks
7
+ Collections::BlocksCollection.new(
8
+ node.each_descendant(:block).map do |block_node|
9
+ Declarations::BlockDeclaration.new(block_node, self)
10
+ end
11
+ )
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Rubyzen
2
+ module Providers
3
+ # Provides access to method call sites within a declaration.
4
+ module CallSiteProvider
5
+ # @return [Rubyzen::Collections::CallSiteCollection] collection of call site declarations
6
+ def call_sites
7
+ Collections::CallSiteCollection.new(
8
+ node.each_descendant(:send).map do |send_node|
9
+ Declarations::CallSiteDeclaration.new(send_node, self)
10
+ end
11
+ )
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,36 @@
1
+ module Rubyzen
2
+ module Providers
3
+ # Provides access to the enclosing class name by traversing parent declarations.
4
+ module ClassNameProvider
5
+ # @return [String, nil] the name of the enclosing class, or nil if not within a class
6
+ def class_name
7
+ class_name_recursive(self)
8
+ end
9
+
10
+ private
11
+
12
+ def class_name_recursive(declaration)
13
+ return if declaration.nil?
14
+ return nil if declaration.is_a?(Rubyzen::Declarations::FileDeclaration)
15
+ return declaration.name if declaration.is_a?(Rubyzen::Declarations::ClassDeclaration)
16
+
17
+ if declaration.is_a?(Rubyzen::Declarations::ModuleDeclaration)
18
+ return module_class_name(declaration)
19
+ end
20
+
21
+ class_name_recursive(parent_declaration(declaration))
22
+ end
23
+
24
+ def module_class_name(declaration)
25
+ parent_class_name = class_name_recursive(parent_declaration(declaration))
26
+ parent_class_name || declaration.name
27
+ end
28
+
29
+ def parent_declaration(declaration)
30
+ return unless declaration.respond_to?(:parent)
31
+
32
+ declaration.parent
33
+ end
34
+ end
35
+ end
36
+ end