carton-tools 0.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +19 -0
- data/README.md +22 -0
- data/exe/carton +6 -0
- data/lib/carton/tools/bindings.rb +110 -0
- data/lib/carton/tools/checks.rb +360 -0
- data/lib/carton/tools/cli.rb +134 -0
- data/lib/carton/tools/project.rb +186 -0
- data/lib/carton/tools/signatures.rb +220 -0
- data/lib/carton/tools/source_file.rb +649 -0
- data/lib/carton/tools/version.rb +9 -0
- data/lib/carton/tools.rb +4 -0
- metadata +87 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'carton/error'
|
|
4
|
+
require 'carton/resolver'
|
|
5
|
+
require_relative 'source_file'
|
|
6
|
+
|
|
7
|
+
module Carton
|
|
8
|
+
module Tools
|
|
9
|
+
# The Cartons an app imports, found by following `import` and
|
|
10
|
+
# `import_relative` calls from the given files without running them.
|
|
11
|
+
# Name-based imports resolve against `load_path`, as they would against the
|
|
12
|
+
# importing Box's `$LOAD_PATH`. `specs` are the gems that files may belong
|
|
13
|
+
# to: under Bundler, the bundle's.
|
|
14
|
+
class Project
|
|
15
|
+
# A Carton: its Entrypoint, its files (the Entrypoint plus its namespace
|
|
16
|
+
# directory), and the `lib/` a gem-shaped directory import adds to its
|
|
17
|
+
# load path.
|
|
18
|
+
Unit =
|
|
19
|
+
Data.define(:entrypoint, :files, :load_path) do
|
|
20
|
+
def name = File.basename(entrypoint, '.rb')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# One import call and what it resolved to: a Unit, an error message, or
|
|
24
|
+
# a name not found on the load path, which a gem activated at runtime
|
|
25
|
+
# may still provide.
|
|
26
|
+
Edge = Data.define(:call, :unit, :error, :unfound)
|
|
27
|
+
|
|
28
|
+
attr_reader :files, :units, :edges
|
|
29
|
+
|
|
30
|
+
# `path` relative to the current directory, for messages and comments.
|
|
31
|
+
def self.relative(path)
|
|
32
|
+
pwd = File.realpath(Dir.pwd)
|
|
33
|
+
path.start_with?("#{pwd}/") ? path.delete_prefix("#{pwd}/") : path
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(paths, load_path: [], specs: nil)
|
|
37
|
+
@load_path = load_path
|
|
38
|
+
@specs = specs
|
|
39
|
+
@files = {}
|
|
40
|
+
@units = {}
|
|
41
|
+
@unit_by_file = {}
|
|
42
|
+
ruby_files(paths).each { |path| parse(path) }
|
|
43
|
+
discover
|
|
44
|
+
@edges = imports.map { |call| resolve(call) }
|
|
45
|
+
@edge_by_call = @edges.to_h { |edge| [edge.call, edge] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The absolute path an import or require loads, or nil.
|
|
49
|
+
def target(call)
|
|
50
|
+
unless SourceFile::IMPORTS.include?(call.kind.to_sym)
|
|
51
|
+
return locate(call)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@edge_by_call[call]&.unit&.entrypoint
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The gem whose require paths hold `path`, or nil.
|
|
58
|
+
def gem_for(path)
|
|
59
|
+
gem_dirs
|
|
60
|
+
.select { |dir, _| path.start_with?("#{dir}/") }
|
|
61
|
+
.max_by { |dir, _| dir.length }
|
|
62
|
+
&.last
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The Unit `file` belongs to, or nil for the app, which runs in Main.
|
|
66
|
+
def owner(file) = @unit_by_file[file]
|
|
67
|
+
|
|
68
|
+
def imports = @files.values.flat_map(&:imports)
|
|
69
|
+
|
|
70
|
+
def requires = @files.values.flat_map(&:requires)
|
|
71
|
+
|
|
72
|
+
# Resolve a `require` or `require_relative` call as Ruby would, to an
|
|
73
|
+
# absolute path or nil.
|
|
74
|
+
def locate(call)
|
|
75
|
+
return unless call.path
|
|
76
|
+
|
|
77
|
+
base_dir = File.dirname(call.file) if call.relative?
|
|
78
|
+
feature =
|
|
79
|
+
Resolver.locate(
|
|
80
|
+
call.path,
|
|
81
|
+
load_path: load_path_for(call.file),
|
|
82
|
+
base_dir:,
|
|
83
|
+
).feature
|
|
84
|
+
File.realpath(feature) if File.absolute_path?(feature)
|
|
85
|
+
rescue LoadError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The Unit whose files include `path`, if that Unit is imported.
|
|
90
|
+
def unit_containing(path)
|
|
91
|
+
@unit_by_file[path] || @units[Resolver.entrypoint_for(path)]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def gem_dirs
|
|
97
|
+
@gem_dirs ||=
|
|
98
|
+
(@specs || Gem::Specification.to_a).flat_map do |spec|
|
|
99
|
+
spec.full_require_paths.filter_map do |dir|
|
|
100
|
+
[File.realpath(dir), spec] if File.directory?(dir)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def ruby_files(paths)
|
|
106
|
+
files =
|
|
107
|
+
paths.flat_map do |path|
|
|
108
|
+
next [path] unless File.directory?(path)
|
|
109
|
+
|
|
110
|
+
Dir.glob('**/*.rb', base: path).sort.map { |f| File.join(path, f) }
|
|
111
|
+
end
|
|
112
|
+
files.map { |path| File.realpath(path) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse(path) = @files[path] ||= SourceFile.parse(path)
|
|
116
|
+
|
|
117
|
+
# Follow imports and `require_relative` calls until no new files or
|
|
118
|
+
# Cartons turn up. A file's load path depends on the Carton it belongs
|
|
119
|
+
# to, which may be found after the file is read.
|
|
120
|
+
def discover
|
|
121
|
+
loop do
|
|
122
|
+
required =
|
|
123
|
+
requires.select(&:relative?).filter_map { |call| locate(call) }
|
|
124
|
+
required = required.grep(/\.rb\z/).reject { |path| @files.key?(path) }
|
|
125
|
+
found = imports.filter_map { |call| resolve(call).unit }
|
|
126
|
+
fresh = found.reject { |unit| @units.key?(unit.entrypoint) }
|
|
127
|
+
break if required.empty? && fresh.empty?
|
|
128
|
+
|
|
129
|
+
required.each { |path| parse(path) }
|
|
130
|
+
fresh.each { |unit| add_unit(unit) }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def add_unit(unit)
|
|
135
|
+
@units[unit.entrypoint] = unit
|
|
136
|
+
unit.files.each do |file|
|
|
137
|
+
@unit_by_file[file] ||= unit
|
|
138
|
+
parse(file)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resolve(call)
|
|
143
|
+
unless call.path
|
|
144
|
+
return Edge.new(call:, unit: nil, error: nil, unfound: false)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
base_dir = File.dirname(call.file) if call.relative?
|
|
148
|
+
target =
|
|
149
|
+
Resolver.resolve(
|
|
150
|
+
call.path,
|
|
151
|
+
load_path: load_path_for(call.file),
|
|
152
|
+
base_dir:,
|
|
153
|
+
)
|
|
154
|
+
unless File.absolute_path?(target.feature)
|
|
155
|
+
return Edge.new(call:, unit: nil, error: nil, unfound: true)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
Edge.new(call:, unit: unit_for(target), error: nil, unfound: false)
|
|
159
|
+
rescue ImportError, LoadError => e
|
|
160
|
+
Edge.new(call:, unit: nil, error: e.message, unfound: false)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def unit_for(target)
|
|
164
|
+
entrypoint = File.realpath(target.feature)
|
|
165
|
+
@units.fetch(entrypoint) do
|
|
166
|
+
dir = entrypoint.delete_suffix('.rb')
|
|
167
|
+
files =
|
|
168
|
+
Dir
|
|
169
|
+
.glob('**/*.rb', base: dir)
|
|
170
|
+
.sort
|
|
171
|
+
.map { |file| File.realpath(File.join(dir, file)) }
|
|
172
|
+
Unit.new(
|
|
173
|
+
entrypoint:,
|
|
174
|
+
files: [entrypoint, *files],
|
|
175
|
+
load_path: target.load_path,
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Like the runtime, a gem-shaped Carton's `lib/` comes first in its Box.
|
|
181
|
+
def load_path_for(file)
|
|
182
|
+
[owner(file)&.load_path, *@load_path].compact
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'project'
|
|
4
|
+
|
|
5
|
+
module Carton
|
|
6
|
+
module Tools
|
|
7
|
+
# RBS for a Project's imports, so a type checker like Steep knows what each
|
|
8
|
+
# `import` returns. An exported constant names the Carton's own type,
|
|
9
|
+
# which the Carton's RBS declares, and a keyword export is a record type.
|
|
10
|
+
# Bare Cartons each get a module under `Carton::Namespace`, typed from
|
|
11
|
+
# rbs-inline `#:` annotations and literal values, or `untyped`.
|
|
12
|
+
class Signatures
|
|
13
|
+
HEADER = <<~RBS
|
|
14
|
+
# Generated by `carton rbs` from the app's imports. Do not edit.
|
|
15
|
+
RBS
|
|
16
|
+
|
|
17
|
+
def initialize(project)
|
|
18
|
+
@project = project
|
|
19
|
+
@names = namespace_names
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_s
|
|
23
|
+
[HEADER, kernel, *namespaces, *constants].compact.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# One `Kernel#import` overload per literal path that means the same
|
|
29
|
+
# Carton everywhere it is written. `import_relative` paths depend on the
|
|
30
|
+
# calling file, so one that resolves differently in two files is left to
|
|
31
|
+
# the untyped signature.
|
|
32
|
+
def kernel
|
|
33
|
+
overloads =
|
|
34
|
+
%w[import import_relative].filter_map do |kind|
|
|
35
|
+
types =
|
|
36
|
+
edges_by_path(kind).filter_map do |path, units|
|
|
37
|
+
next unless units.uniq.size == 1
|
|
38
|
+
|
|
39
|
+
type = type_of(units.first)
|
|
40
|
+
"(#{path.inspect} path) -> #{type}" unless type == 'untyped'
|
|
41
|
+
end
|
|
42
|
+
next if types.empty?
|
|
43
|
+
|
|
44
|
+
lines =
|
|
45
|
+
types.each_with_index.map do |type, index|
|
|
46
|
+
if index.zero?
|
|
47
|
+
" def #{kind}: #{type}"
|
|
48
|
+
else
|
|
49
|
+
"#{' ' * (kind.size + 6)}| #{type}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
[*lines, "#{' ' * (kind.size + 6)}| ..."].join("\n")
|
|
53
|
+
end
|
|
54
|
+
return if overloads.empty?
|
|
55
|
+
|
|
56
|
+
['module Kernel', ' private', *overloads, 'end', ''].join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def edges_by_path(kind)
|
|
60
|
+
@project
|
|
61
|
+
.edges
|
|
62
|
+
.select { |edge| edge.unit && edge.call.kind == kind }
|
|
63
|
+
.group_by { |edge| edge.call.path }
|
|
64
|
+
.transform_values { |edges| edges.map(&:unit) }
|
|
65
|
+
.sort
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Each Bare Carton as a module whose singleton methods and constants
|
|
69
|
+
# mirror its `Carton::Namespace`.
|
|
70
|
+
def namespaces
|
|
71
|
+
@names
|
|
72
|
+
.sort_by { |_, name| name }
|
|
73
|
+
.map do |unit, name|
|
|
74
|
+
members = surface(unit).map { |member| " #{member}" }
|
|
75
|
+
[
|
|
76
|
+
"# #{Project.relative(unit.entrypoint)}",
|
|
77
|
+
"module Carton::Namespace::#{name}",
|
|
78
|
+
' extend ::Carton::NamespaceMethods',
|
|
79
|
+
*members,
|
|
80
|
+
'end',
|
|
81
|
+
'',
|
|
82
|
+
].join("\n")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A Bare Carton's surface is what its files define at top level.
|
|
87
|
+
def surface(unit)
|
|
88
|
+
files = unit.files.map { |path| file(path) }
|
|
89
|
+
methods = files.flat_map { |f| f.methods.keys }.uniq
|
|
90
|
+
constants = files.flat_map { |f| f.constants.keys + top_definitions(f) }
|
|
91
|
+
[
|
|
92
|
+
*methods.map do |name|
|
|
93
|
+
"def self.#{name}: #{method_type(unit, name)}"
|
|
94
|
+
end,
|
|
95
|
+
*constants.uniq.map do |name|
|
|
96
|
+
"#{name}: #{constant_type(unit, name)}"
|
|
97
|
+
end,
|
|
98
|
+
]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# `CONST = import ...` in any file, unless the Carton's own RBS already
|
|
102
|
+
# declares that name: `Billing = import 'billing'` for `export Billing`.
|
|
103
|
+
# A constant bound to two different Cartons is skipped.
|
|
104
|
+
def constants
|
|
105
|
+
bindings =
|
|
106
|
+
@project
|
|
107
|
+
.edges
|
|
108
|
+
.select { |edge| edge.unit && edge.call.constant }
|
|
109
|
+
.group_by { |edge| edge.call.constant }
|
|
110
|
+
.transform_values { |edges| edges.map(&:unit).uniq }
|
|
111
|
+
.select { |_, units| units.size == 1 }
|
|
112
|
+
lines =
|
|
113
|
+
bindings.sort.filter_map do |constant, (unit)|
|
|
114
|
+
type = type_of(unit)
|
|
115
|
+
next if type == "singleton(::#{constant})"
|
|
116
|
+
|
|
117
|
+
alias_of(constant, unit, type) || "#{constant}: #{type}"
|
|
118
|
+
end
|
|
119
|
+
return if lines.empty?
|
|
120
|
+
|
|
121
|
+
['# Constants bound to imports', *lines, ''].join("\n")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def alias_of(constant, unit, type)
|
|
125
|
+
target = type[/\Asingleton\((.+)\)\z/, 1] or return
|
|
126
|
+
return "module #{constant} = #{target}" if @names.key?(unit)
|
|
127
|
+
|
|
128
|
+
kind = definition_kind(unit, target.delete_prefix('::'))
|
|
129
|
+
"#{kind} #{constant} = #{target}" if kind
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def type_of(unit)
|
|
133
|
+
if @names.key?(unit)
|
|
134
|
+
return "singleton(::Carton::Namespace::#{@names[unit]})"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
exports = file(unit.entrypoint).exports
|
|
138
|
+
return 'untyped' unless exports.size == 1
|
|
139
|
+
|
|
140
|
+
export = exports.first
|
|
141
|
+
return "singleton(::#{export.value})" if export.value
|
|
142
|
+
return 'untyped' unless export.keys&.any?
|
|
143
|
+
|
|
144
|
+
fields = export.keys.map { |key, type| "#{key}: #{type}" }
|
|
145
|
+
"{ #{fields.join(', ')} }"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def exported?(unit) = file(unit.entrypoint).exports.any?
|
|
149
|
+
|
|
150
|
+
def file(path) = @project.files.fetch(path)
|
|
151
|
+
|
|
152
|
+
def method_type(unit, name)
|
|
153
|
+
unit.files.each do |path|
|
|
154
|
+
definition = file(path).methods[name]
|
|
155
|
+
return definition.type if definition
|
|
156
|
+
end
|
|
157
|
+
'(?) -> untyped'
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def constant_type(unit, name)
|
|
161
|
+
unit.files.each do |path|
|
|
162
|
+
type = file(path).constants[name]
|
|
163
|
+
return type if type
|
|
164
|
+
end
|
|
165
|
+
kind = definition_kind(unit, name)
|
|
166
|
+
kind ? "singleton(::#{name})" : 'untyped'
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def definition_kind(unit, name)
|
|
170
|
+
unit.files.each do |path|
|
|
171
|
+
kind = file(path).definitions[name]
|
|
172
|
+
return kind if kind
|
|
173
|
+
end
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def top_definitions(entry)
|
|
178
|
+
entry.definitions.keys.reject { |name| name.include?('::') }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# A module name under `Carton::Namespace` for each Bare Carton: its
|
|
182
|
+
# Entrypoint's name, camelized, with parent directories prepended while
|
|
183
|
+
# two Cartons share a name.
|
|
184
|
+
def namespace_names
|
|
185
|
+
units =
|
|
186
|
+
@project
|
|
187
|
+
.edges
|
|
188
|
+
.filter_map(&:unit)
|
|
189
|
+
.uniq
|
|
190
|
+
.reject { |unit| exported?(unit) }
|
|
191
|
+
depth = units.to_h { |unit| [unit, 1] }
|
|
192
|
+
loop do
|
|
193
|
+
names = units.to_h { |unit| [unit, camelize(unit, depth[unit])] }
|
|
194
|
+
clashes = names.keys.group_by { |unit| names[unit] }.values
|
|
195
|
+
clashes = clashes.select { |group| group.size > 1 }.flatten
|
|
196
|
+
if clashes.empty? || clashes.none? { |unit| deeper?(unit, depth) }
|
|
197
|
+
return names
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
clashes.each { |unit| depth[unit] += 1 }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def deeper?(unit, depth) = depth[unit] < path_parts(unit).size
|
|
205
|
+
|
|
206
|
+
def camelize(unit, depth)
|
|
207
|
+
words = path_parts(unit).last(depth).join('_').split(/[^A-Za-z0-9]+/)
|
|
208
|
+
words.map { |word| word[0].upcase + word[1..] }.join
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def path_parts(unit)
|
|
212
|
+
unit
|
|
213
|
+
.entrypoint
|
|
214
|
+
.delete_suffix('.rb')
|
|
215
|
+
.split('/')
|
|
216
|
+
.reject { |part| part.empty? || part == 'lib' }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|