spoom 1.2.1 → 1.2.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b116c81229f1eb09a9e8676643fd54bb3436f7fa2534b06275d657bcd995f0c4
4
- data.tar.gz: 93431ed795ccdcb7aa3afcec9cd3bc468c5b1893bfac4f269c33df9f52e32055
3
+ metadata.gz: 5154e37a81129c70e93925cc2a545a0d7861fd645a3ec2422e6549efc0cb76b5
4
+ data.tar.gz: 30c9ecdc3599a37309180fea890a7888d53b430c8701e0970f503bf5731979f9
5
5
  SHA512:
6
- metadata.gz: 826328b46d178109253dc01197f1cf80102c2174d7dc5b3ac1d52c142fee9785e7100e4b444c4bba71e5303fa711d15f8368cd173177b3614b54ce14c60c5b09
7
- data.tar.gz: 60a28d2220e2507d9deaf12d7e3f51e5ea08418f79685c40aa469da5bc317dbddfded2c8d49cd0102035e5a990d9fa60693ae65a7d1e7538118768bea1140f7f
6
+ metadata.gz: a3246a42fa965e3ef75d0178ffe958a8cc5723812ecb86799b5e2e406d5da7487ffd738696ccb3f6d76ae140de281dab73dba3ec3d8af9fdef696820654ed869
7
+ data.tar.gz: ba4c8ab2d87ff4451f420e2d062c7cebdd19202bc91226fd131ff9ab9f838e7494fa43560ffa317fc0565d023ecceef184c02f762e215ba6451d3ec164e3c75a
data/Gemfile CHANGED
@@ -7,7 +7,6 @@ gemspec
7
7
 
8
8
  group :development do
9
9
  gem "debug"
10
- gem "ruby-lsp"
11
10
  gem "rubocop-shopify", require: false
12
11
  gem "rubocop-sorbet", require: false
13
12
  gem "tapioca", require: false
@@ -10,12 +10,18 @@ module Spoom
10
10
 
11
11
  requires_ancestor { Context }
12
12
 
13
- # Read the `contents` of the Gemfile in this context directory
13
+ # Read the contents of the Gemfile in this context directory
14
14
  sig { returns(T.nilable(String)) }
15
15
  def read_gemfile
16
16
  read("Gemfile")
17
17
  end
18
18
 
19
+ # Read the contents of the Gemfile.lock in this context directory
20
+ sig { returns(T.nilable(String)) }
21
+ def read_gemfile_lock
22
+ read("Gemfile.lock")
23
+ end
24
+
19
25
  # Set the `contents` of the Gemfile in this context directory
20
26
  sig { params(contents: String, append: T::Boolean).void }
21
27
  def write_gemfile!(contents, append: false)
@@ -41,17 +47,20 @@ module Spoom
41
47
  bundle("exec #{command}", version: version, capture_err: capture_err)
42
48
  end
43
49
 
50
+ sig { returns(T::Hash[String, Bundler::LazySpecification]) }
51
+ def gemfile_lock_specs
52
+ return {} unless file?("Gemfile.lock")
53
+
54
+ parser = Bundler::LockfileParser.new(read_gemfile_lock)
55
+ parser.specs.map { |spec| [spec.name, spec] }.to_h
56
+ end
57
+
44
58
  # Get `gem` version from the `Gemfile.lock` content
45
59
  #
46
60
  # Returns `nil` if `gem` cannot be found in the Gemfile.
47
61
  sig { params(gem: String).returns(T.nilable(String)) }
48
62
  def gem_version_from_gemfile_lock(gem)
49
- return nil unless file?("Gemfile.lock")
50
-
51
- content = read("Gemfile.lock").match(/^ #{gem} \(.*(\d+\.\d+\.\d+).*\)/)
52
- return nil unless content
53
-
54
- content[1]
63
+ gemfile_lock_specs[gem]&.version&.to_s
55
64
  end
56
65
  end
57
66
  end
@@ -43,6 +43,23 @@ module Spoom
43
43
  glob("*")
44
44
  end
45
45
 
46
+ sig do
47
+ params(
48
+ allow_extensions: T::Array[String],
49
+ allow_mime_types: T::Array[String],
50
+ exclude_patterns: T::Array[String],
51
+ ).returns(T::Array[String])
52
+ end
53
+ def collect_files(allow_extensions: [], allow_mime_types: [], exclude_patterns: [])
54
+ collector = FileCollector.new(
55
+ allow_extensions: allow_extensions,
56
+ allow_mime_types: allow_mime_types,
57
+ exclude_patterns: exclude_patterns,
58
+ )
59
+ collector.visit_path(absolute_path)
60
+ collector.files.map { |file| file.delete_prefix("#{absolute_path}/") }
61
+ end
62
+
46
63
  # Does `relative_path` point to an existing file in this context directory?
47
64
  sig { params(relative_path: String).returns(T::Boolean) }
48
65
  def file?(relative_path)
@@ -63,8 +63,18 @@ module Spoom
63
63
  git("checkout #{ref}")
64
64
  end
65
65
 
66
+ # Run `git checkout -b <branch-name> <ref>` in this context directory
67
+ sig { params(branch_name: String, ref: T.nilable(String)).returns(ExecResult) }
68
+ def git_checkout_new_branch!(branch_name, ref: nil)
69
+ if ref
70
+ git("checkout -b #{branch_name} #{ref}")
71
+ else
72
+ git("checkout -b #{branch_name}")
73
+ end
74
+ end
75
+
66
76
  # Run `git add . && git commit` in this context directory
67
- sig { params(message: String, time: Time, allow_empty: T::Boolean).void }
77
+ sig { params(message: String, time: Time, allow_empty: T::Boolean).returns(ExecResult) }
68
78
  def git_commit!(message: "message", time: Time.now.utc, allow_empty: false)
69
79
  git("add --all")
70
80
 
@@ -106,6 +116,12 @@ module Spoom
106
116
  git("log #{arg.join(" ")}")
107
117
  end
108
118
 
119
+ # Run `git push <remote> <ref>` in this context directory
120
+ sig { params(remote: String, ref: String, force: T::Boolean).returns(ExecResult) }
121
+ def git_push!(remote, ref, force: false)
122
+ git("push #{force ? "-f" : ""} #{remote} #{ref}")
123
+ end
124
+
109
125
  sig { params(arg: String).returns(ExecResult) }
110
126
  def git_show(*arg)
111
127
  git("show #{arg.join(" ")}")
@@ -69,7 +69,24 @@ module Spoom
69
69
  allowed_extensions = Spoom::Sorbet::Config::DEFAULT_ALLOWED_EXTENSIONS if allowed_extensions.empty?
70
70
  allowed_extensions -= [".rbi"] unless include_rbis
71
71
 
72
- excluded_patterns = config.ignore.map { |string| File.join("**", string, "**") }
72
+ excluded_patterns = config.ignore.map do |string|
73
+ # We need to simulate the behavior of Sorbet's `--ignore` flag.
74
+ #
75
+ # From Sorbet docs on `--ignore`:
76
+ # > Ignores input files that contain the given string in their paths (relative to the input path passed to
77
+ # > Sorbet). Strings beginning with / match against the prefix of these relative paths; others are substring
78
+ # > matchs. Matches must be against whole folder and file names, so `foo` matches `/foo/bar.rb` and
79
+ # > `/bar/foo/baz.rb` but not `/foo.rb` or `/foo2/bar.rb`.
80
+ string = if string.start_with?("/")
81
+ # Strings beginning with / match against the prefix of these relative paths
82
+ File.join(absolute_path, string)
83
+ else
84
+ # Others are substring matchs
85
+ File.join(absolute_path, "**", string)
86
+ end
87
+ # Matches must be against whole folder and file names
88
+ "#{string.delete_suffix("/")}{,/**}"
89
+ end
73
90
 
74
91
  collector = FileCollector.new(allow_extensions: allowed_extensions, exclude_patterns: excluded_patterns)
75
92
  collector.visit_paths(config.paths.map { |path| absolute_path_to(path) })
@@ -0,0 +1,98 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Deadcode
6
+ # A definition is a class, module, method, constant, etc. being defined in the code
7
+ class Definition < T::Struct
8
+ extend T::Sig
9
+
10
+ class Kind < T::Enum
11
+ enums do
12
+ AttrReader = new("attr_reader")
13
+ AttrWriter = new("attr_writer")
14
+ Class = new("class")
15
+ Constant = new("constant")
16
+ Method = new("method")
17
+ Module = new("module")
18
+ end
19
+ end
20
+
21
+ class Status < T::Enum
22
+ enums do
23
+ # A definition is marked as `ALIVE` if it has at least one reference with the same name
24
+ ALIVE = new
25
+ # A definition is marked as `DEAD` if it has no reference with the same name
26
+ DEAD = new
27
+ # A definition can be marked as `IGNORED` if it is not relevant for the analysis
28
+ IGNORED = new
29
+ end
30
+ end
31
+
32
+ const :kind, Kind
33
+ const :name, String
34
+ const :full_name, String
35
+ const :location, Location
36
+ const :status, Status, default: Status::DEAD
37
+
38
+ # Kind
39
+
40
+ sig { returns(T::Boolean) }
41
+ def attr_reader?
42
+ kind == Kind::AttrReader
43
+ end
44
+
45
+ sig { returns(T::Boolean) }
46
+ def attr_writer?
47
+ kind == Kind::AttrWriter
48
+ end
49
+
50
+ sig { returns(T::Boolean) }
51
+ def class?
52
+ kind == Kind::Class
53
+ end
54
+
55
+ sig { returns(T::Boolean) }
56
+ def constant?
57
+ kind == Kind::Constant
58
+ end
59
+
60
+ sig { returns(T::Boolean) }
61
+ def method?
62
+ kind == Kind::Method
63
+ end
64
+
65
+ sig { returns(T::Boolean) }
66
+ def module?
67
+ kind == Kind::Module
68
+ end
69
+
70
+ # Status
71
+
72
+ sig { returns(T::Boolean) }
73
+ def alive?
74
+ status == Status::ALIVE
75
+ end
76
+
77
+ sig { void }
78
+ def alive!
79
+ @status = Status::ALIVE
80
+ end
81
+
82
+ sig { returns(T::Boolean) }
83
+ def dead?
84
+ status == Status::DEAD
85
+ end
86
+
87
+ sig { returns(T::Boolean) }
88
+ def ignored?
89
+ status == Status::IGNORED
90
+ end
91
+
92
+ sig { void }
93
+ def ignored!
94
+ @status = Status::IGNORED
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,103 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # Copied from https://github.com/rails/rails/blob/main/actionview/lib/action_view/template/handlers/erb/erubi.rb.
5
+ #
6
+ # Copyright (c) David Heinemeier Hansson
7
+ #
8
+ # Permission is hereby granted, free of charge, to any person obtaining
9
+ # a copy of this software and associated documentation files (the
10
+ # "Software"), to deal in the Software without restriction, including
11
+ # without limitation the rights to use, copy, modify, merge, publish,
12
+ # distribute, sublicense, and/or sell copies of the Software, and to
13
+ # permit persons to whom the Software is furnished to do so, subject to
14
+ # the following conditions:
15
+ #
16
+ # The above copyright notice and this permission notice shall be
17
+ # included in all copies or substantial portions of the Software.
18
+ #
19
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
21
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
23
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
24
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
25
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
+ module Spoom
27
+ module Deadcode
28
+ # Custom engine to handle ERB templates as used by Rails
29
+ class ERB < ::Erubi::Engine
30
+ extend T::Sig
31
+
32
+ sig { params(input: T.untyped, properties: T.untyped).void }
33
+ def initialize(input, properties = {})
34
+ @newline_pending = 0
35
+
36
+ properties = Hash[properties]
37
+ properties[:bufvar] ||= "@output_buffer"
38
+ properties[:preamble] ||= ""
39
+ properties[:postamble] ||= "#{properties[:bufvar]}.to_s"
40
+ properties[:escapefunc] = ""
41
+
42
+ super
43
+ end
44
+
45
+ private
46
+
47
+ sig { params(text: T.untyped).void }
48
+ def add_text(text)
49
+ return if text.empty?
50
+
51
+ if text == "\n"
52
+ @newline_pending += 1
53
+ else
54
+ src << bufvar << ".safe_append='"
55
+ src << "\n" * @newline_pending if @newline_pending > 0
56
+ src << text.gsub(/['\\]/, '\\\\\&')
57
+ src << "'.freeze;"
58
+
59
+ @newline_pending = 0
60
+ end
61
+ end
62
+
63
+ BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/
64
+
65
+ sig { params(indicator: T.untyped, code: T.untyped).void }
66
+ def add_expression(indicator, code)
67
+ flush_newline_if_pending(src)
68
+
69
+ src << bufvar << if (indicator == "==") || @escape
70
+ ".safe_expr_append="
71
+ else
72
+ ".append="
73
+ end
74
+
75
+ if BLOCK_EXPR.match?(code)
76
+ src << " " << code
77
+ else
78
+ src << "(" << code << ");"
79
+ end
80
+ end
81
+
82
+ sig { params(code: T.untyped).void }
83
+ def add_code(code)
84
+ flush_newline_if_pending(src)
85
+ super
86
+ end
87
+
88
+ sig { params(_: T.untyped).void }
89
+ def add_postamble(_)
90
+ flush_newline_if_pending(src)
91
+ super
92
+ end
93
+
94
+ sig { params(src: T.untyped).void }
95
+ def flush_newline_if_pending(src)
96
+ if @newline_pending > 0
97
+ src << bufvar << ".safe_append='#{"\n" * @newline_pending}'.freeze;"
98
+ @newline_pending = 0
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,61 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Deadcode
6
+ class Index
7
+ extend T::Sig
8
+
9
+ sig { returns(T::Hash[String, T::Array[Definition]]) }
10
+ attr_reader :definitions
11
+
12
+ sig { returns(T::Hash[String, T::Array[Reference]]) }
13
+ attr_reader :references
14
+
15
+ sig { void }
16
+ def initialize
17
+ @definitions = T.let({}, T::Hash[String, T::Array[Definition]])
18
+ @references = T.let({}, T::Hash[String, T::Array[Reference]])
19
+ end
20
+
21
+ # Indexing
22
+
23
+ sig { params(definition: Definition).void }
24
+ def define(definition)
25
+ (@definitions[definition.name] ||= []) << definition
26
+ end
27
+
28
+ sig { params(reference: Reference).void }
29
+ def reference(reference)
30
+ (@references[reference.name] ||= []) << reference
31
+ end
32
+
33
+ # Mark all definitions having a reference of the same name as `alive`
34
+ #
35
+ # To be called once all the files have been indexed and all the definitions and references discovered.
36
+ sig { void }
37
+ def finalize!
38
+ @references.keys.each do |name|
39
+ definitions_for_name(name).each(&:alive!)
40
+ end
41
+ end
42
+
43
+ # Utils
44
+
45
+ sig { params(name: String).returns(T::Array[Definition]) }
46
+ def definitions_for_name(name)
47
+ @definitions[name] || []
48
+ end
49
+
50
+ sig { returns(T::Array[Definition]) }
51
+ def all_definitions
52
+ @definitions.values.flatten
53
+ end
54
+
55
+ sig { returns(T::Array[Reference]) }
56
+ def all_references
57
+ @references.values.flatten
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,394 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Deadcode
6
+ class Indexer < SyntaxTree::Visitor
7
+ extend T::Sig
8
+
9
+ sig { returns(String) }
10
+ attr_reader :path, :file_name
11
+
12
+ sig { returns(Index) }
13
+ attr_reader :index
14
+
15
+ sig { params(path: String, source: String, index: Index).void }
16
+ def initialize(path, source, index)
17
+ super()
18
+
19
+ @path = path
20
+ @file_name = T.let(File.basename(path), String)
21
+ @source = source
22
+ @index = index
23
+ @previous_node = T.let(nil, T.nilable(SyntaxTree::Node))
24
+ @names_nesting = T.let([], T::Array[String])
25
+ @nodes_nesting = T.let([], T::Array[SyntaxTree::Node])
26
+ @in_const_field = T.let(false, T::Boolean)
27
+ @in_opassign = T.let(false, T::Boolean)
28
+ @in_symbol_literal = T.let(false, T::Boolean)
29
+ end
30
+
31
+ # Visit
32
+
33
+ sig { override.params(node: T.nilable(SyntaxTree::Node)).void }
34
+ def visit(node)
35
+ return unless node
36
+
37
+ @nodes_nesting << node
38
+ super
39
+ @nodes_nesting.pop
40
+ @previous_node = node
41
+ end
42
+
43
+ sig { override.params(node: SyntaxTree::AliasNode).void }
44
+ def visit_alias(node)
45
+ reference_method(node_string(node.right), node)
46
+ end
47
+
48
+ sig { override.params(node: SyntaxTree::ARef).void }
49
+ def visit_aref(node)
50
+ super
51
+
52
+ reference_method("[]", node)
53
+ end
54
+
55
+ sig { override.params(node: SyntaxTree::ARefField).void }
56
+ def visit_aref_field(node)
57
+ super
58
+
59
+ reference_method("[]=", node)
60
+ end
61
+
62
+ sig { override.params(node: SyntaxTree::ArgBlock).void }
63
+ def visit_arg_block(node)
64
+ value = node.value
65
+
66
+ case value
67
+ when SyntaxTree::SymbolLiteral
68
+ # If the block call is something like `x.select(&:foo)`, we need to reference the `foo` method
69
+ reference_method(symbol_string(value), node)
70
+ when SyntaxTree::VCall
71
+ # If the block call is something like `x.select { ... }`, we need to visit the block
72
+ super
73
+ end
74
+ end
75
+
76
+ sig { override.params(node: SyntaxTree::Binary).void }
77
+ def visit_binary(node)
78
+ super
79
+
80
+ op = node.operator
81
+
82
+ # Reference the operator itself
83
+ reference_method(op.to_s, node)
84
+
85
+ case op
86
+ when :<, :>, :<=, :>=
87
+ # For comparison operators, we also reference the `<=>` method
88
+ reference_method("<=>", node)
89
+ end
90
+ end
91
+
92
+ sig { override.params(node: SyntaxTree::CallNode).void }
93
+ def visit_call(node)
94
+ visit_send(
95
+ Send.new(
96
+ node: node,
97
+ name: node_string(node.message),
98
+ recv: node.receiver,
99
+ args: call_args(node.arguments),
100
+ ),
101
+ )
102
+ end
103
+
104
+ sig { override.params(node: SyntaxTree::ClassDeclaration).void }
105
+ def visit_class(node)
106
+ const_name = node_string(node.constant)
107
+ @names_nesting << const_name
108
+ define_class(T.must(const_name.split("::").last), @names_nesting.join("::"), node)
109
+
110
+ # We do not call `super` here because we don't want to visit the `constant` again
111
+ visit(node.superclass) if node.superclass
112
+ visit(node.bodystmt)
113
+
114
+ @names_nesting.pop
115
+ end
116
+
117
+ sig { override.params(node: SyntaxTree::Command).void }
118
+ def visit_command(node)
119
+ visit_send(
120
+ Send.new(
121
+ node: node,
122
+ name: node_string(node.message),
123
+ args: call_args(node.arguments),
124
+ block: node.block,
125
+ ),
126
+ )
127
+ end
128
+
129
+ sig { override.params(node: SyntaxTree::CommandCall).void }
130
+ def visit_command_call(node)
131
+ visit_send(
132
+ Send.new(
133
+ node: node,
134
+ name: node_string(node.message),
135
+ recv: node.receiver,
136
+ args: call_args(node.arguments),
137
+ block: node.block,
138
+ ),
139
+ )
140
+ end
141
+
142
+ sig { override.params(node: SyntaxTree::Const).void }
143
+ def visit_const(node)
144
+ reference_constant(node.value, node) unless @in_symbol_literal
145
+ end
146
+
147
+ sig { override.params(node: SyntaxTree::ConstPathField).void }
148
+ def visit_const_path_field(node)
149
+ # We do not call `super` here because we don't want to visit the `constant` again
150
+ visit(node.parent)
151
+
152
+ name = node.constant.value
153
+ full_name = [*@names_nesting, node_string(node.parent), name].join("::")
154
+ define_constant(name, full_name, node)
155
+ end
156
+
157
+ sig { override.params(node: SyntaxTree::DefNode).void }
158
+ def visit_def(node)
159
+ super
160
+
161
+ name = node_string(node.name)
162
+ define_method(name, [*@names_nesting, name].join("::"), node)
163
+ end
164
+
165
+ sig { override.params(node: SyntaxTree::Field).void }
166
+ def visit_field(node)
167
+ visit(node.parent)
168
+
169
+ name = node.name
170
+ case name
171
+ when SyntaxTree::Const
172
+ name = name.value
173
+ full_name = [*@names_nesting, node_string(node.parent), name].join("::")
174
+ define_constant(name, full_name, node)
175
+ when SyntaxTree::Ident
176
+ reference_method(name.value, node) if @in_opassign
177
+ reference_method("#{name.value}=", node)
178
+ end
179
+ end
180
+
181
+ sig { override.params(node: SyntaxTree::ModuleDeclaration).void }
182
+ def visit_module(node)
183
+ const_name = node_string(node.constant)
184
+ @names_nesting << const_name
185
+ define_module(T.must(const_name.split("::").last), @names_nesting.join("::"), node)
186
+
187
+ # We do not call `super` here because we don't want to visit the `constant` again
188
+ visit(node.bodystmt)
189
+
190
+ @names_nesting.pop
191
+ end
192
+
193
+ sig { override.params(node: SyntaxTree::OpAssign).void }
194
+ def visit_opassign(node)
195
+ # Both `FOO = x` and `FOO += x` yield a VarField node, but the former is a constant definition and the latter is
196
+ # a constant reference. We need to distinguish between the two cases.
197
+ @in_opassign = true
198
+ super
199
+ @in_opassign = false
200
+ end
201
+
202
+ sig { params(send: Send).void }
203
+ def visit_send(send)
204
+ visit(send.recv)
205
+
206
+ case send.name
207
+ when "attr_reader"
208
+ send.args.each do |arg|
209
+ next unless arg.is_a?(SyntaxTree::SymbolLiteral)
210
+
211
+ name = symbol_string(arg)
212
+ define_attr_reader(name, [*@names_nesting, name].join("::"), arg)
213
+ end
214
+ when "attr_writer"
215
+ send.args.each do |arg|
216
+ next unless arg.is_a?(SyntaxTree::SymbolLiteral)
217
+
218
+ name = symbol_string(arg)
219
+ define_attr_writer("#{name}=", "#{[*@names_nesting, name].join("::")}=", arg)
220
+ end
221
+ when "attr_accessor"
222
+ send.args.each do |arg|
223
+ next unless arg.is_a?(SyntaxTree::SymbolLiteral)
224
+
225
+ name = symbol_string(arg)
226
+ full_name = [*@names_nesting, name].join("::")
227
+ define_attr_reader(name, full_name, arg)
228
+ define_attr_writer("#{name}=", "#{full_name}=", arg)
229
+ end
230
+ else
231
+ reference_method(send.name, send.node)
232
+ visit_all(send.args)
233
+ visit(send.block)
234
+ end
235
+ end
236
+
237
+ sig { override.params(node: SyntaxTree::SymbolLiteral).void }
238
+ def visit_symbol_literal(node)
239
+ # Something like `:FOO` will yield a Const node but we do not want to treat it as a constant reference.
240
+ # So we need to distinguish between the two cases.
241
+ @in_symbol_literal = true
242
+ super
243
+ @in_symbol_literal = false
244
+ end
245
+
246
+ sig { override.params(node: SyntaxTree::TopConstField).void }
247
+ def visit_top_const_field(node)
248
+ define_constant(node.constant.value, node.constant.value, node)
249
+ end
250
+
251
+ sig { override.params(node: SyntaxTree::VarField).void }
252
+ def visit_var_field(node)
253
+ value = node.value
254
+ case value
255
+ when SyntaxTree::Const
256
+ if @in_opassign
257
+ reference_constant(value.value, node)
258
+ else
259
+ name = value.value
260
+ define_constant(name, [*@names_nesting, name].join("::"), node)
261
+ end
262
+ when SyntaxTree::Ident
263
+ reference_method(value.value, node) if @in_opassign
264
+ reference_method("#{value.value}=", node)
265
+ end
266
+ end
267
+
268
+ sig { override.params(node: SyntaxTree::VCall).void }
269
+ def visit_vcall(node)
270
+ visit_send(Send.new(node: node, name: node_string(node.value)))
271
+ end
272
+
273
+ private
274
+
275
+ # Definition indexing
276
+
277
+ sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
278
+ def define_attr_reader(name, full_name, node)
279
+ definition = Definition.new(
280
+ kind: Definition::Kind::AttrReader,
281
+ name: name,
282
+ full_name: full_name,
283
+ location: node_location(node),
284
+ )
285
+ @index.define(definition)
286
+ end
287
+
288
+ sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
289
+ def define_attr_writer(name, full_name, node)
290
+ definition = Definition.new(
291
+ kind: Definition::Kind::AttrWriter,
292
+ name: name,
293
+ full_name: full_name,
294
+ location: node_location(node),
295
+ )
296
+ @index.define(definition)
297
+ end
298
+
299
+ sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
300
+ def define_class(name, full_name, node)
301
+ definition = Definition.new(
302
+ kind: Definition::Kind::Class,
303
+ name: name,
304
+ full_name: full_name,
305
+ location: node_location(node),
306
+ )
307
+ @index.define(definition)
308
+ end
309
+
310
+ sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
311
+ def define_constant(name, full_name, node)
312
+ definition = Definition.new(
313
+ kind: Definition::Kind::Constant,
314
+ name: name,
315
+ full_name: full_name,
316
+ location: node_location(node),
317
+ )
318
+ @index.define(definition)
319
+ end
320
+
321
+ sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
322
+ def define_method(name, full_name, node)
323
+ definition = Definition.new(
324
+ kind: Definition::Kind::Method,
325
+ name: name,
326
+ full_name: full_name,
327
+ location: node_location(node),
328
+ )
329
+ @index.define(definition)
330
+ end
331
+
332
+ sig { params(name: String, full_name: String, node: SyntaxTree::Node).void }
333
+ def define_module(name, full_name, node)
334
+ definition = Definition.new(
335
+ kind: Definition::Kind::Module,
336
+ name: name,
337
+ full_name: full_name,
338
+ location: node_location(node),
339
+ )
340
+ @index.define(definition)
341
+ end
342
+
343
+ # Reference indexing
344
+
345
+ sig { params(name: String, node: SyntaxTree::Node).void }
346
+ def reference_constant(name, node)
347
+ @index.reference(Reference.new(name: name, kind: Reference::Kind::Constant, location: node_location(node)))
348
+ end
349
+
350
+ sig { params(name: String, node: SyntaxTree::Node).void }
351
+ def reference_method(name, node)
352
+ @index.reference(Reference.new(name: name, kind: Reference::Kind::Method, location: node_location(node)))
353
+ end
354
+
355
+ # Node utils
356
+
357
+ sig { params(node: T.any(Symbol, SyntaxTree::Node)).returns(String) }
358
+ def node_string(node)
359
+ case node
360
+ when Symbol
361
+ node.to_s
362
+ else
363
+ T.must(@source[node.location.start_char...node.location.end_char])
364
+ end
365
+ end
366
+
367
+ sig { params(node: SyntaxTree::Node).returns(Location) }
368
+ def node_location(node)
369
+ Location.from_syntax_tree(@path, node.location)
370
+ end
371
+
372
+ sig { params(node: SyntaxTree::Node).returns(String) }
373
+ def symbol_string(node)
374
+ node_string(node).delete_prefix(":")
375
+ end
376
+
377
+ sig do
378
+ params(
379
+ node: T.any(SyntaxTree::Args, SyntaxTree::ArgParen, SyntaxTree::ArgsForward, NilClass),
380
+ ).returns(T::Array[SyntaxTree::Node])
381
+ end
382
+ def call_args(node)
383
+ case node
384
+ when SyntaxTree::ArgParen
385
+ call_args(node.arguments)
386
+ when SyntaxTree::Args
387
+ node.parts
388
+ else
389
+ []
390
+ end
391
+ end
392
+ end
393
+ end
394
+ end
@@ -0,0 +1,58 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Deadcode
6
+ class Location
7
+ extend T::Sig
8
+
9
+ include Comparable
10
+
11
+ class LocationError < Spoom::Error; end
12
+
13
+ class << self
14
+ extend T::Sig
15
+
16
+ sig { params(file: String, location: SyntaxTree::Location).returns(Location) }
17
+ def from_syntax_tree(file, location)
18
+ new(file, location.start_line, location.start_column, location.end_line, location.end_column)
19
+ end
20
+ end
21
+
22
+ sig { returns(String) }
23
+ attr_reader :file
24
+
25
+ sig { returns(Integer) }
26
+ attr_reader :start_line, :start_column, :end_line, :end_column
27
+
28
+ sig do
29
+ params(
30
+ file: String,
31
+ start_line: Integer,
32
+ start_column: Integer,
33
+ end_line: Integer,
34
+ end_column: Integer,
35
+ ).void
36
+ end
37
+ def initialize(file, start_line, start_column, end_line, end_column)
38
+ @file = file
39
+ @start_line = start_line
40
+ @start_column = start_column
41
+ @end_line = end_line
42
+ @end_column = end_column
43
+ end
44
+
45
+ sig { override.params(other: BasicObject).returns(T.nilable(Integer)) }
46
+ def <=>(other)
47
+ return nil unless Location === other
48
+
49
+ to_s <=> other.to_s
50
+ end
51
+
52
+ sig { returns(String) }
53
+ def to_s
54
+ "#{@file}:#{@start_line}:#{@start_column}-#{@end_line}:#{@end_column}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,34 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Deadcode
6
+ # A reference is a call to a method or a constant
7
+ class Reference < T::Struct
8
+ extend T::Sig
9
+
10
+ class Kind < T::Enum
11
+ enums do
12
+ Constant = new
13
+ Method = new
14
+ end
15
+ end
16
+
17
+ const :kind, Kind
18
+ const :name, String
19
+ const :location, Location
20
+
21
+ # Kind
22
+
23
+ sig { returns(T::Boolean) }
24
+ def constant?
25
+ kind == Kind::Constant
26
+ end
27
+
28
+ sig { returns(T::Boolean) }
29
+ def method?
30
+ kind == Kind::Method
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,18 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Deadcode
6
+ # An abstraction to simplify handling of SyntaxTree::CallNode, SyntaxTree::Command, SyntaxTree::CommandCall and
7
+ # SyntaxTree::VCall nodes.
8
+ class Send < T::Struct
9
+ extend T::Sig
10
+
11
+ const :node, SyntaxTree::Node
12
+ const :name, String
13
+ const :recv, T.nilable(SyntaxTree::Node), default: nil
14
+ const :args, T::Array[SyntaxTree::Node], default: []
15
+ const :block, T.nilable(SyntaxTree::Node), default: nil
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,55 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "erubi"
5
+ require "syntax_tree"
6
+
7
+ require_relative "deadcode/erb"
8
+ require_relative "deadcode/index"
9
+ require_relative "deadcode/indexer"
10
+
11
+ require_relative "deadcode/location"
12
+ require_relative "deadcode/definition"
13
+ require_relative "deadcode/reference"
14
+ require_relative "deadcode/send"
15
+
16
+ module Spoom
17
+ module Deadcode
18
+ class Error < Spoom::Error
19
+ extend T::Sig
20
+ extend T::Helpers
21
+
22
+ abstract!
23
+
24
+ sig { params(message: String, parent: Exception).void }
25
+ def initialize(message, parent:)
26
+ super(message)
27
+ set_backtrace(parent.backtrace)
28
+ end
29
+ end
30
+
31
+ class ParserError < Error; end
32
+ class IndexerError < Error; end
33
+
34
+ class << self
35
+ extend T::Sig
36
+
37
+ sig { params(index: Index, ruby: String, file: String).void }
38
+ def index_ruby(index, ruby, file:)
39
+ node = SyntaxTree.parse(ruby)
40
+ visitor = Spoom::Deadcode::Indexer.new(file, ruby, index)
41
+ visitor.visit(node)
42
+ rescue SyntaxTree::Parser::ParseError => e
43
+ raise ParserError.new("Error while parsing #{file} (#{e.message} at #{e.lineno}:#{e.column})", parent: e)
44
+ rescue => e
45
+ raise IndexerError.new("Error while indexing #{file} (#{e.message})", parent: e)
46
+ end
47
+
48
+ sig { params(index: Index, erb: String, file: String).void }
49
+ def index_erb(index, erb, file:)
50
+ ruby = ERB.new(erb).src
51
+ index_ruby(index, ruby, file: file)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -12,15 +12,21 @@ module Spoom
12
12
  #
13
13
  # If `allow_extensions` is empty, all files are collected.
14
14
  # If `allow_extensions` is an array of extensions, only files with one of these extensions are collected.
15
+ #
16
+ # If `allow_mime_types` is empty, all files are collected.
17
+ # If `allow_mime_types` is an array of mimetypes, files without an extension are collected if their mimetype is in
18
+ # the list.
15
19
  sig do
16
20
  params(
17
21
  allow_extensions: T::Array[String],
22
+ allow_mime_types: T::Array[String],
18
23
  exclude_patterns: T::Array[String],
19
24
  ).void
20
25
  end
21
- def initialize(allow_extensions: [], exclude_patterns: [])
26
+ def initialize(allow_extensions: [], allow_mime_types: [], exclude_patterns: [])
22
27
  @files = T.let([], T::Array[String])
23
28
  @allow_extensions = allow_extensions
29
+ @allow_mime_types = allow_mime_types
24
30
  @exclude_patterns = exclude_patterns
25
31
  end
26
32
 
@@ -68,12 +74,29 @@ module Spoom
68
74
  return false if @allow_extensions.empty?
69
75
 
70
76
  extension = File.extname(path)
71
- @allow_extensions.none? { |allowed| extension == allowed }
77
+ if extension.empty?
78
+ return true if @allow_mime_types.empty?
79
+
80
+ mime = mime_type_for(path)
81
+ @allow_mime_types.none? { |allowed| mime == allowed }
82
+ else
83
+ @allow_extensions.none? { |allowed| extension == allowed }
84
+ end
72
85
  end
73
86
 
74
87
  sig { params(path: String).returns(T::Boolean) }
75
88
  def excluded_path?(path)
76
- @exclude_patterns.any? { |pattern| File.fnmatch?(pattern, path) }
89
+ @exclude_patterns.any? do |pattern|
90
+ # Use `FNM_PATHNAME` so patterns do not match directory separators
91
+ # Use `FNM_EXTGLOB` to allow file globbing through `{a,b}`
92
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB)
93
+ end
94
+ end
95
+
96
+ sig { params(path: String).returns(T.nilable(String)) }
97
+ def mime_type_for(path)
98
+ # The `file` command appears to be hanging on MacOS for some files so we timeout after 1s.
99
+ %x{timeout 1s file --mime-type -b '#{path}'}.split("; ").first&.strip
77
100
  end
78
101
  end
79
102
  end
data/lib/spoom/printer.rb CHANGED
@@ -10,8 +10,6 @@ module Spoom
10
10
 
11
11
  include Colorize
12
12
 
13
- abstract!
14
-
15
13
  sig { returns(T.any(IO, StringIO)) }
16
14
  attr_accessor :out
17
15
 
@@ -12,9 +12,6 @@ module Spoom
12
12
  class Message
13
13
  extend T::Sig
14
14
 
15
- sig { returns(String) }
16
- attr_reader :jsonrpc
17
-
18
15
  sig { void }
19
16
  def initialize
20
17
  @jsonrpc = T.let("2.0", String)
@@ -43,9 +40,6 @@ module Spoom
43
40
  sig { returns(Integer) }
44
41
  attr_reader :id
45
42
 
46
- sig { returns(String) }
47
- attr_reader :method
48
-
49
43
  sig { returns(T::Hash[T.untyped, T.untyped]) }
50
44
  attr_reader :params
51
45
 
@@ -310,7 +310,7 @@ module Spoom
310
310
  extend T::Sig
311
311
 
312
312
  sig { returns(T::Set[Integer]) }
313
- attr_accessor :seen
313
+ attr_reader :seen
314
314
 
315
315
  sig { returns(T.nilable(String)) }
316
316
  attr_accessor :prefix
@@ -28,7 +28,7 @@ module Spoom
28
28
  T::Array[String],
29
29
  )
30
30
 
31
- SIGIL_REGEXP = T.let(/^#[\ t]*typed[\ t]*:[ \t]*(\w*)[ \t]*/.freeze, Regexp)
31
+ SIGIL_REGEXP = T.let(/^#[\ t]*typed[\ t]*:[ \t]*(\w*)[ \t]*/, Regexp)
32
32
 
33
33
  class << self
34
34
  extend T::Sig
data/lib/spoom/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Spoom
5
- VERSION = "1.2.1"
5
+ VERSION = "1.2.2"
6
6
  end
data/lib/spoom.rb CHANGED
@@ -15,6 +15,7 @@ end
15
15
  require "spoom/file_collector"
16
16
  require "spoom/context"
17
17
  require "spoom/colors"
18
+ require "spoom/deadcode"
18
19
  require "spoom/sorbet"
19
20
  require "spoom/cli"
20
21
  require "spoom/version"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spoom
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexandre Terrasa
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-03-31 00:00:00.000000000 Z
11
+ date: 2023-06-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: 13.0.1
69
+ - !ruby/object:Gem::Dependency
70
+ name: erubi
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 1.10.0
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 1.10.0
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: sorbet
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -94,6 +108,20 @@ dependencies:
94
108
  - - ">="
95
109
  - !ruby/object:Gem::Version
96
110
  version: 0.5.9204
111
+ - !ruby/object:Gem::Dependency
112
+ name: syntax_tree
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 6.1.1
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: 6.1.1
97
125
  - !ruby/object:Gem::Dependency
98
126
  name: thor
99
127
  requirement: !ruby/object:Gem::Requirement
@@ -143,6 +171,14 @@ files:
143
171
  - lib/spoom/coverage/d3/timeline.rb
144
172
  - lib/spoom/coverage/report.rb
145
173
  - lib/spoom/coverage/snapshot.rb
174
+ - lib/spoom/deadcode.rb
175
+ - lib/spoom/deadcode/definition.rb
176
+ - lib/spoom/deadcode/erb.rb
177
+ - lib/spoom/deadcode/index.rb
178
+ - lib/spoom/deadcode/indexer.rb
179
+ - lib/spoom/deadcode/location.rb
180
+ - lib/spoom/deadcode/reference.rb
181
+ - lib/spoom/deadcode/send.rb
146
182
  - lib/spoom/file_collector.rb
147
183
  - lib/spoom/file_tree.rb
148
184
  - lib/spoom/printer.rb
@@ -173,14 +209,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
173
209
  requirements:
174
210
  - - ">="
175
211
  - !ruby/object:Gem::Version
176
- version: 2.7.0
212
+ version: 3.0.0
177
213
  required_rubygems_version: !ruby/object:Gem::Requirement
178
214
  requirements:
179
215
  - - ">="
180
216
  - !ruby/object:Gem::Version
181
217
  version: '0'
182
218
  requirements: []
183
- rubygems_version: 3.4.9
219
+ rubygems_version: 3.4.14
184
220
  signing_key:
185
221
  specification_version: 4
186
222
  summary: Useful tools for Sorbet enthusiasts.