tapioca 0.0.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+ # typed: true
3
+
4
+ require 'json'
5
+ require 'pathname'
6
+ require 'tempfile'
7
+ require 'shellwords'
8
+
9
+ module Tapioca
10
+ module Compilers
11
+ module SymbolTable
12
+ module SymbolLoader
13
+ SORBET = Pathname.new(Gem::Specification.find_by_name("sorbet-static").full_gem_path) / "libexec" / "sorbet"
14
+
15
+ class << self
16
+ extend(T::Sig)
17
+
18
+ sig { params(paths: T::Array[Pathname]).returns(T::Set[String]) }
19
+ def list_from_paths(paths)
20
+ load_symbols(paths.map(&:to_s))
21
+ end
22
+
23
+ def ignore_symbol?(symbol)
24
+ ignored_symbols.include?(symbol)
25
+ end
26
+
27
+ private
28
+
29
+ sig { params(paths: T::Array[String]).returns(T::Set[String]) }
30
+ def load_symbols(paths)
31
+ output = T.cast(Tempfile.create('sorbet') do |file|
32
+ file.write(Array(paths).join("\n"))
33
+ file.flush
34
+
35
+ symbol_table_json_from("@#{file.path}")
36
+ end, T.nilable(String))
37
+
38
+ return Set.new if output.nil? || output.empty?
39
+
40
+ json = JSON.parse(output)
41
+ SymbolTableParser.parse(json)
42
+ end
43
+
44
+ def ignored_symbols
45
+ unless @ignored_symbols
46
+ output = symbol_table_json_from("''", table_type: "symbol-table-full-json")
47
+ json = JSON.parse(output)
48
+ @ignored_symbols = SymbolTableParser.parse(json)
49
+ end
50
+
51
+ @ignored_symbols
52
+ end
53
+
54
+ def symbol_table_json_from(input, table_type: "symbol-table-json")
55
+ IO.popen(
56
+ [
57
+ SORBET,
58
+ # We don't want to pick up any sorbet/config files in cwd
59
+ "--no-config",
60
+ "--print=#{table_type}",
61
+ "--quiet",
62
+ input,
63
+ ].shelljoin,
64
+ err: "/dev/null"
65
+ ).read
66
+ end
67
+ end
68
+
69
+ class SymbolTableParser
70
+ def self.parse(object, parents = [])
71
+ symbols = Set.new
72
+
73
+ children = object.fetch("children", [])
74
+
75
+ children.each do |child|
76
+ kind = child.fetch("kind")
77
+ name = child.fetch("name")
78
+ name = name.fetch("name") if name.is_a?(Hash)
79
+
80
+ next if kind.nil? || name.nil?
81
+
82
+ next unless %w[CLASS STATIC_FIELD].include?(kind)
83
+ next if name =~ /[<>()$]/
84
+ next if name =~ /^[0-9]+$/
85
+ next if name == "T::Helpers"
86
+
87
+ parents << name
88
+
89
+ symbols.add(parents.join("::"))
90
+ symbols.merge(parse(child, parents))
91
+
92
+ parents.pop
93
+ end
94
+ symbols
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ # typed: strong
3
+
4
+ module Tapioca
5
+ module Compilers
6
+ class SymbolTableCompiler
7
+ extend(T::Sig)
8
+
9
+ sig do
10
+ params(
11
+ gem: Gemfile::Gem,
12
+ indent: Integer
13
+ ).returns(String)
14
+ end
15
+ def compile(
16
+ gem,
17
+ indent = 0
18
+ )
19
+ Tapioca::Compilers::SymbolTable::SymbolGenerator.new(gem, indent).generate
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require "bundler"
5
+
6
+ module Tapioca
7
+ class Gemfile
8
+ extend(T::Sig)
9
+
10
+ Spec = T.type_alias(T.any(::Bundler::StubSpecification, ::Gem::Specification))
11
+
12
+ sig { void }
13
+ def initialize
14
+ @gemfile = T.let(File.new(Bundler.default_gemfile), File)
15
+ @lockfile = T.let(File.new(Bundler.default_lockfile), File)
16
+ @dependencies = T.let(nil, T.nilable(T::Array[Gem]))
17
+ @definition = T.let(nil, T.nilable(Bundler::Definition))
18
+ end
19
+
20
+ sig { returns(T::Array[Gem]) }
21
+ def dependencies
22
+ @dependencies ||= begin
23
+ specs = definition.specs.to_a
24
+
25
+ definition
26
+ .resolve
27
+ .materialize(specs)
28
+ .reject { |spec| ignore_gem_spec?(spec) }
29
+ .map { |spec| Gem.new(spec) }
30
+ .uniq(&:rbi_file_name)
31
+ .sort_by(&:rbi_file_name)
32
+ end
33
+ end
34
+
35
+ sig { params(gem_name: String).returns(T.nilable(Gem)) }
36
+ def gem(gem_name)
37
+ dependencies.detect { |dep| dep.name == gem_name }
38
+ end
39
+
40
+ sig { params(initialize_file: T.nilable(String), require_file: T.nilable(String)).void }
41
+ def require_bundle(initialize_file, require_file)
42
+ require(initialize_file) if initialize_file && File.exist?(initialize_file)
43
+
44
+ runtime = Bundler::Runtime.new(File.dirname(gemfile.path), definition)
45
+ groups = Bundler.definition.groups
46
+ runtime.setup(*groups).require(*groups)
47
+
48
+ require(require_file) if require_file && File.exist?(require_file)
49
+
50
+ load_rails_engines
51
+ end
52
+
53
+ private
54
+
55
+ sig { returns(File) }
56
+ attr_reader(:gemfile, :lockfile)
57
+
58
+ sig { returns(Bundler::Definition) }
59
+ def definition
60
+ @definition ||= Bundler::Dsl.evaluate(gemfile, lockfile, {})
61
+ end
62
+
63
+ sig { params(spec: Spec).returns(T::Boolean) }
64
+ def ignore_gem_spec?(spec)
65
+ spec.name == "sorbet" ||
66
+ spec.full_gem_path.start_with?(gemfile_dir)
67
+ end
68
+
69
+ sig { returns(String) }
70
+ def gemfile_dir
71
+ File.expand_path(gemfile.path + "/..")
72
+ end
73
+
74
+ sig { void }
75
+ def load_rails_engines
76
+ return unless Object.const_defined?("Rails::Engine")
77
+ engines = Object.const_get("Rails::Engine").descendants.reject(&:abstract_railtie?)
78
+
79
+ engines.each do |engine|
80
+ errored_files = []
81
+
82
+ engine.config.eager_load_paths.each do |load_path|
83
+ Dir.glob("#{load_path}/**/*.rb").sort.each do |file|
84
+ require(file)
85
+ rescue LoadError, StandardError
86
+ errored_files << file
87
+ end
88
+ end
89
+
90
+ # Try files that have errored one more time
91
+ # It might have been a load order problem
92
+ errored_files.each do |file|
93
+ require(file)
94
+ rescue LoadError, StandardError
95
+ nil
96
+ end
97
+ end
98
+ end
99
+
100
+ class Gem
101
+ extend(T::Sig)
102
+
103
+ sig { params(spec: Spec).void }
104
+ def initialize(spec)
105
+ @spec = T.let(spec, Spec)
106
+ end
107
+
108
+ sig { returns(String) }
109
+ def full_gem_path
110
+ @spec.full_gem_path.to_s
111
+ end
112
+
113
+ sig { returns(T::Array[Pathname]) }
114
+ def files
115
+ @spec.full_require_paths.flat_map do |path|
116
+ Pathname.new(path).glob("**/*.rb")
117
+ end
118
+ end
119
+
120
+ sig { returns(String) }
121
+ def name
122
+ @spec.name
123
+ end
124
+
125
+ sig { returns(::Gem::Version) }
126
+ def version
127
+ @spec.version
128
+ end
129
+
130
+ sig { returns(String) }
131
+ def rbi_file_name
132
+ "#{name}@#{version}.rbi"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require 'pathname'
5
+ require 'thor'
6
+
7
+ module Tapioca
8
+ class Generator < ::Thor::Shell::Color
9
+ extend(T::Sig)
10
+
11
+ DEFAULT_OUTDIR = "sorbet/rbi/gems"
12
+
13
+ sig { returns(Pathname) }
14
+ attr_reader :outdir
15
+ sig { returns(T.nilable(String)) }
16
+ attr_reader :prerequire
17
+ sig { returns(T.nilable(String)) }
18
+ attr_reader :postrequire
19
+ sig { returns(String) }
20
+ attr_reader :command
21
+ sig { returns(T::Hash[String, String]) }
22
+ attr_reader :typed_overrides
23
+
24
+ sig do
25
+ params(
26
+ outdir: T.nilable(String),
27
+ prerequire: T.nilable(String),
28
+ postrequire: T.nilable(String),
29
+ command: T.nilable(String),
30
+ typed_overrides: T.nilable(T::Hash[String, String])
31
+ ).void
32
+ end
33
+ def initialize(outdir: nil, prerequire: nil, postrequire: nil, command: nil, typed_overrides: nil)
34
+ @outdir = T.let(Pathname.new(outdir || DEFAULT_OUTDIR), Pathname)
35
+ @prerequire = T.let(prerequire, T.nilable(String))
36
+ @postrequire = T.let(postrequire, T.nilable(String))
37
+ @command = T.let(command || default_command, String)
38
+ @typed_overrides = T.let(typed_overrides || {}, T::Hash[String, String])
39
+ @bundle = T.let(nil, T.nilable(Gemfile))
40
+ @compiler = T.let(nil, T.nilable(Compilers::SymbolTableCompiler))
41
+ @existing_rbis = T.let(nil, T.nilable(T::Hash[String, String]))
42
+ @expected_rbis = T.let(nil, T.nilable(T::Hash[String, String]))
43
+ super()
44
+ end
45
+
46
+ sig { params(gem_names: T::Array[String]).void }
47
+ def build_gem_rbis(gem_names)
48
+ require_gem_file
49
+
50
+ gems_to_generate(gem_names).map do |gem|
51
+ say("Processing '#{gem.name}' gem:", :green)
52
+ indent do
53
+ compile_rbi(gem)
54
+ puts
55
+ end
56
+ end
57
+
58
+ say("All operations performed in working directory.", [:green, :bold])
59
+ say("Please review changes and commit them.", [:green, :bold])
60
+ end
61
+
62
+ sig { void }
63
+ def sync_rbis_with_gemfile
64
+ anything_done = [
65
+ perform_removals,
66
+ perform_additions,
67
+ ].any?
68
+
69
+ if anything_done
70
+ say("All operations performed in working directory.", [:green, :bold])
71
+ say("Please review changes and commit them.", [:green, :bold])
72
+ else
73
+ say("No operations performed, all RBIs are up-to-date.", [:green, :bold])
74
+ end
75
+
76
+ puts
77
+ end
78
+
79
+ private
80
+
81
+ sig { returns(String) }
82
+ def default_command
83
+ command = File.basename($PROGRAM_NAME)
84
+ args = ARGV.join(" ")
85
+
86
+ "#{command} #{args}"
87
+ end
88
+
89
+ sig { returns(Gemfile) }
90
+ def bundle
91
+ @bundle ||= Gemfile.new
92
+ end
93
+
94
+ sig { returns(Compilers::SymbolTableCompiler) }
95
+ def compiler
96
+ @compiler ||= Compilers::SymbolTableCompiler.new
97
+ end
98
+
99
+ sig { void }
100
+ def require_gem_file
101
+ say("Requiring all gems to prepare for compiling... ")
102
+ bundle.require_bundle(prerequire, postrequire)
103
+ say(" Done", :green)
104
+ puts
105
+ end
106
+
107
+ sig { returns(T::Hash[String, String]) }
108
+ def existing_rbis
109
+ @existing_rbis ||= Dir.glob("*@*.rbi", T.unsafe(base: outdir))
110
+ .map { |f| File.basename(f, ".*").split('@') }
111
+ .to_h
112
+ end
113
+
114
+ sig { returns(T::Hash[String, String]) }
115
+ def expected_rbis
116
+ @expected_rbis ||= bundle.dependencies
117
+ .map { |gem| [gem.name, gem.version.to_s] }
118
+ .to_h
119
+ end
120
+
121
+ sig { params(gem_name: String, version: String).returns(Pathname) }
122
+ def rbi_filename(gem_name, version)
123
+ outdir / "#{gem_name}@#{version}.rbi"
124
+ end
125
+
126
+ sig { params(gem_name: String).returns(Pathname) }
127
+ def existing_rbi(gem_name)
128
+ rbi_filename(gem_name, T.must(existing_rbis[gem_name]))
129
+ end
130
+
131
+ sig { params(gem_name: String).returns(Pathname) }
132
+ def expected_rbi(gem_name)
133
+ rbi_filename(gem_name, T.must(expected_rbis[gem_name]))
134
+ end
135
+
136
+ sig { params(gem_name: String).returns(T::Boolean) }
137
+ def rbi_exists?(gem_name)
138
+ existing_rbis.key?(gem_name)
139
+ end
140
+
141
+ sig { returns(T::Array[String]) }
142
+ def removed_rbis
143
+ (existing_rbis.keys - expected_rbis.keys).sort
144
+ end
145
+
146
+ sig { returns(T::Array[String]) }
147
+ def added_rbis
148
+ expected_rbis.select do |name, value|
149
+ existing_rbis[name] != value
150
+ end.keys.sort
151
+ end
152
+
153
+ sig { params(filename: Pathname).void }
154
+ def add(filename)
155
+ say("++ Adding: #{filename}")
156
+ end
157
+
158
+ sig { params(filename: Pathname).void }
159
+ def remove(filename)
160
+ say("-- Removing: #{filename}")
161
+ filename.unlink
162
+ end
163
+
164
+ sig { params(old_filename: Pathname, new_filename: Pathname).void }
165
+ def move(old_filename, new_filename)
166
+ say("-> Moving: #{old_filename} to #{new_filename}")
167
+ old_filename.rename(new_filename.to_s)
168
+ end
169
+
170
+ sig { void }
171
+ def perform_removals
172
+ say("Removing RBI files of gems that have been removed:", [:blue, :bold])
173
+ puts
174
+
175
+ anything_done = T.let(false, T::Boolean)
176
+
177
+ gems = removed_rbis
178
+
179
+ indent do
180
+ if gems.empty?
181
+ say("Nothing to do.")
182
+ else
183
+ gems.each do |removed|
184
+ filename = existing_rbi(removed)
185
+ remove(filename)
186
+ end
187
+
188
+ anything_done = true
189
+ end
190
+ end
191
+
192
+ puts
193
+
194
+ anything_done
195
+ end
196
+
197
+ sig { void }
198
+ def perform_additions
199
+ say("Generating RBI files of gems that are added or updated:", [:blue, :bold])
200
+ puts
201
+
202
+ anything_done = T.let(false, T::Boolean)
203
+
204
+ gems = added_rbis
205
+
206
+ indent do
207
+ if gems.empty?
208
+ say("Nothing to do.")
209
+ else
210
+ require_gem_file
211
+
212
+ gems.each do |gem_name|
213
+ filename = expected_rbi(gem_name)
214
+
215
+ if rbi_exists?(gem_name)
216
+ old_filename = existing_rbi(gem_name)
217
+ move(old_filename, filename) unless old_filename == filename
218
+ end
219
+
220
+ gem = T.must(bundle.gem(gem_name))
221
+ compile_rbi(gem)
222
+ add(filename)
223
+
224
+ puts
225
+ end
226
+ end
227
+
228
+ anything_done = true
229
+ end
230
+
231
+ puts
232
+
233
+ anything_done
234
+ end
235
+
236
+ sig do
237
+ params(gem_names: T::Array[String])
238
+ .returns(T::Array[Gemfile::Gem])
239
+ end
240
+ def gems_to_generate(gem_names)
241
+ return bundle.dependencies if gem_names.empty?
242
+
243
+ gem_names.map do |gem_name|
244
+ gem = bundle.gem(gem_name)
245
+ if gem.nil?
246
+ say("Error: Cannot find gem '#{gem_name}'", :red)
247
+ exit(1)
248
+ end
249
+ gem
250
+ end
251
+ end
252
+
253
+ sig { params(command: String, typed_sigil: String).returns(String) }
254
+ def rbi_header(command, typed_sigil)
255
+ <<~HEAD
256
+ # This file is autogenerated. Do not edit it by hand. Regenerate it with:
257
+ # #{command}
258
+
259
+ # typed: #{typed_sigil}
260
+
261
+ HEAD
262
+ end
263
+
264
+ sig { params(gem: Gemfile::Gem).void }
265
+ def compile_rbi(gem)
266
+ compiler = Compilers::SymbolTableCompiler.new
267
+ gem_name = set_color(gem.name, :yellow, :bold)
268
+ say("Compiling #{gem_name}, this may take a few seconds... ")
269
+
270
+ typed_sigil = typed_overrides.fetch(gem.name, "true")
271
+
272
+ content = compiler.compile(gem)
273
+ content.prepend(rbi_header(command, typed_sigil))
274
+
275
+ FileUtils.mkdir_p(outdir)
276
+ filename = outdir / gem.rbi_file_name
277
+ File.write(filename.to_s, content)
278
+
279
+ say("Done", :green)
280
+
281
+ outdir.glob("#{gem.name}@*.rbi") do |file|
282
+ remove(file) unless file.basename.to_s == gem.rbi_file_name
283
+ end
284
+ end
285
+ end
286
+ end