spoom 1.2.1 → 1.2.2

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