rspectre 0.0.1

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,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ba92a8e8133dfaf7b4b3482717139be699fb8f25
4
+ data.tar.gz: 760cdddf1a3aeb9db71b54fa637f8009218eccc1
5
+ SHA512:
6
+ metadata.gz: f60346768b3217d357455cc3f8c8e8d3048e0f1ee859ff7bf796e3f8b85f5a57457dcc3b2170e146baf211438ef4679c144de46c2fcf4d6c1c164d90d660e411
7
+ data.tar.gz: '06917b75a86f56f949b6edbd681af187b392af58ef4d8ccf567145ed7775914090b5ae0060516ef049cdea8b7358e336fbc30c6bf73b9501fdeff6100fbcdd38'
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
5
+
6
+ require 'shellwords'
7
+ require 'optparse'
8
+
9
+ require 'rspectre'
10
+
11
+ options = { rspec: %w[--fail-fast spec], :'auto-correct' => false }
12
+
13
+ OptionParser.new do |opts|
14
+ opts.banner = 'Usage: rspectre [options]'
15
+
16
+ opts.on('--rspec RSPEC_ARGS', 'Arguments to feed to RSpec.') do |value|
17
+ options[:rspec] = value.shellsplit
18
+ end
19
+
20
+ opts.on(
21
+ '--auto-correct',
22
+ 'Enables auto-correct.',
23
+ 'When auto-correct is enabled, rspectre will modify your source files in place and delete any'\
24
+ ' unused test setup.'
25
+ ) do |value|
26
+ options[:'auto-correct'] = value
27
+ end
28
+ end.parse(ARGV)
29
+
30
+ rspec, auto_correct = options.fetch_values(:rspec, :'auto-correct')
31
+
32
+ RSpectre::Runner.new(rspec, auto_correct).lint
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
4
+
5
+ require 'pathname'
6
+ require 'set'
7
+
8
+ require 'anima'
9
+ require 'concord'
10
+ require 'parser/current'
11
+ require 'rspec'
12
+ require 'unparser'
13
+
14
+ require 'rspectre/auto_corrector'
15
+ require 'rspectre/color'
16
+ require 'rspectre/node'
17
+ require 'rspectre/offense'
18
+ require 'rspectre/runner'
19
+ require 'rspectre/source_map'
20
+ require 'rspectre/source_map/parser'
21
+ require 'rspectre/tracker'
22
+
23
+ require 'rspectre/linter'
24
+ require 'rspectre/linter/unused_let'
25
+ require 'rspectre/linter/unused_subject'
26
+ require 'rspectre/linter/unused_shared_setup'
27
+
28
+ module RSpectre
29
+ TRACKER = Tracker.new
30
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class AutoCorrector < Parser::Rewriter
5
+ include Concord.new(:filename, :nodes)
6
+
7
+ def correct
8
+ buffer = Parser::Source::Buffer.new("(#{filename})")
9
+ buffer.source = File.read(filename)
10
+
11
+ File.open(filename, 'w') do |file|
12
+ file.write(rewrite(buffer, Parser::CurrentRuby.new.parse(buffer)))
13
+ end
14
+ end
15
+
16
+ def on_block(node)
17
+ remove(node.location.expression) if nodes.any? do |offense_node|
18
+ node == offense_node && node.location.line == offense_node.location.line
19
+ end
20
+
21
+ super
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ module Color
5
+ def self.red(str)
6
+ colorize(str, 31)
7
+ end
8
+
9
+ def self.yellow(str)
10
+ colorize(str, 33)
11
+ end
12
+
13
+ def self.light_blue(str)
14
+ colorize(str, 36)
15
+ end
16
+
17
+ def self.colorize(str, color_code)
18
+ "\e[#{color_code}m#{str}\e[0m"
19
+ end
20
+ private_class_method :colorize
21
+ end
22
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class Linter
5
+ SOURCE_FILES = {} # rubocop:disable Style/MutableConstant
6
+
7
+ def self.example_group
8
+ RSpec::Core::ExampleGroup
9
+ end
10
+
11
+ def self.register(selector, locations) # rubocop:disable Metrics/MethodLength
12
+ location = locations.first
13
+
14
+ file = File.realpath(location.path)
15
+ line = location.lineno
16
+
17
+ return unless file.to_s.start_with?(File.realpath(Dir.pwd))
18
+
19
+ raw_node = node_map(file).find_method(selector, line)
20
+
21
+ if raw_node
22
+ node = RSpectre::Node.new(file, line, raw_node)
23
+ TRACKER.register(self::TAG, node)
24
+ if block_given?
25
+ yield node
26
+ else
27
+ return node
28
+ end
29
+ end
30
+ end
31
+
32
+ def self.node_map(file)
33
+ SOURCE_FILES[file] ||= SourceMap.parse(file)
34
+ end
35
+
36
+ def self.record(node)
37
+ TRACKER.record(self::TAG, node)
38
+ end
39
+
40
+ def self.prepend_behavior(scope, method_name)
41
+ original_method = scope.instance_method(method_name)
42
+
43
+ scope.__send__(:define_method, method_name) do |*args, &block|
44
+ yield
45
+
46
+ original_method.bind(self).(*args, &block)
47
+ end
48
+ end
49
+
50
+ private_constant(*constants(false))
51
+ private_class_method(*singleton_methods(false) - %i[record register prepend_behavior])
52
+ end
53
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class Linter
5
+ class UnusedLet < self
6
+ TAG = 'UnusedLet'
7
+
8
+ def example_group.let(name, &block)
9
+ super(name, &block)
10
+
11
+ UnusedLet.register(:let, caller_locations) do |node|
12
+ UnusedLet.prepend_behavior(self, name) { UnusedLet.record(node) }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class Linter
5
+ class UnusedSharedSetup < self
6
+ TAG = 'UnusedSharedSetup'
7
+
8
+ def self.redefine_shared(receiver, method) # rubocop:disable Metrics/MethodLength
9
+ # Capture the original class method
10
+ original_method = receiver.method(method)
11
+
12
+ # Overwrite the class method using define_singleton_method
13
+ receiver.__send__(:define_singleton_method, method) do |name, *args, &block|
14
+ # When we can locate the source of the node, tag it
15
+ if (node = UnusedSharedSetup.register(method, caller_locations))
16
+ # And call the orignal
17
+ original_method.(name, *args) do |*shared_args|
18
+ # But record that it was used in a `before`
19
+ before { UnusedSharedSetup.record(node) }
20
+
21
+ # And then perform the original block in a `class_exec` like the original block was
22
+ # supposed to be
23
+ class_exec(*shared_args, &block)
24
+ end
25
+ else
26
+ # If we couldn't locate the source, just delegate to the original method.
27
+ original_method.(name, *args, &block)
28
+ end
29
+ end
30
+ end
31
+
32
+ if respond_to?(:shared_examples)
33
+ main = TOPLEVEL_BINDING.eval('self')
34
+ redefine_shared(main, :shared_examples)
35
+ redefine_shared(main, :shared_examples_for)
36
+ redefine_shared(main, :shared_context)
37
+ end
38
+
39
+ redefine_shared(RSpec, :shared_examples)
40
+ redefine_shared(RSpec, :shared_examples_for)
41
+ redefine_shared(RSpec, :shared_context)
42
+
43
+ # We cannot dynamically capture the original method for the example group like we can for the
44
+ # binding on main and for the RSpec constant because RSpec checks to see if the receiver of
45
+ # the method is the top level example group which would be the case if we redefined it in
46
+ # terms of the old method. I think we can probably do some kind of module inclusion to reduce
47
+ # this duplication (which is effectively what happens here anyway, i think), but this works
48
+ # for now.
49
+ def example_group.shared_examples(name, *args, &block)
50
+ if (node = UnusedSharedSetup.register(:shared_examples, caller_locations))
51
+ super(name, *args) do |*shared_args|
52
+ before { UnusedSharedSetup.record(node) }
53
+
54
+ class_exec(*shared_args, &block)
55
+ end
56
+ else
57
+ super(name, *args, &block)
58
+ end
59
+ end
60
+
61
+ def example_group.shared_examples_for(name, *args, &block)
62
+ if (node = UnusedSharedSetup.register(:shared_examples_for, caller_locations))
63
+ super(name, *args) do |*shared_args|
64
+ before { UnusedSharedSetup.record(node) }
65
+
66
+ class_exec(*shared_args, &block)
67
+ end
68
+ else
69
+ super(name, *args, &block)
70
+ end
71
+ end
72
+
73
+ def example_group.shared_context(name, *args, &block)
74
+ if (node = UnusedSharedSetup.register(:shared_context, caller_locations))
75
+ super(name, *args) do |*shared_args|
76
+ before { UnusedSharedSetup.record(node) }
77
+
78
+ class_exec(*shared_args, &block)
79
+ end
80
+ else
81
+ super(name, *args, &block)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class Linter
5
+ class UnusedSubject < self
6
+ TAG = 'UnusedSubject'
7
+
8
+ def example_group.subject(name = nil, &block)
9
+ super(*name, &block)
10
+
11
+ UnusedSubject.register(:subject, caller_locations) do |node|
12
+ UnusedSubject.prepend_behavior(self, :subject) { UnusedSubject.record(node) }
13
+ alias_method name, :subject if name
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class Node
5
+ include Concord::Public.new(:file, :line, :node)
6
+
7
+ def start_column
8
+ location.column + 1
9
+ end
10
+
11
+ def end_column
12
+ if single_line?
13
+ location.last_column + 1
14
+ else
15
+ source_line.length + 1
16
+ end
17
+ end
18
+
19
+ def source_line
20
+ location.expression.source_line.rstrip
21
+ end
22
+
23
+ private
24
+
25
+ def single_line?
26
+ line.equal?(location.last_line)
27
+ end
28
+
29
+ def location
30
+ node.children.first.location
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class Offense
5
+ include Anima.new(:file, :line, :source_line, :start_column, :end_column, :type)
6
+
7
+ DESCRIPTIONS = {
8
+ 'UnusedLet' => 'Unused `let` definition.',
9
+ 'UnusedSubject' => 'Unused `subject` definition.',
10
+ 'UnusedSharedSetup' => 'Unused `shared_examples`, `shared_examples_for`, or'\
11
+ ' `shared_context` definition.'
12
+ }.freeze
13
+
14
+ def self.parse(type, node)
15
+ new(
16
+ file: node.file,
17
+ line: node.line,
18
+ source_line: node.source_line,
19
+ start_column: node.start_column,
20
+ end_column: node.end_column,
21
+ type: type
22
+ )
23
+ end
24
+
25
+ def warn
26
+ puts to_s
27
+ end
28
+
29
+ def to_s
30
+ <<~DOC
31
+
32
+ #{source_id}: #{offense_type}: #{description}
33
+ #{source_line}
34
+ #{highlight}
35
+ DOC
36
+ end
37
+
38
+ def description
39
+ DESCRIPTIONS.fetch(type)
40
+ end
41
+
42
+ private
43
+
44
+ def source_id
45
+ Color.light_blue("#{file}:#{line}:#{start_column}")
46
+ end
47
+
48
+ def offense_type
49
+ Color.yellow(type)
50
+ end
51
+
52
+ def highlight
53
+ ' ' * (start_column - 1) + carets(end_column - start_column)
54
+ end
55
+
56
+ def carets(n)
57
+ Color.yellow('^' * n)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class Runner
5
+ include Concord.new(:rspec_arguments, :auto_correct)
6
+
7
+ EXIT_SUCCESS = 0
8
+
9
+ def lint
10
+ if runner.run_specs(RSpec.world.ordered_example_groups).equal?(EXIT_SUCCESS)
11
+ handle_offenses
12
+ else
13
+ exit_with_error
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def handle_offenses
20
+ if TRACKER.offenses?
21
+ auto_correct ? TRACKER.correct_offenses : TRACKER.report_offenses
22
+ end
23
+ end
24
+
25
+ def exit_with_error
26
+ abort(
27
+ Color.red(
28
+ 'Running the specs failed. Either your tests do not pass '\
29
+ 'normally or this is a bug in RSpectre.'
30
+ )
31
+ )
32
+ end
33
+
34
+ def runner
35
+ rspec_config = RSpec::Core::ConfigurationOptions.new(rspec_arguments)
36
+
37
+ RSpec::Core::Runner.new(rspec_config).tap { |runner| runner.setup($stderr, StringIO.new) }
38
+ end
39
+ end # Rspec
40
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class SourceMap
5
+ include Concord.new(:map)
6
+
7
+ def initialize
8
+ super(Hash.new { [] })
9
+ end
10
+ private_class_method :new
11
+
12
+ def self.parse(file)
13
+ self::Parser.new(file).populate(new)
14
+ end
15
+
16
+ def add(node)
17
+ return unless node.loc.expression
18
+
19
+ map[node.loc.first_line] <<= node
20
+ end
21
+
22
+ def find_method(target_selector, line)
23
+ candidates = find_methods(target_selector, line)
24
+
25
+ if candidates.one?
26
+ candidates.first
27
+ else
28
+ warn Color.yellow("Unable to resolve `#{target_selector}` on line #{line}.")
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def find_methods(target_selector, line)
35
+ block_nodes(line).select do |node|
36
+ send, = *node
37
+ _receiver, selector = *send
38
+
39
+ selector.equal?(target_selector)
40
+ end
41
+ end
42
+
43
+ def block_nodes(line)
44
+ map.fetch(line, []).select { |node| node.type.equal?(:block) }
45
+ end
46
+
47
+ class Null < self
48
+ public_class_method :new
49
+
50
+ def find_let(_)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class SourceMap
5
+ class Parser
6
+ include Concord.new(:file)
7
+
8
+ def populate(map)
9
+ walk(parsed_source) { |node| map.add(node) }
10
+
11
+ map.freeze
12
+ rescue ::Parser::SyntaxError => error
13
+ warn Color.yellow("Warning! Skipping #{file} due to parsing error!")
14
+ warn error.diagnostic.render
15
+ Null.new
16
+ end
17
+
18
+ private
19
+
20
+ def walk(node, &block)
21
+ yield node
22
+
23
+ node.children.each do |child|
24
+ next unless child.is_a?(::Parser::AST::Node)
25
+
26
+ walk(child, &block)
27
+ end
28
+ end
29
+
30
+ def parsed_source
31
+ ::Parser::CurrentRuby.parse(raw_source, file)
32
+ end
33
+
34
+ def raw_source
35
+ Pathname.new(file).read
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ class Tracker
5
+ include Concord.new(:registry, :tracker)
6
+
7
+ def initialize
8
+ super(Hash.new { Set.new }, Hash.new { Set.new })
9
+ end
10
+
11
+ def register(type, node)
12
+ registry[type] <<= node
13
+ end
14
+
15
+ def record(type, node)
16
+ tracker[type] <<= node
17
+ end
18
+
19
+ def report_offenses
20
+ offenses.each(&:warn)
21
+ abort
22
+ end
23
+
24
+ def offenses?
25
+ offenses.any?
26
+ end
27
+
28
+ def correct_offenses
29
+ registry.flat_map { |type, locations| (locations - tracker[type]).to_a }
30
+ .group_by(&:file)
31
+ .transform_values { |nodes| nodes.map(&:node) }
32
+ .each do |file, nodes|
33
+ AutoCorrector.new(file, nodes).correct
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def offenses
40
+ registry.flat_map do |type, locations|
41
+ missing = locations - tracker[type]
42
+
43
+ missing.map { |node| Offense.parse(type, node) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpectre
4
+ VERSION = '0.0.1'
5
+ end
metadata ADDED
@@ -0,0 +1,171 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rspectre
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Daniel Gollahon
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-03-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: anima
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: concord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: parser
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: unparser
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0.2'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.10'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.10'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.47'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.47'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.15'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.15'
125
+ description:
126
+ email:
127
+ executables:
128
+ - rspectre
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - bin/rspectre
133
+ - lib/rspectre.rb
134
+ - lib/rspectre/auto_corrector.rb
135
+ - lib/rspectre/color.rb
136
+ - lib/rspectre/linter.rb
137
+ - lib/rspectre/linter/unused_let.rb
138
+ - lib/rspectre/linter/unused_shared_setup.rb
139
+ - lib/rspectre/linter/unused_subject.rb
140
+ - lib/rspectre/node.rb
141
+ - lib/rspectre/offense.rb
142
+ - lib/rspectre/runner.rb
143
+ - lib/rspectre/source_map.rb
144
+ - lib/rspectre/source_map/parser.rb
145
+ - lib/rspectre/tracker.rb
146
+ - lib/rspectre/version.rb
147
+ homepage: http://github.com/dgollahon/rspectre
148
+ licenses:
149
+ - MIT
150
+ metadata: {}
151
+ post_install_message:
152
+ rdoc_options: []
153
+ require_paths:
154
+ - lib
155
+ required_ruby_version: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '2.3'
160
+ required_rubygems_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ version: '0'
165
+ requirements: []
166
+ rubyforge_project:
167
+ rubygems_version: 2.6.11
168
+ signing_key:
169
+ specification_version: 4
170
+ summary: A tool for linting RSpec test suites.
171
+ test_files: []