rails_routes_analyzer 1.0.4 → 2.0.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.
@@ -0,0 +1,65 @@
1
+ module RailsRoutesAnalyzer
2
+
3
+ # Represents both positive and negative information collected
4
+ # about a specific call that generated Rails routes.
5
+ #
6
+ # If called in a loop each iteration generates a new record.
7
+ class RouteCall < Hash
8
+ def self.fields(*names)
9
+ names.each { |name| define_method(name) { self[name] } }
10
+ end
11
+
12
+ fields \
13
+ :action,
14
+ :action_names,
15
+ :controller_class_name,
16
+ :controller_name,
17
+ :file_location,
18
+ :route_creation_method,
19
+ :present_actions
20
+
21
+ def initialize(**kwargs)
22
+ update(kwargs)
23
+ end
24
+
25
+ def issues
26
+ self[:issues] ||= []
27
+ end
28
+
29
+ def add_issue(issue)
30
+ issue.route_call = self
31
+ issues << issue
32
+ end
33
+
34
+ def issue?
35
+ issues.any?
36
+ end
37
+
38
+ def present_actions?
39
+ present_actions.present?
40
+ end
41
+
42
+ def full_filename
43
+ @full_filename ||= RailsRoutesAnalyzer.get_full_filename(file_location.sub(/:[0-9]*\z/, ''))
44
+ end
45
+
46
+ def line_number
47
+ @line_number ||= file_location[/:([0-9]+)\z/, 1].to_i
48
+ end
49
+
50
+ def suggestion(**kwargs)
51
+ issues.map { |issue| issue.suggestion(**kwargs) }.join('; ')
52
+ end
53
+
54
+ def human_readable_error(**kwargs)
55
+ issues.map { |issue| issue.human_readable_error(**kwargs) }.join('; ')
56
+ end
57
+
58
+ def try_to_fix_line(line)
59
+ return if issues.size != 1
60
+
61
+ issues[0].try_to_fix_line(line)
62
+ end
63
+ end
64
+
65
+ end
@@ -1,73 +1,153 @@
1
1
  module RailsRoutesAnalyzer
2
2
 
3
3
  class RouteFileAnnotator
4
- def initialize(analysis: RailsRoutesAnalyzer::RouteAnalysis.new)
5
- @analysis = analysis
4
+ # @param try_to_fix [Boolean] should automatic fixes be attempted
5
+ # @param allow_deleting [Boolean] should route lines be deleted when they match no actions
6
+ # @param force_overwrite [Boolean] allow overwriting routes file even if it has uncommited changes or is outside Rails.root
7
+ def initialize(try_to_fix: false, allow_deleting: false, force_overwrite: false, analysis: nil, **kwargs)
8
+ @analysis = analysis || RailsRoutesAnalyzer::RouteAnalysis.new(**kwargs)
9
+ @try_to_fix = try_to_fix
10
+ @allow_deleting = allow_deleting
11
+ @force_overwrite = force_overwrite
6
12
  end
7
13
 
8
14
  def annotated_file_content(route_filename)
9
- relevant_issues = @analysis.all_issues_for_file_name(route_filename)
15
+ route_lines = @analysis.route_lines_for_file(route_filename)
10
16
 
11
- if relevant_issues.none?(&:issue?)
12
- log_notice { "Didn't find any route issues for file: #{route_filename}, only have references to: #{@analysis.all_unique_issues_file_names.join(', ')}" }
17
+ if route_lines.none?(&:issues?)
18
+ log_notice { "Didn't find any route issues for file: #{route_filename}, only found issues in files: #{@analysis.all_unique_issues_file_names.join(', ')}" }
13
19
  end
14
20
 
15
21
  log_notice { "Annotating #{route_filename}" }
16
22
 
17
- lines = File.readlines(route_filename)
18
- issue_map = relevant_issues.group_by { |issue| issue.line_number }
23
+ route_lines_map = route_lines.index_by(&:line_number)
19
24
 
20
25
  "".tap do |output|
21
26
  File.readlines(route_filename).each_with_index do |line, index|
22
- suggestion = combined_suggestion_for(issue_map[index + 1])
27
+ route_line = route_lines_map[index + 1]
28
+
29
+ output <<
30
+ if route_line
31
+ route_line.annotate(line,
32
+ try_to_fix: @try_to_fix,
33
+ allow_deleting: @allow_deleting)
34
+ else
35
+ line
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def annotate_routes_file(route_filename, inplace: false, do_exit: true)
42
+ filenames = files_to_work_on(route_filename, inplace: inplace)
43
+ if filenames.is_a?(Integer)
44
+ exit filenames if do_exit
45
+ return filenames
46
+ end
47
+
48
+ filenames.map! { |file| RailsRoutesAnalyzer.get_full_filename(file) }
23
49
 
24
- if suggestion.present?
25
- output << line.sub(/( # SUGGESTION.*)?$/, " # SUGGESTION #{suggestion}")
26
- else
27
- output << line
50
+ filenames.each do |filename|
51
+ unless File.exist?(filename)
52
+ $stderr.puts "Can't find routes file: #{filename}"
53
+ exit 1
54
+ end
55
+ end
56
+
57
+ if filenames.size != 1 && !inplace
58
+ raise ArgumentError, "got #{filenames.size} files but can annotate only one at a time to stdout"
59
+ end
60
+
61
+ filenames.each do |filename|
62
+ if inplace
63
+ if @force_overwrite || self.class.check_file_is_modifiable(filename, report: true)
64
+ content = annotated_file_content(filename)
65
+ File.open(filename, 'w') { |f| f.write content }
28
66
  end
67
+ else
68
+ puts annotated_file_content(filename)
29
69
  end
30
70
  end
31
71
  end
32
72
 
33
- def log_notice(message=nil, &block)
34
- return if ENV['RAILS_ENV'] == 'test'
35
- message ||= block.call if block
36
- STDERR.puts "# #{message}" if message.present?
73
+ protected
74
+
75
+ def log_notice(*args, &block)
76
+ self.class.log_notice(*args, &block)
37
77
  end
38
78
 
39
- def combined_suggestion_for(all_issues)
40
- return if all_issues.nil? || all_issues.none?(&:issue?)
79
+ class << self
80
+ def log_notice(message = nil)
81
+ return if ENV['RAILS_ENV'] == 'test'
82
+ message ||= yield if block_given?
83
+ $stderr.puts "# #{message}" if message.present?
84
+ end
41
85
 
42
- issues, non_issues = all_issues.partition(&:issue?)
86
+ def check_file_is_modifiable(filename, report: false, **kwargs)
87
+ unless filename.to_s.starts_with?(Rails.root.to_s)
88
+ log_notice "Refusing to modify files outside Rails root: #{Rails.root}" if report
89
+ return false
90
+ end
43
91
 
44
- context = {
45
- non_issues: non_issues.present?,
46
- num_controllers: all_issues.map(&:controller_class_name).uniq.count,
47
- }
92
+ check_file_git_status(filename, report: report, **kwargs)
93
+ end
48
94
 
49
- issues.map { |issue| issue.suggestion(**context) }.join(', ')
50
- end
95
+ def check_file_git_status(filename, report: false, skip_git: false, repo_root: Rails.root.to_s)
96
+ return skip_git if skip_git
51
97
 
52
- def annotate_routes_file(filename)
53
- filenames = @analysis.unique_issues_file_names
98
+ git = nil
99
+ begin
100
+ require 'git'
101
+ git = Git.open(repo_root)
102
+ rescue => e
103
+ log_notice "Couldn't access git repository at Rails root #{repo_root}. #{e.message}" if report
104
+ return false
105
+ end
54
106
 
55
- if filename.blank?
56
- if filenames.size > 1
57
- STDERR.puts "Please specify file to annotate with ANNOTATE='path/routes.rb' as you have more than one:\n#{filenames.join("\n ")}"
58
- exit 1
107
+ repo_relative_filename = filename.to_s.sub("#{repo_root}/", '')
108
+
109
+ # This seems to be required to force some kind of git status
110
+ # refresh because without it tests would randomly detect a file
111
+ # as modified by git-status when the file in fact has no changes.
112
+ #
113
+ # Currently randomly causes a NoMethodError exception but can still help.
114
+ begin
115
+ git.diff.each { |file| }
116
+ rescue NoMethodError # rubocop:disable Lint/HandleExceptions
117
+ end
118
+
119
+ if git.status.changed.key?(repo_relative_filename)
120
+ log_notice "Refusing to modify '#{repo_relative_filename}' as it has uncommited changes" if report
121
+ return false
59
122
  end
60
- filename = filenames.first
123
+
124
+ true
61
125
  end
126
+ end
62
127
 
63
- filename = RailsRoutesAnalyzer.get_full_filename(filename)
128
+ def annotatable_routes_files(inplace:)
129
+ filenames = @analysis.all_unique_issues_file_names
64
130
 
65
- unless File.exists?(filename)
66
- STDERR.puts "Can't routes find file: #{filename}"
67
- exit 1
131
+ if inplace && !@force_overwrite
132
+ filenames.select! { |filename| self.class.check_file_is_modifiable(filename) }
68
133
  end
69
134
 
70
- puts annotated_file_content(filename)
135
+ filenames
136
+ end
137
+
138
+ def files_to_work_on(filename, inplace:)
139
+ return [filename] if filename.present?
140
+
141
+ filenames = annotatable_routes_files(inplace: inplace)
142
+
143
+ if filenames.empty?
144
+ $stderr.puts "All routes are good, nothing to annotate"
145
+ return 0
146
+ elsif filenames.size > 1 && !inplace
147
+ $stderr.puts "Please specify routes file with ROUTES_FILE='path/routes.rb' as you have more than one file with problems:\n #{filenames.join("\n ")}"
148
+ return 1
149
+ end
150
+ filenames
71
151
  end
72
152
  end
73
153
 
@@ -1,15 +1,15 @@
1
- require 'rails'
1
+ require 'rails/version'
2
2
 
3
3
  module RailsRoutesAnalyzer
4
4
 
5
5
  # Plugs into ActionDispatch::Routing::Mapper::Mapping to help get detailed information
6
6
  # on which route was generated, exactly where and if there is a matching controller action
7
7
  module RouteInterceptor
8
- ROUTE_METHOD_REGEX = /action_dispatch\/routing\/mapper.rb:[0-9]+:in `(#{Regexp.union(*::RailsRoutesAnalyzer::ROUTE_METHODS)})'\z/
8
+ ROUTE_METHOD_REGEX = %r{action_dispatch/routing/mapper.rb:[0-9]+:in `(#{Regexp.union(*::RailsRoutesAnalyzer::ROUTE_METHODS)})'\z}
9
9
 
10
10
  def self.route_data
11
11
  {}.tap do |result|
12
- route_log.each do |(location, controller_name, action, request_methods)|
12
+ route_log.each do |(location, _controller_name, action, _request_methods)|
13
13
  (result[location] ||= []) << action
14
14
  end
15
15
  end
@@ -20,36 +20,35 @@ module RailsRoutesAnalyzer
20
20
  end
21
21
 
22
22
  # Finds the most interesting Rails.root file from the backtrace that called a method in mapper.rb
23
- def get_routes_rb_location
23
+ def routes_rb_location
24
24
  bt = caller
25
25
  base = 0
26
- while true
27
- index = bt[base..-1].index {|l| l =~ ROUTE_METHOD_REGEX }
26
+ loop do
27
+ index = bt[base..-1].index { |l| l =~ ROUTE_METHOD_REGEX }
28
28
  return "" if index.nil?
29
29
 
30
30
  next_line = bt[base + index + 1]
31
31
 
32
- if next_line =~ /action_dispatch\/routing\/mapper.rb/
32
+ if next_line =~ %r{action_dispatch/routing/mapper.rb}
33
33
  base += index + 1
34
34
  next
35
35
  else
36
- file_location = next_line[/:?\A#{Rails.root}\/(.*:[0-9]+)/, 1] || next_line
36
+ file_location = next_line[%r{:?\A#{Rails.root}\/(.*:[0-9]+)}, 1] || next_line
37
37
 
38
- bt[base + index] =~ ROUTE_METHOD_REGEX
39
- route_creation_method = $1
38
+ route_creation_method = bt[base + index][ROUTE_METHOD_REGEX, 1]
40
39
 
41
40
  return [file_location, route_creation_method]
42
41
  end
43
42
  end
44
43
  end
45
44
 
46
- if Rails.version =~ /\A3[.]/
45
+ if Rails::VERSION::MAJOR == 3
47
46
  def initialize(*args)
48
47
  super.tap do
49
48
  record_route(@options[:controller], @options[:action], conditions[:request_method])
50
49
  end
51
50
  end
52
- elsif Rails.version =~ /\A4\./
51
+ elsif Rails::VERSION::MAJOR == 4
53
52
  def initialize(*args)
54
53
  super.tap do
55
54
  record_route(@defaults[:controller], @defaults[:action], conditions[:request_method])
@@ -66,7 +65,7 @@ module RailsRoutesAnalyzer
66
65
  def record_route(controller_name, action, request_methods)
67
66
  return unless controller_name && action
68
67
 
69
- location = get_routes_rb_location + [controller_name]
68
+ location = routes_rb_location + [controller_name]
70
69
 
71
70
  if location[0].nil?
72
71
  puts "Failed to find call location for: #{controller_name}/#{action}"
@@ -1,109 +1,4 @@
1
- module RailsRoutesAnalyzer
2
-
3
- class RouteIssue < Hash
4
- %i[
5
- action
6
- action_names
7
- missing_actions
8
- present_actions
9
- controller_class_name
10
- controller_name
11
- error
12
- file_location
13
- route_creation_method
14
- suggested_param
15
- type
16
- verbose_message
17
- ].each do |name|
18
- define_method(name) { self[name] }
19
- end
20
-
21
- def initialize(opts={})
22
- self.update(opts)
23
- end
24
-
25
- def issue?
26
- type != :non_issue
27
- end
28
-
29
- def resource?
30
- type == :resources
31
- end
32
-
33
- def full_filename
34
- RailsRoutesAnalyzer.get_full_filename(file_location.sub(/:[0-9]*\z/, ''))
35
- end
36
-
37
- def line_number
38
- file_location[/:([0-9]+)\z/, 1].to_i
39
- end
40
-
41
- def human_readable
42
- case self[:type]
43
- when :non_issue
44
- ''
45
- when :no_controller
46
- "`#{route_creation_method}' call at #{file_location} there is no controller: #{controller_class_name} for '#{controller_name}' (actions: #{action_names.inspect})".tap do |msg|
47
- msg << " error: #{error}" if error.present?
48
- end
49
- when :no_action
50
- missing_actions.map do |action|
51
- "`#{route_creation_method} :#{action}' call at #{file_location} there is no matching action in #{controller_class_name}"
52
- end.tap do |result|
53
- return nil if result.size == 0
54
- return result[0] if result.size == 1
55
- end
56
- when :resources
57
- "`#{route_creation_method}' call at #{file_location} for #{controller_class_name} should use #{suggested_param}"
58
- else
59
- raise ArgumentError, "Unknown issue_type: #{self[:type].inspect}"
60
- end.tap do |message|
61
- message << "| #{verbose_message}" if verbose_message
62
- end
63
- end
64
-
65
- def suggestion(non_issues:, num_controllers:)
66
- case self[:type]
67
- when :non_issue
68
- nil
69
- when :no_controller
70
- if non_issues
71
- "remove case for #{controller_class_name} as it doesn't exist"
72
- else
73
- "delete, #{controller_class_name} not found"
74
- end
75
- when :no_action
76
- actions = format_actions(missing_actions)
77
- if non_issues
78
- "remove case#{'s' if missing_actions.size > 1} for #{actions}"
79
- else
80
- "delete line, #{actions} matches nothing"
81
- end.tap do |message|
82
- message << " for controller #{controller_class_name}" if num_controllers > 1
83
- end
84
- when :resources
85
- "use #{suggested_param}".tap do |message|
86
- if num_controllers > 1
87
- message << " only for #{controller_class_name}"
88
- end
89
- end
90
- else
91
- raise ArgumentError, "Unknown issue_type: #{self[:type].inspect}"
92
- end.tap do |message|
93
- message << "| #{verbose_message}" if verbose_message
94
- end
95
- end
96
-
97
- def format_actions(actions)
98
- case actions.size
99
- when 0
100
- when 1
101
- ":#{actions.first}"
102
- else
103
- list = actions.map { |action| ":#{action}" }.sort.join(', ')
104
- "[#{list}]"
105
- end
106
- end
107
- end
108
-
109
- end
1
+ require_relative 'route_issue/base'
2
+ require_relative 'route_issue/no_action'
3
+ require_relative 'route_issue/no_controller'
4
+ require_relative 'route_issue/resources'
@@ -0,0 +1,68 @@
1
+ require_relative '../route_call'
2
+
3
+ module RailsRoutesAnalyzer
4
+ module RouteIssue
5
+
6
+ class Base < Hash
7
+ def self.fields(*names)
8
+ names.each do |name|
9
+ define_method(name) { self[name] }
10
+ define_method("#{name}=") { |val| self[name] = val }
11
+ end
12
+ end
13
+
14
+ fields :route_call
15
+
16
+ delegate \
17
+ :action,
18
+ :action_names,
19
+ :controller_class_name,
20
+ :controller_name,
21
+ :file_location,
22
+ :route_creation_method,
23
+ :present_actions,
24
+ to: :route_call
25
+
26
+ def initialize(opts = {})
27
+ update(opts)
28
+ end
29
+
30
+ def human_readable_error(verbose: false)
31
+ human_readable_error_message.tap do |message|
32
+ append_verbose_message(message) if verbose
33
+ end
34
+ end
35
+
36
+ def verbose_message
37
+ end
38
+
39
+ def suggestion(verbose: false, **kwargs)
40
+ error_suggestion(**kwargs).tap do |message|
41
+ append_verbose_message(message) if verbose
42
+ end
43
+ end
44
+
45
+ def append_verbose_message(message)
46
+ verbose = verbose_message
47
+ message << "| #{verbose}" if verbose.present?
48
+ end
49
+
50
+ def try_to_fix_line(_line)
51
+ raise NotImplementedError, 'should be provided by subclasses'
52
+ end
53
+
54
+ def format_actions(actions)
55
+ case actions.size
56
+ when 0
57
+ nil
58
+ when 1
59
+ ":#{actions.first}"
60
+ else
61
+ list = actions.map { |action| ":#{action}" }.sort.join(', ')
62
+ "[#{list}]"
63
+ end
64
+ end
65
+ end
66
+
67
+ end
68
+ end