spoom 1.2.4 → 1.3.0

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +54 -55
  3. data/lib/spoom/cli/deadcode.rb +172 -0
  4. data/lib/spoom/cli/helper.rb +20 -0
  5. data/lib/spoom/cli/srb/bump.rb +200 -0
  6. data/lib/spoom/cli/srb/coverage.rb +224 -0
  7. data/lib/spoom/cli/srb/lsp.rb +159 -0
  8. data/lib/spoom/cli/srb/tc.rb +150 -0
  9. data/lib/spoom/cli/srb.rb +27 -0
  10. data/lib/spoom/cli.rb +72 -32
  11. data/lib/spoom/context/git.rb +2 -2
  12. data/lib/spoom/context/sorbet.rb +2 -2
  13. data/lib/spoom/deadcode/definition.rb +11 -0
  14. data/lib/spoom/deadcode/indexer.rb +222 -224
  15. data/lib/spoom/deadcode/location.rb +2 -2
  16. data/lib/spoom/deadcode/plugins/action_mailer.rb +2 -2
  17. data/lib/spoom/deadcode/plugins/action_mailer_preview.rb +19 -0
  18. data/lib/spoom/deadcode/plugins/actionpack.rb +4 -6
  19. data/lib/spoom/deadcode/plugins/active_model.rb +8 -8
  20. data/lib/spoom/deadcode/plugins/active_record.rb +9 -12
  21. data/lib/spoom/deadcode/plugins/active_support.rb +11 -0
  22. data/lib/spoom/deadcode/plugins/base.rb +1 -1
  23. data/lib/spoom/deadcode/plugins/graphql.rb +4 -4
  24. data/lib/spoom/deadcode/plugins/namespaces.rb +2 -4
  25. data/lib/spoom/deadcode/plugins/ruby.rb +8 -17
  26. data/lib/spoom/deadcode/plugins/sorbet.rb +4 -10
  27. data/lib/spoom/deadcode/plugins.rb +1 -0
  28. data/lib/spoom/deadcode/remover.rb +209 -174
  29. data/lib/spoom/deadcode/send.rb +9 -10
  30. data/lib/spoom/deadcode/visitor.rb +755 -0
  31. data/lib/spoom/deadcode.rb +40 -10
  32. data/lib/spoom/file_tree.rb +0 -16
  33. data/lib/spoom/sorbet/errors.rb +1 -1
  34. data/lib/spoom/sorbet/lsp/structures.rb +2 -2
  35. data/lib/spoom/version.rb +1 -1
  36. metadata +19 -15
  37. data/lib/spoom/cli/bump.rb +0 -198
  38. data/lib/spoom/cli/coverage.rb +0 -222
  39. data/lib/spoom/cli/lsp.rb +0 -168
  40. data/lib/spoom/cli/run.rb +0 -148
@@ -0,0 +1,200 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "find"
5
+ require "open3"
6
+
7
+ module Spoom
8
+ module Cli
9
+ module Srb
10
+ class Bump < Thor
11
+ extend T::Sig
12
+ include Helper
13
+
14
+ default_task :bump
15
+
16
+ desc "bump DIRECTORY", "Change Sorbet sigils from one strictness to another when no errors"
17
+ option :from,
18
+ type: :string,
19
+ default: Spoom::Sorbet::Sigils::STRICTNESS_FALSE,
20
+ desc: "Change only files from this strictness"
21
+ option :to,
22
+ type: :string,
23
+ default: Spoom::Sorbet::Sigils::STRICTNESS_TRUE,
24
+ desc: "Change files to this strictness"
25
+ option :force,
26
+ type: :boolean,
27
+ default: false,
28
+ aliases: :f,
29
+ desc: "Change strictness without type checking"
30
+ option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
31
+ option :dry,
32
+ type: :boolean,
33
+ default: false,
34
+ aliases: :d,
35
+ desc: "Only display what would happen, do not actually change sigils"
36
+ option :only,
37
+ type: :string,
38
+ default: nil,
39
+ aliases: :o,
40
+ desc: "Only change specified list (one file by line)"
41
+ option :suggest_bump_command,
42
+ type: :string,
43
+ desc: "Command to suggest if files can be bumped"
44
+ option :count_errors,
45
+ type: :boolean,
46
+ default: false,
47
+ desc: "Count the number of errors if all files were bumped"
48
+ option :sorbet_options, type: :string, default: "", desc: "Pass options to Sorbet"
49
+ sig { params(directory: String).void }
50
+ def bump(directory = ".")
51
+ context = context_requiring_sorbet!
52
+ from = options[:from]
53
+ to = options[:to]
54
+ force = options[:force]
55
+ dry = options[:dry]
56
+ only = options[:only]
57
+ cmd = options[:suggest_bump_command]
58
+ directory = File.expand_path(directory)
59
+ exec_path = File.expand_path(self.exec_path)
60
+
61
+ unless Sorbet::Sigils.valid_strictness?(from)
62
+ say_error("Invalid strictness `#{from}` for option `--from`")
63
+ exit(1)
64
+ end
65
+
66
+ unless Sorbet::Sigils.valid_strictness?(to)
67
+ say_error("Invalid strictness `#{to}` for option `--to`")
68
+ exit(1)
69
+ end
70
+
71
+ if options[:count_errors] && !dry
72
+ say_error("`--count-errors` can only be used with `--dry`")
73
+ exit(1)
74
+ end
75
+
76
+ say("Checking files...")
77
+
78
+ files_to_bump = context.srb_files_with_strictness(from, include_rbis: false)
79
+ .map { |file| File.expand_path(file, context.absolute_path) }
80
+ .select { |file| file.start_with?(directory) }
81
+
82
+ if only
83
+ list = File.read(only).lines.map { |file| File.expand_path(file.strip) }
84
+ files_to_bump.select! { |file| list.include?(File.expand_path(file)) }
85
+ end
86
+
87
+ say("\n")
88
+
89
+ if files_to_bump.empty?
90
+ say("No files to bump from `#{from}` to `#{to}`")
91
+ exit(0)
92
+ end
93
+
94
+ Sorbet::Sigils.change_sigil_in_files(files_to_bump, to)
95
+
96
+ if force
97
+ print_changes(files_to_bump, command: cmd, from: from, to: to, dry: dry, path: exec_path)
98
+ undo_changes(files_to_bump, from) if dry
99
+ exit(files_to_bump.empty?)
100
+ end
101
+
102
+ error_url_base = Spoom::Sorbet::Errors::DEFAULT_ERROR_URL_BASE
103
+ result = begin
104
+ T.unsafe(context).srb_tc(
105
+ *options[:sorbet_options].split(" "),
106
+ "--error-url-base=#{error_url_base}",
107
+ capture_err: true,
108
+ sorbet_bin: options[:sorbet],
109
+ )
110
+ rescue Spoom::Sorbet::Error::Segfault => error
111
+ say_error(<<~ERR, status: nil)
112
+ !!! Sorbet exited with code #{Spoom::Sorbet::SEGFAULT_CODE} - SEGFAULT !!!
113
+
114
+ This is most likely related to a bug in Sorbet.
115
+ It means one of the file bumped to `typed: #{to}` made Sorbet crash.
116
+ Run `spoom bump -f` locally followed by `bundle exec srb tc` to investigate the problem.
117
+ ERR
118
+ undo_changes(files_to_bump, from)
119
+ exit(error.result.exit_code)
120
+ rescue Spoom::Sorbet::Error::Killed => error
121
+ say_error(<<~ERR, status: nil)
122
+ !!! Sorbet exited with code #{Spoom::Sorbet::KILLED_CODE} - KILLED !!!
123
+
124
+ It means Sorbet was killed while executing. Changes to files have not been applied.
125
+ Re-run `spoom bump` to try again.
126
+ ERR
127
+ undo_changes(files_to_bump, from)
128
+ exit(error.result.exit_code)
129
+ end
130
+
131
+ if result.status
132
+ print_changes(files_to_bump, command: cmd, from: from, to: to, dry: dry, path: exec_path)
133
+ undo_changes(files_to_bump, from) if dry
134
+ exit(files_to_bump.empty?)
135
+ end
136
+
137
+ unless result.exit_code == 100
138
+ # Sorbet will return exit code 100 if there are type checking errors.
139
+ # If Sorbet returned something else, it means it didn't terminate normally.
140
+ say_error(result.err, status: nil, nl: false)
141
+ undo_changes(files_to_bump, from)
142
+ exit(1)
143
+ end
144
+
145
+ errors = Sorbet::Errors::Parser.parse_string(result.err, error_url_base: error_url_base)
146
+
147
+ all_files = errors.flat_map do |err|
148
+ [err.file, *err.files_from_error_sections]
149
+ end
150
+
151
+ files_with_errors = all_files.map do |file|
152
+ path = File.expand_path(file)
153
+ next unless path.start_with?(directory)
154
+ next unless File.file?(path)
155
+ next unless files_to_bump.include?(path)
156
+
157
+ path
158
+ end.compact.uniq
159
+
160
+ undo_changes(files_with_errors, from)
161
+
162
+ say("Found #{errors.length} type checking error#{"s" if errors.length > 1}") if options[:count_errors]
163
+
164
+ files_changed = files_to_bump - files_with_errors
165
+ print_changes(files_changed, command: cmd, from: from, to: to, dry: dry, path: exec_path)
166
+ undo_changes(files_to_bump, from) if dry
167
+ exit(files_changed.empty?)
168
+ end
169
+
170
+ no_commands do
171
+ def print_changes(files, command:, from: "false", to: "true", dry: false, path: File.expand_path("."))
172
+ files_count = files.size
173
+ if files_count.zero?
174
+ say("No files to bump from `#{from}` to `#{to}`")
175
+ return
176
+ end
177
+ message = StringIO.new
178
+ message << (dry ? "Can bump" : "Bumped")
179
+ message << " `#{files_count}` file#{"s" if files_count > 1}"
180
+ message << " from `#{from}` to `#{to}`:"
181
+ say(message.string)
182
+ files.each do |file|
183
+ file_path = Pathname.new(file).relative_path_from(path)
184
+ say(" + #{file_path}")
185
+ end
186
+ if dry && command
187
+ say("\nRun `#{command}` to bump #{files_count > 1 ? "them" : "it"}")
188
+ elsif dry
189
+ say("\nRun `spoom bump --from #{from} --to #{to}` locally then `commit the changes` and `push them`")
190
+ end
191
+ end
192
+
193
+ def undo_changes(files, from_strictness)
194
+ Sorbet::Sigils.change_sigil_in_files(files, from_strictness)
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,224 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../../coverage"
5
+ require_relative "../../timeline"
6
+
7
+ module Spoom
8
+ module Cli
9
+ module Srb
10
+ class Coverage < Thor
11
+ include Helper
12
+
13
+ DATA_DIR = "spoom_data"
14
+
15
+ default_task :snapshot
16
+
17
+ desc "snapshot", "Run srb tc and display metrics"
18
+ option :save, type: :string, lazy_default: DATA_DIR, desc: "Save snapshot data as json"
19
+ option :rbi, type: :boolean, default: true, desc: "Include RBI files in metrics"
20
+ option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
21
+ def snapshot
22
+ context = context_requiring_sorbet!
23
+ sorbet = options[:sorbet]
24
+
25
+ snapshot = Spoom::Coverage.snapshot(context, rbi: options[:rbi], sorbet_bin: sorbet)
26
+ snapshot.print
27
+
28
+ save_dir = options[:save]
29
+ return unless save_dir
30
+
31
+ FileUtils.mkdir_p(save_dir)
32
+ file = "#{save_dir}/#{snapshot.commit_sha || snapshot.timestamp}.json"
33
+ File.write(file, snapshot.to_json)
34
+ say("\nSnapshot data saved under `#{file}`")
35
+ end
36
+
37
+ desc "timeline", "Replay a project and collect metrics"
38
+ option :from, type: :string, desc: "From commit date"
39
+ option :to, type: :string, default: Time.now.strftime("%F"), desc: "To commit date"
40
+ option :save, type: :string, lazy_default: DATA_DIR, desc: "Save snapshot data as json"
41
+ option :bundle_install, type: :boolean, desc: "Execute `bundle install` before collecting metrics"
42
+ option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
43
+ def timeline
44
+ context = context_requiring_sorbet!
45
+ path = exec_path
46
+ sorbet = options[:sorbet]
47
+
48
+ ref_before = context.git_current_branch
49
+ ref_before = context.git_last_commit&.sha unless ref_before
50
+ unless ref_before
51
+ say_error("Not in a git repository")
52
+ say_error("\nSpoom needs to checkout into your previous commits to build the timeline.", status: nil)
53
+ exit(1)
54
+ end
55
+
56
+ unless context.git_workdir_clean?
57
+ say_error("Uncommitted changes")
58
+ say_error(<<~ERR, status: nil)
59
+
60
+ Spoom needs to checkout into your previous commits to build the timeline."
61
+
62
+ Please `git commit` or `git stash` your changes then try again
63
+ ERR
64
+ exit(1)
65
+ end
66
+
67
+ save_dir = options[:save]
68
+ FileUtils.mkdir_p(save_dir) if save_dir
69
+
70
+ from = parse_time(options[:from], "--from")
71
+ to = parse_time(options[:to], "--to")
72
+
73
+ unless from
74
+ intro_commit = context.sorbet_intro_commit
75
+ intro_commit = T.must(intro_commit) # we know it's in there since in_sorbet_project!
76
+ from = intro_commit.time
77
+ end
78
+
79
+ timeline = Spoom::Timeline.new(context, from, to)
80
+ ticks = timeline.ticks
81
+
82
+ if ticks.empty?
83
+ say_error("No commits to replay, try different `--from` and `--to` options")
84
+ exit(1)
85
+ end
86
+
87
+ ticks.each_with_index do |commit, i|
88
+ say("Analyzing commit `#{commit.sha}` - #{commit.time.strftime("%F")} (#{i + 1} / #{ticks.size})")
89
+
90
+ context.git_checkout!(ref: commit.sha)
91
+
92
+ snapshot = T.let(nil, T.nilable(Spoom::Coverage::Snapshot))
93
+ if options[:bundle_install]
94
+ Bundler.with_unbundled_env do
95
+ next unless bundle_install(path, commit.sha)
96
+
97
+ snapshot = Spoom::Coverage.snapshot(context, sorbet_bin: sorbet)
98
+ end
99
+ else
100
+ snapshot = Spoom::Coverage.snapshot(context, sorbet_bin: sorbet)
101
+ end
102
+ next unless snapshot
103
+
104
+ snapshot.print(indent_level: 2)
105
+ say("\n")
106
+
107
+ next unless save_dir
108
+
109
+ file = "#{save_dir}/#{commit.sha}.json"
110
+ File.write(file, snapshot.to_json)
111
+ say(" Snapshot data saved under `#{file}`\n\n")
112
+ end
113
+ context.git_checkout!(ref: ref_before)
114
+ end
115
+
116
+ desc "report", "Produce a typing coverage report"
117
+ option :data, type: :string, default: DATA_DIR, desc: "Snapshots JSON data"
118
+ option :file,
119
+ type: :string,
120
+ default: "spoom_report.html",
121
+ aliases: :f,
122
+ desc: "Save report to file"
123
+ option :color_ignore,
124
+ type: :string,
125
+ default: Spoom::Coverage::D3::COLOR_IGNORE,
126
+ desc: "Color used for typed: ignore"
127
+ option :color_false,
128
+ type: :string,
129
+ default: Spoom::Coverage::D3::COLOR_FALSE,
130
+ desc: "Color used for typed: false"
131
+ option :color_true,
132
+ type: :string,
133
+ default: Spoom::Coverage::D3::COLOR_TRUE,
134
+ desc: "Color used for typed: true"
135
+ option :color_strict,
136
+ type: :string,
137
+ default: Spoom::Coverage::D3::COLOR_STRICT,
138
+ desc: "Color used for typed: strict"
139
+ option :color_strong,
140
+ type: :string,
141
+ default: Spoom::Coverage::D3::COLOR_STRONG,
142
+ desc: "Color used for typed: strong"
143
+ def report
144
+ context = context_requiring_sorbet!
145
+
146
+ data_dir = options[:data]
147
+ files = Dir.glob("#{data_dir}/*.json")
148
+ if files.empty?
149
+ message_no_data(data_dir)
150
+ exit(1)
151
+ end
152
+
153
+ snapshots = files.sort.map do |file|
154
+ json = File.read(file)
155
+ Spoom::Coverage::Snapshot.from_json(json)
156
+ end.filter(&:commit_timestamp).sort_by!(&:commit_timestamp)
157
+
158
+ palette = Spoom::Coverage::D3::ColorPalette.new(
159
+ ignore: options[:color_ignore],
160
+ false: options[:color_false],
161
+ true: options[:color_true],
162
+ strict: options[:color_strict],
163
+ strong: options[:color_strong],
164
+ )
165
+
166
+ report = Spoom::Coverage.report(context, snapshots, palette: palette)
167
+ file = options[:file]
168
+ File.write(file, report.html)
169
+ say("Report generated under `#{file}`")
170
+ say("\nUse `spoom coverage open` to open it.")
171
+ end
172
+
173
+ desc "open", "Open the typing coverage report"
174
+ def open(file = "spoom_report.html")
175
+ unless File.exist?(file)
176
+ say_error("No report file to open `#{file}`")
177
+ say_error(<<~ERR, status: nil)
178
+
179
+ If you already generated a report under another name use #{blue("spoom coverage open PATH")}.
180
+
181
+ To generate a report run #{blue("spoom coverage report")}.
182
+ ERR
183
+ exit(1)
184
+ end
185
+
186
+ exec("open #{file}")
187
+ end
188
+
189
+ no_commands do
190
+ def parse_time(string, option)
191
+ return unless string
192
+
193
+ Time.parse(string)
194
+ rescue ArgumentError
195
+ say_error("Invalid date `#{string}` for option `#{option}` (expected format `YYYY-MM-DD`)")
196
+ exit(1)
197
+ end
198
+
199
+ def bundle_install(path, sha)
200
+ opts = {}
201
+ opts[:chdir] = path
202
+ out, status = Open3.capture2e("bundle install", opts)
203
+ unless status.success?
204
+ say_error("Can't run `bundle install` for commit `#{sha}`. Skipping snapshot")
205
+ say_error(out, status: nil)
206
+ return false
207
+ end
208
+ true
209
+ end
210
+
211
+ def message_no_data(file)
212
+ say_error("No snapshot files found in `#{file}`")
213
+ say_error(<<~ERR, status: nil)
214
+
215
+ If you already generated snapshot files under another directory use #{blue("spoom coverage report PATH")}.
216
+
217
+ To generate snapshot files run #{blue("spoom coverage timeline --save")}.
218
+ ERR
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,159 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require "shellwords"
5
+
6
+ require_relative "../../sorbet/lsp"
7
+
8
+ module Spoom
9
+ module Cli
10
+ module Srb
11
+ class LSP < Thor
12
+ include Helper
13
+
14
+ desc "list", "List all known symbols"
15
+ # TODO: options, filter, limit, kind etc.. filter rbi
16
+ def list
17
+ run do |client|
18
+ printer = symbol_printer
19
+ Dir["**/*.rb"].each do |file|
20
+ res = client.document_symbols(to_uri(file))
21
+ next if res.empty?
22
+
23
+ say("Symbols from `#{file}`:")
24
+ printer.print_objects(res)
25
+ end
26
+ end
27
+ end
28
+
29
+ desc "hover", "Request hover information"
30
+ # TODO: options, filter, limit, kind etc.. filter rbi
31
+ def hover(file, line, col)
32
+ run do |client|
33
+ res = client.hover(to_uri(file), line.to_i, col.to_i)
34
+ say("Hovering `#{file}:#{line}:#{col}`:")
35
+ if res
36
+ symbol_printer.print_object(res)
37
+ else
38
+ say("<no data>")
39
+ end
40
+ end
41
+ end
42
+
43
+ desc "defs", "List definitions of a symbol"
44
+ # TODO: options, filter, limit, kind etc.. filter rbi
45
+ def defs(file, line, col)
46
+ run do |client|
47
+ res = client.definitions(to_uri(file), line.to_i, col.to_i)
48
+ say("Definitions for `#{file}:#{line}:#{col}`:")
49
+ symbol_printer.print_list(res)
50
+ end
51
+ end
52
+
53
+ desc "find", "Find symbols matching a query"
54
+ # TODO: options, filter, limit, kind etc.. filter rbi
55
+ def find(query)
56
+ run do |client|
57
+ res = client.symbols(query).reject { |symbol| symbol.location.uri.start_with?("https") }
58
+ say("Symbols matching `#{query}`:")
59
+ symbol_printer.print_objects(res)
60
+ end
61
+ end
62
+
63
+ desc "symbols", "List symbols from a file"
64
+ # TODO: options, filter, limit, kind etc.. filter rbi
65
+ def symbols(file)
66
+ run do |client|
67
+ res = client.document_symbols(to_uri(file))
68
+ say("Symbols from `#{file}`:")
69
+ symbol_printer.print_objects(res)
70
+ end
71
+ end
72
+
73
+ desc "refs", "List references to a symbol"
74
+ # TODO: options, filter, limit, kind etc.. filter rbi
75
+ def refs(file, line, col)
76
+ run do |client|
77
+ res = client.references(to_uri(file), line.to_i, col.to_i)
78
+ say("References to `#{file}:#{line}:#{col}`:")
79
+ symbol_printer.print_list(res)
80
+ end
81
+ end
82
+
83
+ desc "sigs", "List signatures for a symbol"
84
+ # TODO: options, filter, limit, kind etc.. filter rbi
85
+ def sigs(file, line, col)
86
+ run do |client|
87
+ res = client.signatures(to_uri(file), line.to_i, col.to_i)
88
+ say("Signature for `#{file}:#{line}:#{col}`:")
89
+ symbol_printer.print_list(res)
90
+ end
91
+ end
92
+
93
+ desc "types", "Display type of a symbol"
94
+ # TODO: options, filter, limit, kind etc.. filter rbi
95
+ def types(file, line, col)
96
+ run do |client|
97
+ res = client.type_definitions(to_uri(file), line.to_i, col.to_i)
98
+ say("Type for `#{file}:#{line}:#{col}`:")
99
+ symbol_printer.print_list(res)
100
+ end
101
+ end
102
+
103
+ no_commands do
104
+ def lsp_client
105
+ context_requiring_sorbet!
106
+
107
+ path = exec_path
108
+ client = Spoom::LSP::Client.new(
109
+ Spoom::Sorbet::BIN_PATH,
110
+ "--lsp",
111
+ "--enable-all-experimental-lsp-features",
112
+ "--disable-watchman",
113
+ path: path,
114
+ )
115
+ client.open(File.expand_path(path))
116
+ client
117
+ end
118
+
119
+ def symbol_printer
120
+ Spoom::LSP::SymbolPrinter.new(
121
+ indent_level: 2,
122
+ colors: options[:color],
123
+ prefix: "file://#{File.expand_path(exec_path)}",
124
+ )
125
+ end
126
+
127
+ def run(&block)
128
+ client = lsp_client
129
+ block.call(client)
130
+ rescue Spoom::LSP::Error::Diagnostics => err
131
+ say_error("Sorbet returned typechecking errors for `#{symbol_printer.clean_uri(err.uri)}`")
132
+ err.diagnostics.each do |d|
133
+ say_error("#{d.message} (#{d.code})", status: " #{d.range}")
134
+ end
135
+ exit(1)
136
+ rescue Spoom::LSP::Error::BadHeaders => err
137
+ say_error("Sorbet didn't answer correctly (#{err.message})")
138
+ exit(1)
139
+ rescue Spoom::LSP::Error => err
140
+ say_error(err.message)
141
+ exit(1)
142
+ ensure
143
+ begin
144
+ client&.close
145
+ rescue
146
+ # We can't do much if Sorbet refuse to close.
147
+ # We kill the parent process and let the child be killed.
148
+ exit(1)
149
+ end
150
+ end
151
+
152
+ def to_uri(path)
153
+ "file://" + File.join(File.expand_path(exec_path), path)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end