spoom 1.0.4 → 1.0.9

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +0 -1
  3. data/README.md +296 -1
  4. data/Rakefile +1 -0
  5. data/lib/spoom.rb +21 -2
  6. data/lib/spoom/cli.rb +56 -10
  7. data/lib/spoom/cli/bump.rb +138 -0
  8. data/lib/spoom/cli/config.rb +51 -0
  9. data/lib/spoom/cli/coverage.rb +206 -0
  10. data/lib/spoom/cli/helper.rb +149 -0
  11. data/lib/spoom/cli/lsp.rb +165 -0
  12. data/lib/spoom/cli/run.rb +109 -0
  13. data/lib/spoom/coverage.rb +89 -0
  14. data/lib/spoom/coverage/d3.rb +110 -0
  15. data/lib/spoom/coverage/d3/base.rb +50 -0
  16. data/lib/spoom/coverage/d3/circle_map.rb +195 -0
  17. data/lib/spoom/coverage/d3/pie.rb +175 -0
  18. data/lib/spoom/coverage/d3/timeline.rb +486 -0
  19. data/lib/spoom/coverage/report.rb +308 -0
  20. data/lib/spoom/coverage/snapshot.rb +132 -0
  21. data/lib/spoom/file_tree.rb +196 -0
  22. data/lib/spoom/git.rb +98 -0
  23. data/lib/spoom/printer.rb +80 -0
  24. data/lib/spoom/sorbet.rb +99 -47
  25. data/lib/spoom/sorbet/config.rb +30 -0
  26. data/lib/spoom/sorbet/errors.rb +33 -15
  27. data/lib/spoom/sorbet/lsp.rb +2 -4
  28. data/lib/spoom/sorbet/lsp/structures.rb +108 -14
  29. data/lib/spoom/sorbet/metrics.rb +10 -79
  30. data/lib/spoom/sorbet/sigils.rb +98 -0
  31. data/lib/spoom/test_helpers/project.rb +112 -0
  32. data/lib/spoom/timeline.rb +53 -0
  33. data/lib/spoom/version.rb +2 -2
  34. data/templates/card.erb +8 -0
  35. data/templates/card_snapshot.erb +22 -0
  36. data/templates/page.erb +50 -0
  37. metadata +28 -11
  38. data/lib/spoom/cli/commands/base.rb +0 -36
  39. data/lib/spoom/cli/commands/config.rb +0 -67
  40. data/lib/spoom/cli/commands/lsp.rb +0 -156
  41. data/lib/spoom/cli/commands/run.rb +0 -92
  42. data/lib/spoom/cli/symbol_printer.rb +0 -71
  43. data/lib/spoom/config.rb +0 -11
@@ -0,0 +1,165 @@
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
+ class LSP < Thor
11
+ include Helper
12
+
13
+ default_task :show
14
+
15
+ desc "interactive", "Interactive LSP mode"
16
+ def show
17
+ in_sorbet_project!
18
+ lsp = lsp_client
19
+ # TODO: run interactive mode
20
+ puts lsp
21
+ end
22
+
23
+ desc "list", "List all known symbols"
24
+ # TODO: options, filter, limit, kind etc.. filter rbi
25
+ def list
26
+ run do |client|
27
+ printer = symbol_printer
28
+ Dir["**/*.rb"].each do |file|
29
+ res = client.document_symbols(to_uri(file))
30
+ next if res.empty?
31
+ say("Symbols from `#{file}`:")
32
+ printer.print_objects(res)
33
+ end
34
+ end
35
+ end
36
+
37
+ desc "hover", "Request hover informations"
38
+ # TODO: options, filter, limit, kind etc.. filter rbi
39
+ def hover(file, line, col)
40
+ run do |client|
41
+ res = client.hover(to_uri(file), line.to_i, col.to_i)
42
+ say("Hovering `#{file}:#{line}:#{col}`:")
43
+ if res
44
+ symbol_printer.print_object(res)
45
+ else
46
+ say("<no data>")
47
+ end
48
+ end
49
+ end
50
+
51
+ desc "defs", "List definitions of a symbol"
52
+ # TODO: options, filter, limit, kind etc.. filter rbi
53
+ def defs(file, line, col)
54
+ run do |client|
55
+ res = client.definitions(to_uri(file), line.to_i, col.to_i)
56
+ say("Definitions for `#{file}:#{line}:#{col}`:")
57
+ symbol_printer.print_list(res)
58
+ end
59
+ end
60
+
61
+ desc "find", "Find symbols matching a query"
62
+ # TODO: options, filter, limit, kind etc.. filter rbi
63
+ def find(query)
64
+ run do |client|
65
+ res = client.symbols(query).reject { |symbol| symbol.location.uri.start_with?("https") }
66
+ say("Symbols matching `#{query}`:")
67
+ symbol_printer.print_objects(res)
68
+ end
69
+ end
70
+
71
+ desc "symbols", "List symbols from a file"
72
+ # TODO: options, filter, limit, kind etc.. filter rbi
73
+ def symbols(file)
74
+ run do |client|
75
+ res = client.document_symbols(to_uri(file))
76
+ say("Symbols from `#{file}`:")
77
+ symbol_printer.print_objects(res)
78
+ end
79
+ end
80
+
81
+ desc "refs", "List references to a symbol"
82
+ # TODO: options, filter, limit, kind etc.. filter rbi
83
+ def refs(file, line, col)
84
+ run do |client|
85
+ res = client.references(to_uri(file), line.to_i, col.to_i)
86
+ say("References to `#{file}:#{line}:#{col}`:")
87
+ symbol_printer.print_list(res)
88
+ end
89
+ end
90
+
91
+ desc "sigs", "List signatures for a symbol"
92
+ # TODO: options, filter, limit, kind etc.. filter rbi
93
+ def sigs(file, line, col)
94
+ run do |client|
95
+ res = client.signatures(to_uri(file), line.to_i, col.to_i)
96
+ say("Signature for `#{file}:#{line}:#{col}`:")
97
+ symbol_printer.print_list(res)
98
+ end
99
+ end
100
+
101
+ desc "types", "Display type of a symbol"
102
+ # TODO: options, filter, limit, kind etc.. filter rbi
103
+ def types(file, line, col)
104
+ run do |client|
105
+ res = client.type_definitions(to_uri(file), line.to_i, col.to_i)
106
+ say("Type for `#{file}:#{line}:#{col}`:")
107
+ symbol_printer.print_list(res)
108
+ end
109
+ end
110
+
111
+ no_commands do
112
+ def lsp_client
113
+ in_sorbet_project!
114
+ path = exec_path
115
+ client = Spoom::LSP::Client.new(
116
+ Spoom::Sorbet::BIN_PATH,
117
+ "--lsp",
118
+ "--enable-all-experimental-lsp-features",
119
+ "--disable-watchman",
120
+ path: path
121
+ )
122
+ client.open(File.expand_path(path))
123
+ client
124
+ end
125
+
126
+ def symbol_printer
127
+ Spoom::LSP::SymbolPrinter.new(
128
+ indent_level: 2,
129
+ colors: options[:color],
130
+ prefix: "file://#{File.expand_path(exec_path)}"
131
+ )
132
+ end
133
+
134
+ def run(&block)
135
+ client = lsp_client
136
+ block.call(client)
137
+ rescue Spoom::LSP::Error::Diagnostics => err
138
+ say_error("Sorbet returned typechecking errors for `#{symbol_printer.clean_uri(err.uri)}`")
139
+ err.diagnostics.each do |d|
140
+ say_error("#{d.message} (#{d.code})", status: " #{d.range}")
141
+ end
142
+ exit(1)
143
+ rescue Spoom::LSP::Error::BadHeaders => err
144
+ say_error("Sorbet didn't answer correctly (#{err.message})")
145
+ exit(1)
146
+ rescue Spoom::LSP::Error => err
147
+ say_error(err.message)
148
+ exit(1)
149
+ ensure
150
+ begin
151
+ client&.close
152
+ rescue
153
+ # We can't do much if Sorbet refuse to close.
154
+ # We kill the parent process and let the child be killed.
155
+ exit(1)
156
+ end
157
+ end
158
+
159
+ def to_uri(path)
160
+ "file://" + File.join(File.expand_path(exec_path), path)
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,109 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Cli
6
+ class Run < Thor
7
+ include Helper
8
+
9
+ default_task :tc
10
+
11
+ SORT_CODE = "code"
12
+ SORT_LOC = "loc"
13
+ SORT_ENUM = [SORT_CODE, SORT_LOC]
14
+
15
+ DEFAULT_FORMAT = "%C - %F:%L: %M"
16
+
17
+ desc "tc", "Run `srb tc`"
18
+ option :limit, type: :numeric, aliases: :l, desc: "Limit displayed errors"
19
+ option :code, type: :numeric, aliases: :c, desc: "Filter displayed errors by code"
20
+ option :sort, type: :string, aliases: :s, desc: "Sort errors", enum: SORT_ENUM, lazy_default: SORT_LOC
21
+ option :format, type: :string, aliases: :f, desc: "Format line output"
22
+ option :uniq, type: :boolean, aliases: :u, desc: "Remove duplicated lines"
23
+ option :count, type: :boolean, default: true, desc: "Show errors count"
24
+ option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
25
+ def tc(*arg)
26
+ in_sorbet_project!
27
+
28
+ path = exec_path
29
+ limit = options[:limit]
30
+ sort = options[:sort]
31
+ code = options[:code]
32
+ uniq = options[:uniq]
33
+ format = options[:format]
34
+ count = options[:count]
35
+ sorbet = options[:sorbet]
36
+
37
+ unless limit || code || sort
38
+ output, status = T.unsafe(Spoom::Sorbet).srb_tc(*arg, path: path, capture_err: false, sorbet_bin: sorbet)
39
+ say_error(output, status: nil, nl: false)
40
+ exit(status)
41
+ end
42
+
43
+ output, status = T.unsafe(Spoom::Sorbet).srb_tc(*arg, path: path, capture_err: true, sorbet_bin: sorbet)
44
+ if status
45
+ say_error(output, status: nil, nl: false)
46
+ exit(0)
47
+ end
48
+
49
+ errors = Spoom::Sorbet::Errors::Parser.parse_string(output)
50
+ errors_count = errors.size
51
+
52
+ errors = case sort
53
+ when SORT_CODE
54
+ Spoom::Sorbet::Errors.sort_errors_by_code(errors)
55
+ when SORT_LOC
56
+ errors.sort
57
+ else
58
+ errors # preserve natural sort
59
+ end
60
+
61
+ errors = errors.select { |e| e.code == code } if code
62
+ errors = T.must(errors.slice(0, limit)) if limit
63
+
64
+ lines = errors.map { |e| format_error(e, format || DEFAULT_FORMAT) }
65
+ lines = lines.uniq if uniq
66
+
67
+ lines.each do |line|
68
+ say_error(line, status: nil)
69
+ end
70
+
71
+ if count
72
+ if errors_count == errors.size
73
+ say_error("Errors: #{errors_count}", status: nil)
74
+ else
75
+ say_error("Errors: #{errors.size} shown, #{errors_count} total", status: nil)
76
+ end
77
+ end
78
+
79
+ exit(1)
80
+ end
81
+
82
+ no_commands do
83
+ def format_error(error, format)
84
+ line = format
85
+ line = line.gsub(/%C/, yellow(error.code.to_s))
86
+ line = line.gsub(/%F/, error.file)
87
+ line = line.gsub(/%L/, error.line.to_s)
88
+ line = line.gsub(/%M/, colorize_message(error.message))
89
+ line
90
+ end
91
+
92
+ def colorize_message(message)
93
+ return message unless color?
94
+
95
+ cyan = T.let(false, T::Boolean)
96
+ word = StringIO.new
97
+ message.chars.each do |c|
98
+ if c == '`'
99
+ cyan = !cyan
100
+ next
101
+ end
102
+ word << (cyan ? c.cyan : c.red)
103
+ end
104
+ word.string
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,89 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "coverage/snapshot"
5
+ require_relative "coverage/report"
6
+ require_relative "file_tree"
7
+
8
+ require "date"
9
+
10
+ module Spoom
11
+ module Coverage
12
+ extend T::Sig
13
+
14
+ sig { params(path: String, rbi: T::Boolean, sorbet_bin: T.nilable(String)).returns(Snapshot) }
15
+ def self.snapshot(path: '.', rbi: true, sorbet_bin: nil)
16
+ config = sorbet_config(path: path)
17
+ config.allowed_extensions.push(".rb", ".rbi") if config.allowed_extensions.empty?
18
+
19
+ new_config = config.copy
20
+ new_config.allowed_extensions.reject! { |ext| !rbi && ext == ".rbi" }
21
+
22
+ metrics = Spoom::Sorbet.srb_metrics(
23
+ "--no-config",
24
+ new_config.options_string,
25
+ path: path,
26
+ capture_err: true,
27
+ sorbet_bin: sorbet_bin
28
+ )
29
+
30
+ snapshot = Snapshot.new
31
+ return snapshot unless metrics
32
+
33
+ sha = Spoom::Git.last_commit(path: path)
34
+ snapshot.commit_sha = sha
35
+ snapshot.commit_timestamp = Spoom::Git.commit_timestamp(sha, path: path).to_i if sha
36
+
37
+ snapshot.files = metrics.fetch("types.input.files", 0)
38
+ snapshot.modules = metrics.fetch("types.input.modules.total", 0)
39
+ snapshot.classes = metrics.fetch("types.input.classes.total", 0)
40
+ snapshot.singleton_classes = metrics.fetch("types.input.singleton_classes.total", 0)
41
+ snapshot.methods_with_sig = metrics.fetch("types.sig.count", 0)
42
+ snapshot.methods_without_sig = metrics.fetch("types.input.methods.total", 0) - snapshot.methods_with_sig
43
+ snapshot.calls_typed = metrics.fetch("types.input.sends.typed", 0)
44
+ snapshot.calls_untyped = metrics.fetch("types.input.sends.total", 0) - snapshot.calls_typed
45
+
46
+ snapshot.duration += metrics.fetch("run.utilization.system_time.us", 0)
47
+ snapshot.duration += metrics.fetch("run.utilization.user_time.us", 0)
48
+
49
+ Snapshot::STRICTNESSES.each do |strictness|
50
+ next unless metrics.key?("types.input.files.sigil.#{strictness}")
51
+ snapshot.sigils[strictness] = T.must(metrics["types.input.files.sigil.#{strictness}"])
52
+ end
53
+
54
+ snapshot.version_static = Spoom::Sorbet.version_from_gemfile_lock(gem: "sorbet-static", path: path)
55
+ snapshot.version_runtime = Spoom::Sorbet.version_from_gemfile_lock(gem: "sorbet-runtime", path: path)
56
+
57
+ snapshot
58
+ end
59
+
60
+ sig { params(snapshots: T::Array[Snapshot], palette: D3::ColorPalette, path: String).returns(Report) }
61
+ def self.report(snapshots, palette:, path: ".")
62
+ intro_commit = Git.sorbet_intro_commit(path: path)
63
+ intro_date = intro_commit ? Git.commit_time(intro_commit, path: path) : nil
64
+
65
+ Report.new(
66
+ project_name: File.basename(File.expand_path(path)),
67
+ palette: palette,
68
+ snapshots: snapshots,
69
+ sigils_tree: sigils_tree(path: path),
70
+ sorbet_intro_commit: intro_commit,
71
+ sorbet_intro_date: intro_date,
72
+ )
73
+ end
74
+
75
+ sig { params(path: String).returns(Sorbet::Config) }
76
+ def self.sorbet_config(path: ".")
77
+ Sorbet::Config.parse_file("#{path}/#{Spoom::Sorbet::CONFIG_PATH}")
78
+ end
79
+
80
+ sig { params(path: String).returns(FileTree) }
81
+ def self.sigils_tree(path: ".")
82
+ config = sorbet_config(path: path)
83
+ files = Sorbet.srb_files(config, path: path)
84
+ files.select! { |file| file =~ /\.rb$/ }
85
+ files.reject! { |file| file =~ %r{/test/} }
86
+ FileTree.new(files, strip_prefix: path)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,110 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "d3/circle_map"
5
+ require_relative "d3/pie"
6
+ require_relative "d3/timeline"
7
+
8
+ module Spoom
9
+ module Coverage
10
+ module D3
11
+ extend T::Sig
12
+
13
+ COLOR_IGNORE = "#999"
14
+ COLOR_FALSE = "#db4437"
15
+ COLOR_TRUE = "#0f9d58"
16
+ COLOR_STRICT = "#0a7340"
17
+ COLOR_STRONG = "#064828"
18
+
19
+ sig { returns(String) }
20
+ def self.header_style
21
+ <<~CSS
22
+ svg {
23
+ width: 100%;
24
+ height: 100%;
25
+ }
26
+
27
+ .tooltip {
28
+ font: 12px Arial, sans-serif;
29
+ color: #fff;
30
+ text-align: center;
31
+ background: rgba(0, 0, 0, 0.6);
32
+ padding: 5px;
33
+ border: 0px;
34
+ border-radius: 4px;
35
+ position: absolute;
36
+ top: 0;
37
+ left: 0;
38
+ opacity: 0;
39
+ }
40
+
41
+ .label {
42
+ font: 14px Arial, sans-serif;
43
+ font-weight: bold;
44
+ fill: #fff;
45
+ text-anchor: middle;
46
+ pointer-events: none;
47
+ }
48
+
49
+ .label .small {
50
+ font-size: 10px;
51
+ }
52
+
53
+ #{Pie.header_style}
54
+ #{CircleMap.header_style}
55
+ #{Timeline.header_style}
56
+ CSS
57
+ end
58
+
59
+ sig { params(palette: ColorPalette).returns(String) }
60
+ def self.header_script(palette)
61
+ <<~JS
62
+ var parseDate = d3.timeParse("%s");
63
+
64
+ function strictnessColor(strictness) {
65
+ switch(strictness) {
66
+ case "ignore":
67
+ return "#{palette.ignore}";
68
+ case "false":
69
+ return "#{palette.false}";
70
+ case "true":
71
+ return "#{palette.true}";
72
+ case "strict":
73
+ return "#{palette.strict}";
74
+ case "strong":
75
+ return "#{palette.strong}";
76
+ }
77
+ return "#{palette.false}";
78
+ }
79
+
80
+ function toPercent(value, sum) {
81
+ return value ? Math.round(value * 100 / sum) : 0;
82
+ }
83
+
84
+ var tooltip = d3.select("body")
85
+ .append("div")
86
+ .append("div")
87
+ .attr("class", "tooltip");
88
+
89
+ function moveTooltip(d) {
90
+ return tooltip
91
+ .style("left", (d3.event.pageX + 20) + "px")
92
+ .style("top", (d3.event.pageY) + "px")
93
+ }
94
+
95
+ #{Pie.header_script}
96
+ #{CircleMap.header_script}
97
+ #{Timeline.header_script}
98
+ JS
99
+ end
100
+
101
+ class ColorPalette < T::Struct
102
+ prop :ignore, String
103
+ prop :false, String
104
+ prop :true, String
105
+ prop :strict, String
106
+ prop :strong, String
107
+ end
108
+ end
109
+ end
110
+ end