spoom 1.0.4 → 1.0.9

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