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.
@@ -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