rails_routes_analyzer 1.0.4 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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