spoom 1.2.0 → 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: 323ebe199d0efed57d5e92af12eb377c3c5af35e468c83db851b27f86b44117b
4
- data.tar.gz: c0fb24e2af1af3c0e460bb7c06ba9f3cff9cda471ec829ba00742b40dd67ab84
3
+ metadata.gz: 5154e37a81129c70e93925cc2a545a0d7861fd645a3ec2422e6549efc0cb76b5
4
+ data.tar.gz: 30c9ecdc3599a37309180fea890a7888d53b430c8701e0970f503bf5731979f9
5
5
  SHA512:
6
- metadata.gz: faa5f86d378dd4cf56aa6e680cdee5717c59069b464b823bebfc63e7c8a7a40aeb7a75d86ec4dd0c69e025c4ad397eff740fdfa2ac134f49868736197836790a
7
- data.tar.gz: 67f7d4bc72282ff4bea575e85c2eed80f7195f94d3b518e3a84af57085bed90c757aedab5cfe33fb6d14dc96b62f21c6e8aa8cb56b79d4e9b148318a7c713e98
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
@@ -54,6 +54,7 @@ module Spoom
54
54
  dry = options[:dry]
55
55
  only = options[:only]
56
56
  cmd = options[:suggest_bump_command]
57
+ directory = File.expand_path(directory)
57
58
  exec_path = File.expand_path(self.exec_path)
58
59
 
59
60
  unless Sorbet::Sigils.valid_strictness?(from)
@@ -73,11 +74,9 @@ module Spoom
73
74
 
74
75
  say("Checking files...")
75
76
 
76
- directory = File.expand_path(directory)
77
- files_to_bump = Sorbet::Sigils.files_with_sigil_strictness(directory, from)
78
-
79
- files_from_config = context.srb_files.map { |file| File.expand_path(file) }
80
- files_to_bump.select! { |file| files_from_config.include?(file) }
77
+ files_to_bump = context.srb_files_with_strictness(from, include_rbis: false)
78
+ .map { |file| File.expand_path(file, context.absolute_path) }
79
+ .select { |file| file.start_with?(directory) }
81
80
 
82
81
  if only
83
82
  list = File.read(only).lines.map { |file| File.expand_path(file.strip) }
data/lib/spoom/cli.rb CHANGED
@@ -39,23 +39,19 @@ module Spoom
39
39
 
40
40
  desc "files", "List all the files typechecked by Sorbet"
41
41
  option :tree, type: :boolean, default: true, desc: "Display list as an indented tree"
42
- option :rbi, type: :boolean, default: true, desc: "Show RBI files"
42
+ option :rbi, type: :boolean, default: false, desc: "Show RBI files"
43
43
  def files
44
44
  context = context_requiring_sorbet!
45
- files = context.srb_files
46
-
47
- unless options[:rbi]
48
- files = files.reject { |file| file.end_with?(".rbi") }
49
- end
50
45
 
46
+ files = context.srb_files(include_rbis: options[:rbi])
51
47
  if files.empty?
52
48
  say_error("No file matching `#{Sorbet::CONFIG_PATH}`")
53
49
  exit(1)
54
50
  end
55
51
 
56
52
  if options[:tree]
57
- tree = FileTree.new(files, strip_prefix: exec_path)
58
- tree.print(colors: options[:color], indent_level: 0)
53
+ tree = FileTree.new(files)
54
+ tree.print_with_strictnesses(context, colors: options[:color])
59
55
  else
60
56
  puts files
61
57
  end
@@ -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(" ")}")
@@ -61,14 +61,49 @@ module Spoom
61
61
  end
62
62
 
63
63
  # List all files typechecked by Sorbet from its `config`
64
- sig { params(with_config: T.nilable(Spoom::Sorbet::Config)).returns(T::Array[String]) }
65
- def srb_files(with_config: nil)
64
+ sig { params(with_config: T.nilable(Spoom::Sorbet::Config), include_rbis: T::Boolean).returns(T::Array[String]) }
65
+ def srb_files(with_config: nil, include_rbis: true)
66
66
  config = with_config || sorbet_config
67
- regs = config.ignore.map { |string| Regexp.new(Regexp.escape(string)) }
68
- exts = config.allowed_extensions.empty? ? [".rb", ".rbi"] : config.allowed_extensions
69
- glob("**/*{#{exts.join(",")}}").reject do |f|
70
- regs.any? { |re| re.match?(f) }
71
- end.sort
67
+
68
+ allowed_extensions = config.allowed_extensions
69
+ allowed_extensions = Spoom::Sorbet::Config::DEFAULT_ALLOWED_EXTENSIONS if allowed_extensions.empty?
70
+ allowed_extensions -= [".rbi"] unless include_rbis
71
+
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
90
+
91
+ collector = FileCollector.new(allow_extensions: allowed_extensions, exclude_patterns: excluded_patterns)
92
+ collector.visit_paths(config.paths.map { |path| absolute_path_to(path) })
93
+ collector.files.map { |file| file.delete_prefix("#{absolute_path}/") }.sort
94
+ end
95
+
96
+ # List all files typechecked by Sorbet from its `config` that matches `strictness`
97
+ sig do
98
+ params(
99
+ strictness: String,
100
+ with_config: T.nilable(Spoom::Sorbet::Config),
101
+ include_rbis: T::Boolean,
102
+ ).returns(T::Array[String])
103
+ end
104
+ def srb_files_with_strictness(strictness, with_config: nil, include_rbis: true)
105
+ srb_files(with_config: with_config, include_rbis: include_rbis)
106
+ .select { |file| read_file_strictness(file) == strictness }
72
107
  end
73
108
 
74
109
  sig { params(arg: String, sorbet_bin: T.nilable(String), capture_err: T::Boolean).returns(T.nilable(String)) }
@@ -148,48 +148,34 @@ module Spoom
148
148
  class Sigils < CircleMap
149
149
  extend T::Sig
150
150
 
151
- sig { params(id: String, sigils_tree: FileTree).void }
152
- def initialize(id, sigils_tree)
153
- @scores = T.let({}, T::Hash[FileTree::Node, Float])
154
- @strictnesses = T.let({}, T::Hash[FileTree::Node, T.nilable(String)])
155
- @sigils_tree = sigils_tree
156
- super(id, sigils_tree.roots.map { |r| tree_node_to_json(r) })
151
+ sig do
152
+ params(
153
+ id: String,
154
+ file_tree: FileTree,
155
+ nodes_strictnesses: T::Hash[FileTree::Node, T.nilable(String)],
156
+ nodes_scores: T::Hash[FileTree::Node, Float],
157
+ ).void
158
+ end
159
+ def initialize(id, file_tree, nodes_strictnesses, nodes_scores)
160
+ @nodes_strictnesses = nodes_strictnesses
161
+ @nodes_scores = nodes_scores
162
+ super(id, file_tree.roots.map { |r| tree_node_to_json(r) })
157
163
  end
158
164
 
159
165
  sig { params(node: FileTree::Node).returns(T::Hash[Symbol, T.untyped]) }
160
166
  def tree_node_to_json(node)
161
167
  if node.children.empty?
162
- return { name: node.name, strictness: tree_node_strictness(node) }
163
- end
164
-
165
- {
166
- name: node.name,
167
- children: node.children.values.map { |n| tree_node_to_json(n) },
168
- score: tree_node_score(node),
169
- }
170
- end
171
-
172
- sig { params(node: FileTree::Node).returns(T.nilable(String)) }
173
- def tree_node_strictness(node)
174
- prefix = @sigils_tree.strip_prefix
175
- path = node.path
176
- path = "#{prefix}/#{path}" if prefix
177
- @strictnesses[node] ||= Spoom::Sorbet::Sigils.file_strictness(path)
178
- end
179
-
180
- sig { params(node: FileTree::Node).returns(Float) }
181
- def tree_node_score(node)
182
- unless @scores.key?(node)
183
- if node.name =~ /\.rbi?$/
184
- case tree_node_strictness(node)
185
- when "true", "strict", "strong"
186
- @scores[node] = 1.0
187
- end
188
- elsif !node.children.empty?
189
- @scores[node] = node.children.values.sum { |n| tree_node_score(n) } / node.children.size.to_f
190
- end
168
+ {
169
+ name: node.name,
170
+ strictness: @nodes_strictnesses.fetch(node, "false"),
171
+ }
172
+ else
173
+ {
174
+ name: node.name,
175
+ children: node.children.values.map { |n| tree_node_to_json(n) },
176
+ score: @nodes_scores.fetch(node, 0.0),
177
+ }
191
178
  end
192
- @scores[node] || 0.0
193
179
  end
194
180
  end
195
181
  end
@@ -153,9 +153,24 @@ module Spoom
153
153
  class Map < Card
154
154
  extend T::Sig
155
155
 
156
- sig { params(sigils_tree: FileTree, title: String).void }
157
- def initialize(sigils_tree:, title: "Strictness Map")
158
- super(title: title, body: D3::CircleMap::Sigils.new("map_sigils", sigils_tree).html)
156
+ sig do
157
+ params(
158
+ file_tree: FileTree,
159
+ nodes_strictnesses: T::Hash[FileTree::Node, T.nilable(String)],
160
+ nodes_strictness_scores: T::Hash[FileTree::Node, Float],
161
+ title: String,
162
+ ).void
163
+ end
164
+ def initialize(file_tree:, nodes_strictnesses:, nodes_strictness_scores:, title: "Strictness Map")
165
+ super(
166
+ title: title,
167
+ body: D3::CircleMap::Sigils.new(
168
+ "map_sigils",
169
+ file_tree,
170
+ nodes_strictnesses,
171
+ nodes_strictness_scores,
172
+ ).html
173
+ )
159
174
  end
160
175
  end
161
176
 
@@ -246,27 +261,14 @@ module Spoom
246
261
  class Report < Page
247
262
  extend T::Sig
248
263
 
249
- sig { returns(String) }
250
- attr_reader :project_name
251
-
252
- sig { returns(T.nilable(String)) }
253
- attr_reader :sorbet_intro_commit
254
-
255
- sig { returns(T.nilable(Time)) }
256
- attr_reader :sorbet_intro_date
257
-
258
- sig { returns(T::Array[Snapshot]) }
259
- attr_reader :snapshots
260
-
261
- sig { returns(FileTree) }
262
- attr_reader :sigils_tree
263
-
264
264
  sig do
265
265
  params(
266
266
  project_name: String,
267
267
  palette: D3::ColorPalette,
268
268
  snapshots: T::Array[Snapshot],
269
- sigils_tree: FileTree,
269
+ file_tree: FileTree,
270
+ nodes_strictnesses: T::Hash[FileTree::Node, T.nilable(String)],
271
+ nodes_strictness_scores: T::Hash[FileTree::Node, Float],
270
272
  sorbet_intro_commit: T.nilable(String),
271
273
  sorbet_intro_date: T.nilable(Time),
272
274
  ).void
@@ -275,24 +277,28 @@ module Spoom
275
277
  project_name:,
276
278
  palette:,
277
279
  snapshots:,
278
- sigils_tree:,
280
+ file_tree:,
281
+ nodes_strictnesses:,
282
+ nodes_strictness_scores:,
279
283
  sorbet_intro_commit: nil,
280
284
  sorbet_intro_date: nil
281
285
  )
282
286
  super(title: project_name, palette: palette)
283
287
  @project_name = project_name
284
288
  @snapshots = snapshots
285
- @sigils_tree = sigils_tree
289
+ @file_tree = file_tree
290
+ @nodes_strictnesses = nodes_strictnesses
291
+ @nodes_strictness_scores = nodes_strictness_scores
286
292
  @sorbet_intro_commit = sorbet_intro_commit
287
293
  @sorbet_intro_date = sorbet_intro_date
288
294
  end
289
295
 
290
296
  sig { override.returns(String) }
291
297
  def header_html
292
- last = T.must(snapshots.last)
298
+ last = T.must(@snapshots.last)
293
299
  <<~ERB
294
300
  <h1 class="display-3">
295
- #{project_name}
301
+ #{@project_name}
296
302
  <span class="badge badge-pill badge-dark" style="font-size: 20%;">#{last.commit_sha}</span>
297
303
  </h1>
298
304
  ERB
@@ -300,17 +306,24 @@ module Spoom
300
306
 
301
307
  sig { override.returns(T::Array[Cards::Card]) }
302
308
  def cards
303
- last = T.must(snapshots.last)
309
+ last = T.must(@snapshots.last)
304
310
  cards = []
305
311
  cards << Cards::Snapshot.new(snapshot: last)
306
- cards << Cards::Map.new(sigils_tree: sigils_tree)
307
- cards << Cards::Timeline::Sigils.new(snapshots: snapshots)
308
- cards << Cards::Timeline::Calls.new(snapshots: snapshots)
309
- cards << Cards::Timeline::Sigs.new(snapshots: snapshots)
310
- cards << Cards::Timeline::RBIs.new(snapshots: snapshots)
311
- cards << Cards::Timeline::Versions.new(snapshots: snapshots)
312
- cards << Cards::Timeline::Runtimes.new(snapshots: snapshots)
313
- cards << Cards::SorbetIntro.new(sorbet_intro_commit: sorbet_intro_commit, sorbet_intro_date: sorbet_intro_date)
312
+ cards << Cards::Map.new(
313
+ file_tree: @file_tree,
314
+ nodes_strictnesses: @nodes_strictnesses,
315
+ nodes_strictness_scores: @nodes_strictness_scores,
316
+ )
317
+ cards << Cards::Timeline::Sigils.new(snapshots: @snapshots)
318
+ cards << Cards::Timeline::Calls.new(snapshots: @snapshots)
319
+ cards << Cards::Timeline::Sigs.new(snapshots: @snapshots)
320
+ cards << Cards::Timeline::RBIs.new(snapshots: @snapshots)
321
+ cards << Cards::Timeline::Versions.new(snapshots: @snapshots)
322
+ cards << Cards::Timeline::Runtimes.new(snapshots: @snapshots)
323
+ cards << Cards::SorbetIntro.new(
324
+ sorbet_intro_commit: @sorbet_intro_commit,
325
+ sorbet_intro_date: @sorbet_intro_date,
326
+ )
314
327
  cards
315
328
  end
316
329
  end
@@ -83,29 +83,29 @@ module Spoom
83
83
  def report(context, snapshots, palette:)
84
84
  intro_commit = context.sorbet_intro_commit
85
85
 
86
+ file_tree = file_tree(context)
87
+ v = FileTree::CollectScores.new(context)
88
+ v.visit_tree(file_tree)
89
+
86
90
  Report.new(
87
91
  project_name: File.basename(context.absolute_path),
88
92
  palette: palette,
89
93
  snapshots: snapshots,
90
- sigils_tree: sigils_tree(context),
94
+ file_tree: file_tree,
95
+ nodes_strictnesses: v.strictnesses,
96
+ nodes_strictness_scores: v.scores,
91
97
  sorbet_intro_commit: intro_commit&.sha,
92
98
  sorbet_intro_date: intro_commit&.time,
93
99
  )
94
100
  end
95
101
 
96
102
  sig { params(context: Context).returns(FileTree) }
97
- def sigils_tree(context)
98
- files = context.srb_files
99
-
100
- extensions = context.sorbet_config.allowed_extensions
101
- extensions = [".rb"] if extensions.empty?
102
- extensions -= [".rbi"]
103
-
104
- pattern = /\.(#{Regexp.union(extensions.map { |ext| ext[1..-1] })})$/
105
- files.select! { |file| file =~ pattern }
106
- files.reject! { |file| file =~ %r{/test/} }
103
+ def file_tree(context)
104
+ config = context.sorbet_config
105
+ config.ignore += ["test"]
107
106
 
108
- FileTree.new(files, strip_prefix: context.absolute_path)
107
+ files = context.srb_files(with_config: config, include_rbis: false)
108
+ FileTree.new(files)
109
109
  end
110
110
  end
111
111
  end
@@ -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