klenod-build 0.0.1
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/README.md +29 -0
- data/lib/klenod/build/asset.rb +235 -0
- data/lib/klenod/build/asset_compression.rb +58 -0
- data/lib/klenod/build/asset_generation_queue.rb +50 -0
- data/lib/klenod/build/cli/application.rb +108 -0
- data/lib/klenod/build/cli.rb +3 -0
- data/lib/klenod/build/config.rb +142 -0
- data/lib/klenod/build/context.rb +370 -0
- data/lib/klenod/build/dependency.rb +21 -0
- data/lib/klenod/build/errors.rb +175 -0
- data/lib/klenod/build/filesystem_resolver.rb +147 -0
- data/lib/klenod/build/graph/invalidator.rb +246 -0
- data/lib/klenod/build/graph.rb +864 -0
- data/lib/klenod/build/graphviz.rb +282 -0
- data/lib/klenod/build/hashing.rb +21 -0
- data/lib/klenod/build/invalidation_result.rb +55 -0
- data/lib/klenod/build/load_result.rb +7 -0
- data/lib/klenod/build/loaded_module.rb +38 -0
- data/lib/klenod/build/module_id.rb +100 -0
- data/lib/klenod/build/module_record.rb +22 -0
- data/lib/klenod/build/plugin.rb +35 -0
- data/lib/klenod/build/plugins/class_names_runtime.rb +125 -0
- data/lib/klenod/build/plugins/component_defaults.rb +12 -0
- data/lib/klenod/build/plugins/data_plugin.rb +117 -0
- data/lib/klenod/build/plugins/gem_import_plugin.rb +125 -0
- data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.json +17687 -0
- data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.rb +105 -0
- data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.txt +31 -0
- data/lib/klenod/build/plugins/google_fonts_plugin.rb +387 -0
- data/lib/klenod/build/plugins/haml_plugin/companions.rb +40 -0
- data/lib/klenod/build/plugins/haml_plugin/errors.rb +114 -0
- data/lib/klenod/build/plugins/haml_plugin/helper_source.rb +123 -0
- data/lib/klenod/build/plugins/haml_plugin/parser.rb +85 -0
- data/lib/klenod/build/plugins/haml_plugin/transformer/ruby_builder.rb +1039 -0
- data/lib/klenod/build/plugins/haml_plugin/transformer.rb +766 -0
- data/lib/klenod/build/plugins/haml_plugin.rb +369 -0
- data/lib/klenod/build/plugins/image_plugin.rb +401 -0
- data/lib/klenod/build/plugins/intl_plugin.rb +34 -0
- data/lib/klenod/build/plugins/markdown_compiler.rb +180 -0
- data/lib/klenod/build/plugins/markdown_plugin.rb +161 -0
- data/lib/klenod/build/plugins/router_plugin.rb +942 -0
- data/lib/klenod/build/plugins/ruby_plugin.rb +34 -0
- data/lib/klenod/build/plugins/svg_plugin.rb +197 -0
- data/lib/klenod/build/plugins.rb +22 -0
- data/lib/klenod/build/profiler.rb +79 -0
- data/lib/klenod/build/resolution_error_formatter.rb +42 -0
- data/lib/klenod/build/resolver.rb +95 -0
- data/lib/klenod/build/ruby_import_rewriter.rb +436 -0
- data/lib/klenod/build/source_map/editor.rb +110 -0
- data/lib/klenod/build/source_map/map.rb +168 -0
- data/lib/klenod/build/source_map/vlq.rb +68 -0
- data/lib/klenod/build/source_map.rb +12 -0
- data/lib/klenod/build/transform_result.rb +12 -0
- data/lib/klenod/build/version.rb +7 -0
- data/lib/klenod/build/watched_pattern.rb +11 -0
- data/lib/klenod/build/watcher.rb +141 -0
- data/lib/klenod/build.rb +15 -0
- metadata +310 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "klenod/runtime/bundle_format"
|
|
6
|
+
|
|
7
|
+
require_relative "graph"
|
|
8
|
+
require_relative "loaded_module"
|
|
9
|
+
require_relative "plugin"
|
|
10
|
+
require_relative "plugins"
|
|
11
|
+
require_relative "config"
|
|
12
|
+
require_relative "asset_compression"
|
|
13
|
+
|
|
14
|
+
module Klenod
|
|
15
|
+
module Build
|
|
16
|
+
AssetWriteResult = Data.define(:written_paths, :removed_paths, :skipped_paths) do
|
|
17
|
+
def initialize(written_paths = nil, removed_paths = nil, skipped_paths = nil, **keywords)
|
|
18
|
+
written_paths = keywords.fetch(:written_paths, written_paths)
|
|
19
|
+
removed_paths = keywords.fetch(:removed_paths, removed_paths)
|
|
20
|
+
skipped_paths = keywords.fetch(:skipped_paths, skipped_paths || [])
|
|
21
|
+
|
|
22
|
+
super(
|
|
23
|
+
written_paths: written_paths,
|
|
24
|
+
removed_paths: removed_paths,
|
|
25
|
+
skipped_paths: skipped_paths
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def empty?
|
|
30
|
+
written_paths.empty? && removed_paths.empty?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
AppliedUpdate = Data.define(:event, :entry, :exports, :asset_write_result, :errors) do
|
|
35
|
+
def success?
|
|
36
|
+
errors.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def failed?
|
|
40
|
+
!success?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def entry_record
|
|
44
|
+
entry&.record
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def each_error(&block)
|
|
48
|
+
return errors.each unless block
|
|
49
|
+
|
|
50
|
+
errors.each(&block)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def error_messages
|
|
54
|
+
errors.map { |module_id, error| "#{module_id}: #{error.class}: #{error.message}" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def asset_files_changed?
|
|
58
|
+
asset_write_result && !asset_write_result.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def written_asset_paths
|
|
62
|
+
asset_write_result&.written_paths || []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def removed_asset_paths
|
|
66
|
+
asset_write_result&.removed_paths || []
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def skipped_asset_paths
|
|
70
|
+
asset_write_result&.skipped_paths || []
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class Context
|
|
75
|
+
class DefaultPlugins
|
|
76
|
+
include Enumerable
|
|
77
|
+
|
|
78
|
+
def each(&block)
|
|
79
|
+
return enum_for(:each) unless block
|
|
80
|
+
|
|
81
|
+
Context.default_plugins.each(&block)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_a
|
|
85
|
+
Context.default_plugins
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
DEFAULT_PLUGINS = DefaultPlugins.new.freeze
|
|
90
|
+
|
|
91
|
+
def self.default_plugins
|
|
92
|
+
[
|
|
93
|
+
Plugins::RubyPlugin.new,
|
|
94
|
+
Plugins::IntlPlugin.new,
|
|
95
|
+
Plugins::HamlPlugin.new,
|
|
96
|
+
Plugins::MarkdownPlugin.new,
|
|
97
|
+
Plugins::GemImportPlugin.new,
|
|
98
|
+
Plugins::SvgPlugin.new,
|
|
99
|
+
Plugins::ImagePlugin.new,
|
|
100
|
+
Plugins::JsonPlugin.new,
|
|
101
|
+
Plugins::YamlPlugin.new,
|
|
102
|
+
Plugins::TomlPlugin.new,
|
|
103
|
+
Plugins::TextPlugin.new
|
|
104
|
+
]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def initialize(
|
|
108
|
+
source_dir:,
|
|
109
|
+
plugins: DEFAULT_PLUGINS,
|
|
110
|
+
mode: :development,
|
|
111
|
+
asset_generation_concurrency: AssetGenerationQueue::DEFAULT_CONCURRENCY,
|
|
112
|
+
asset_download_concurrency: AssetGenerationQueue::DEFAULT_DOWNLOAD_CONCURRENCY,
|
|
113
|
+
profiler: nil
|
|
114
|
+
)
|
|
115
|
+
@source_dir = source_dir
|
|
116
|
+
plugins = plugins.to_a if plugins.equal?(DEFAULT_PLUGINS)
|
|
117
|
+
@plugins = plugins
|
|
118
|
+
@mode = mode
|
|
119
|
+
@update_handlers = []
|
|
120
|
+
@graph =
|
|
121
|
+
Graph.new(
|
|
122
|
+
source_dir: source_dir,
|
|
123
|
+
plugins: plugins,
|
|
124
|
+
mode: mode,
|
|
125
|
+
asset_generation_concurrency: asset_generation_concurrency,
|
|
126
|
+
asset_download_concurrency: asset_download_concurrency,
|
|
127
|
+
profiler: profiler
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
attr_reader :graph, :mode
|
|
132
|
+
|
|
133
|
+
def evaluate(specifier)
|
|
134
|
+
@graph.load(specifier)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def collect(specifier)
|
|
138
|
+
loaded(@graph.collect(specifier))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def entry(specifier)
|
|
142
|
+
collect(specifier)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def loaded(record_or_module_id)
|
|
146
|
+
return record_or_module_id if record_or_module_id.is_a?(LoadedModule)
|
|
147
|
+
|
|
148
|
+
LoadedModule.new(self, record_or_module_id.respond_to?(:id) ? record_or_module_id.id : record_or_module_id)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def build(entrypoints:, output:, assets_dir: nil)
|
|
152
|
+
bundle = @graph.bundle(entrypoints: entrypoints)
|
|
153
|
+
assets_dir ? write_assets(assets_dir) : wait_for_assets
|
|
154
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
155
|
+
File.binwrite(output, Runtime::BundleFormat.dump(bundle))
|
|
156
|
+
bundle
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def build_executable(entrypoints:, output:, assets_dir: nil)
|
|
160
|
+
bundle = @graph.bundle(entrypoints: entrypoints)
|
|
161
|
+
assets_dir ? write_assets(assets_dir) : wait_for_assets
|
|
162
|
+
FileUtils.mkdir_p(File.dirname(output))
|
|
163
|
+
File.binwrite(output, executable_bundle_source + Runtime::BundleFormat.dump(bundle))
|
|
164
|
+
FileUtils.chmod("+x", output)
|
|
165
|
+
bundle
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def invalidate_paths(changed_paths, removed_paths: [])
|
|
169
|
+
@graph.invalidate_paths(changed_paths, removed_paths: removed_paths)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def assets
|
|
173
|
+
@graph.assets
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def asset(output_path)
|
|
177
|
+
@graph.asset(output_path)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def exports(record_or_module_id)
|
|
181
|
+
return record_or_module_id.exports if record_or_module_id.is_a?(LoadedModule)
|
|
182
|
+
|
|
183
|
+
@graph.exports(record_or_module_id)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def evaluated?(record_or_module_id)
|
|
187
|
+
record_or_module_id = record_or_module_id.id if record_or_module_id.is_a?(LoadedModule)
|
|
188
|
+
@graph.evaluated?(record_or_module_id)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def module_id_for(record_or_module_id)
|
|
192
|
+
return record_or_module_id.id if record_or_module_id.is_a?(LoadedModule)
|
|
193
|
+
|
|
194
|
+
@graph.module_id_for(record_or_module_id)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def asset_bytes(output_path, assets_dir: nil)
|
|
198
|
+
asset = asset(output_path)
|
|
199
|
+
return asset.bytes unless assets_dir
|
|
200
|
+
|
|
201
|
+
path = asset_disk_path(asset.output_path, Pathname.new(assets_dir))
|
|
202
|
+
write_asset(asset, Pathname.new(assets_dir)) unless File.file?(path)
|
|
203
|
+
File.binread(path)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def assets_for(logical_name)
|
|
207
|
+
@graph.assets_for(logical_name)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def assets_for_module(record_or_module_id, type: nil, content_type: nil, recursive: true)
|
|
211
|
+
@graph.assets_for_module(module_refs_for_assets(record_or_module_id), type: type, content_type: content_type, recursive: recursive)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def asset_references_for_module(record_or_module_id, type: nil, content_type: nil, recursive: true)
|
|
215
|
+
@graph.asset_references_for_module(module_refs_for_assets(record_or_module_id), type: type, content_type: content_type, recursive: recursive)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def each_asset(&block)
|
|
219
|
+
@graph.each_asset(&block)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def wait_for_assets
|
|
223
|
+
@graph.wait_for_assets
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def asset_generation_queue
|
|
227
|
+
@graph.asset_generation_queue
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def write_assets(assets_dir, &block)
|
|
231
|
+
assets_root = Pathname.new(assets_dir)
|
|
232
|
+
results =
|
|
233
|
+
with_async_task do |task|
|
|
234
|
+
assets.each_value.map { |asset| task.async { write_asset(asset, assets_root, &block) } }.map(&:wait)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
asset_write_result(results, removed_paths: [])
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def write_asset_updates(asset_updates, assets_dir:, &block)
|
|
241
|
+
assets_root = Pathname.new(assets_dir)
|
|
242
|
+
removed_paths =
|
|
243
|
+
asset_updates.filter_map do |update|
|
|
244
|
+
remove_asset(update.output_path, assets_root) if update.removed?
|
|
245
|
+
end
|
|
246
|
+
written_paths =
|
|
247
|
+
asset_updates.filter_map do |update|
|
|
248
|
+
write_asset(update.current_asset, assets_root, &block) if update.current_asset
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
asset_write_result(written_paths, removed_paths: removed_paths)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def apply_update(event, entry:, assets_dir: nil)
|
|
255
|
+
return AppliedUpdate.new(event, nil, nil, nil, event.result.errors.freeze) if event.result.errors.any?
|
|
256
|
+
|
|
257
|
+
entry = loaded(entry)
|
|
258
|
+
entry.record
|
|
259
|
+
asset_write_result = assets_dir && write_asset_updates(event.asset_updates, assets_dir: assets_dir)
|
|
260
|
+
|
|
261
|
+
AppliedUpdate.new(
|
|
262
|
+
event,
|
|
263
|
+
entry,
|
|
264
|
+
evaluated?(entry) ? entry.exports : nil,
|
|
265
|
+
asset_write_result,
|
|
266
|
+
[].freeze
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def on_update(&block)
|
|
271
|
+
@update_handlers << block
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def emit_update(event)
|
|
275
|
+
@update_handlers.each { |handler| handler.call(event) }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
private
|
|
279
|
+
|
|
280
|
+
def executable_bundle_source
|
|
281
|
+
<<~RUBY.b
|
|
282
|
+
#!/usr/bin/env ruby
|
|
283
|
+
# encoding: ASCII-8BIT
|
|
284
|
+
# frozen_string_literal: true
|
|
285
|
+
|
|
286
|
+
require "klenod/runtime"
|
|
287
|
+
|
|
288
|
+
Klenod::Runtime.load_executable_bundle(__FILE__).load_entrypoints
|
|
289
|
+
|
|
290
|
+
__END__
|
|
291
|
+
RUBY
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def write_asset(asset, assets_root, &block)
|
|
295
|
+
output_path = asset_disk_path(asset.output_path, assets_root)
|
|
296
|
+
status = asset.write_to(output_path) do |event, event_asset, event_path|
|
|
297
|
+
block&.call(event, event_asset, event_path)
|
|
298
|
+
end
|
|
299
|
+
AssetCompression.write_sidecar(asset, output_path.to_s)
|
|
300
|
+
block&.call(status, asset, output_path.to_s)
|
|
301
|
+
|
|
302
|
+
[status, output_path.to_s]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def asset_write_result(results, removed_paths:)
|
|
306
|
+
written_paths = []
|
|
307
|
+
skipped_paths = []
|
|
308
|
+
|
|
309
|
+
results.each do |status, path|
|
|
310
|
+
case status
|
|
311
|
+
when :written then written_paths << path
|
|
312
|
+
when :skipped then skipped_paths << path
|
|
313
|
+
else raise ArgumentError, "unknown asset write status: #{status.inspect}"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
AssetWriteResult.new(
|
|
318
|
+
written_paths: written_paths.freeze,
|
|
319
|
+
removed_paths: removed_paths.freeze,
|
|
320
|
+
skipped_paths: skipped_paths.freeze
|
|
321
|
+
)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def remove_asset(output_path, assets_root)
|
|
325
|
+
path = asset_disk_path(output_path, assets_root)
|
|
326
|
+
|
|
327
|
+
FileUtils.rm_f(path)
|
|
328
|
+
AssetCompression.remove_sidecar(path.to_s)
|
|
329
|
+
path.to_s
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def with_async_task(&block)
|
|
333
|
+
task = current_async_task
|
|
334
|
+
return block.call(task) if task
|
|
335
|
+
|
|
336
|
+
with_experimental_warnings_suppressed do
|
|
337
|
+
Async(&block).wait
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def current_async_task
|
|
342
|
+
Async::Task.current
|
|
343
|
+
rescue RuntimeError
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def with_experimental_warnings_suppressed
|
|
348
|
+
enabled = Warning[:experimental]
|
|
349
|
+
Warning[:experimental] = false
|
|
350
|
+
yield
|
|
351
|
+
ensure
|
|
352
|
+
Warning[:experimental] = enabled
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def asset_disk_path(output_path, assets_root)
|
|
356
|
+
assets_root.join(output_path.delete_prefix("/"))
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def module_refs_for_assets(record_or_module_id)
|
|
360
|
+
if record_or_module_id.is_a?(Array)
|
|
361
|
+
record_or_module_id.map { |module_ref| module_ref.is_a?(LoadedModule) ? module_ref.id : module_ref }
|
|
362
|
+
elsif record_or_module_id.is_a?(LoadedModule)
|
|
363
|
+
record_or_module_id.id
|
|
364
|
+
else
|
|
365
|
+
record_or_module_id
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klenod
|
|
4
|
+
module Build
|
|
5
|
+
SourceLocation =
|
|
6
|
+
Data.define(:path, :line, :column) do
|
|
7
|
+
def to_s
|
|
8
|
+
[path, line, column].compact.join(":")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
Dependency =
|
|
13
|
+
Data.define(:id, :specifier, :importer_id, :kind, :loc, :metadata, :eager) do
|
|
14
|
+
def self.create(specifier:, importer_id:, kind:, loc: nil, metadata: {})
|
|
15
|
+
new(nil, specifier, importer_id, kind, loc, metadata.freeze, true)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
ResolvedDependency = Data.define(:dependency, :module_id, :metadata)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Klenod
|
|
4
|
+
class Error < StandardError; end unless const_defined?(:Error, false)
|
|
5
|
+
|
|
6
|
+
module Build
|
|
7
|
+
class Error < Klenod::Error; end
|
|
8
|
+
|
|
9
|
+
class ResolveError < Error
|
|
10
|
+
REASONS = [:incorrect_case, :not_found].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :unresolved_path, :dependency, :importer_id, :reason, :requested_specifier, :suggestions
|
|
13
|
+
|
|
14
|
+
def initialize(message = nil, unresolved_path: nil, dependency: nil, importer_id: nil, reason: nil, requested_specifier: nil, suggestions: [])
|
|
15
|
+
@unresolved_path = unresolved_path
|
|
16
|
+
@dependency = dependency
|
|
17
|
+
@importer_id = importer_id
|
|
18
|
+
@reason = reason
|
|
19
|
+
@requested_specifier = requested_specifier
|
|
20
|
+
@suggestions = suggestions.freeze
|
|
21
|
+
|
|
22
|
+
super(message || resolution_message)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolution_failure?
|
|
26
|
+
REASONS.include?(reason) && requested_specifier
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def title
|
|
30
|
+
case reason
|
|
31
|
+
when :incorrect_case then "Incorrect import path casing"
|
|
32
|
+
when :not_found then "Module not found"
|
|
33
|
+
else self.class.name
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def source_location
|
|
38
|
+
dependency&.loc
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def imported_by
|
|
42
|
+
if source_location&.line
|
|
43
|
+
"#{display_importer(source_location.path)}:#{source_location.line}"
|
|
44
|
+
else
|
|
45
|
+
display_importer(dependency&.importer_id || importer_id)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def with_resolution_context(dependency:, importer_id:)
|
|
50
|
+
unless resolution_failure?
|
|
51
|
+
return self if self.dependency
|
|
52
|
+
|
|
53
|
+
return self.class.new(
|
|
54
|
+
contextual_message(dependency, importer_id),
|
|
55
|
+
unresolved_path: unresolved_path,
|
|
56
|
+
dependency: dependency,
|
|
57
|
+
importer_id: importer_id
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
replacements = suggestions.map { replacement_specifier(it, dependency) }
|
|
62
|
+
self.class.new(
|
|
63
|
+
nil,
|
|
64
|
+
unresolved_path: unresolved_path,
|
|
65
|
+
dependency: dependency,
|
|
66
|
+
importer_id: importer_id,
|
|
67
|
+
reason: reason,
|
|
68
|
+
requested_specifier: dependency.specifier.to_s,
|
|
69
|
+
suggestions: replacements
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def with_path_prefix(prefix)
|
|
74
|
+
return self unless resolution_failure?
|
|
75
|
+
|
|
76
|
+
self.class.new(
|
|
77
|
+
nil,
|
|
78
|
+
unresolved_path: unresolved_path,
|
|
79
|
+
dependency: dependency,
|
|
80
|
+
importer_id: importer_id,
|
|
81
|
+
reason: reason,
|
|
82
|
+
requested_specifier: prefixed_path(prefix, requested_specifier),
|
|
83
|
+
suggestions: suggestions.map { prefixed_path(prefix, it) }
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def resolution_message
|
|
90
|
+
requested = requested_specifier.inspect
|
|
91
|
+
case reason
|
|
92
|
+
when :incorrect_case
|
|
93
|
+
suggestion = suggestions.first
|
|
94
|
+
suggestion ? "Incorrect case for #{requested}. Use #{suggestion.inspect}." : "Incorrect case for #{requested}."
|
|
95
|
+
when :not_found
|
|
96
|
+
message = "Could not resolve #{requested}"
|
|
97
|
+
return message if suggestions.empty?
|
|
98
|
+
|
|
99
|
+
if suggestions.one?
|
|
100
|
+
"#{message}. Did you mean #{suggestions.first.inspect}?"
|
|
101
|
+
else
|
|
102
|
+
"#{message}. Did you mean #{suggestions.map(&:inspect).join(", ")}?"
|
|
103
|
+
end
|
|
104
|
+
else
|
|
105
|
+
"Could not resolve #{requested}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def display_importer(value)
|
|
110
|
+
value&.to_s&.delete_prefix("app:/")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def contextual_message(dependency, importer_id)
|
|
114
|
+
details = [
|
|
115
|
+
"while resolving #{dependency.specifier.inspect}",
|
|
116
|
+
"for #{importer_id}",
|
|
117
|
+
"from #{dependency.importer_id || "unknown importer"}"
|
|
118
|
+
]
|
|
119
|
+
details << "kind: #{dependency.kind}" if dependency.kind
|
|
120
|
+
details << "at #{dependency.loc}" if dependency.loc
|
|
121
|
+
|
|
122
|
+
"#{message} (#{details.join(", ")})"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def replacement_specifier(suggestion, dependency)
|
|
126
|
+
canonical = canonical_parts(suggestion)
|
|
127
|
+
return suggestion unless canonical
|
|
128
|
+
|
|
129
|
+
requested_path, query = dependency.specifier.to_s.split("?", 2)
|
|
130
|
+
replacement =
|
|
131
|
+
if requested_path.match?(/\A[A-Za-z][A-Za-z0-9+.-]*:/)
|
|
132
|
+
suggestion
|
|
133
|
+
elsif requested_path.start_with?("/")
|
|
134
|
+
"/#{canonical.fetch(:path)}"
|
|
135
|
+
elsif (importer = canonical_parts(dependency.importer_id.to_s)) && importer.fetch(:root) == canonical.fetch(:root)
|
|
136
|
+
relative = Pathname.new(canonical.fetch(:path)).relative_path_from(Pathname.new(File.dirname(importer.fetch(:path)))).to_s
|
|
137
|
+
(requested_path.start_with?("./") && !relative.start_with?(".")) ? "./#{relative}" : relative
|
|
138
|
+
else
|
|
139
|
+
canonical.fetch(:path)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
query ? "#{replacement}?#{query}" : replacement
|
|
143
|
+
rescue ArgumentError
|
|
144
|
+
suggestion
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def canonical_parts(value)
|
|
148
|
+
case value.to_s
|
|
149
|
+
when /\A(?<scheme>[A-Za-z][A-Za-z0-9+.-]*):\/\/(?<host>[^\/]+)\/(?<path>.*)\z/
|
|
150
|
+
{root: "#{$~[:scheme]}://#{$~[:host]}/", path: $~[:path]}
|
|
151
|
+
when /\A(?<scheme>[A-Za-z][A-Za-z0-9+.-]*):\/(?<path>.*)\z/
|
|
152
|
+
{root: "#{$~[:scheme]}:/", path: $~[:path]}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def prefixed_path(prefix, value)
|
|
157
|
+
return value if value.to_s.match?(/\A[A-Za-z][A-Za-z0-9+.-]*:/)
|
|
158
|
+
|
|
159
|
+
"#{prefix}#{value}"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
class DynamicImportError < Error; end
|
|
164
|
+
class UnsupportedFileError < Error; end
|
|
165
|
+
|
|
166
|
+
class ImportCycleError < Error
|
|
167
|
+
attr_reader :cycle
|
|
168
|
+
|
|
169
|
+
def initialize(cycle)
|
|
170
|
+
@cycle = cycle.freeze
|
|
171
|
+
super("Import cycle detected: #{cycle.join(" -> ")}")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Klenod
|
|
8
|
+
module Build
|
|
9
|
+
class FilesystemResolver
|
|
10
|
+
MAX_CORRECTIONS = 3
|
|
11
|
+
|
|
12
|
+
PathMatch = Data.define(:path, :case_mismatch)
|
|
13
|
+
|
|
14
|
+
def initialize(root:, extensions: [], path_prefix: nil)
|
|
15
|
+
@root = Pathname.new(root).expand_path
|
|
16
|
+
@extensions = extensions.freeze
|
|
17
|
+
@path_prefix = path_prefix.to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolve(relative_path)
|
|
21
|
+
relative_path = normalize_relative_path(relative_path)
|
|
22
|
+
case_corrections = []
|
|
23
|
+
|
|
24
|
+
candidate_paths(relative_path).each do |candidate_path|
|
|
25
|
+
path_matches(candidate_path).each do |match|
|
|
26
|
+
return match.path unless match.case_mismatch
|
|
27
|
+
|
|
28
|
+
case_corrections << relative_path_for(match.path)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
unless case_corrections.empty?
|
|
33
|
+
raise ResolveError.new(
|
|
34
|
+
nil,
|
|
35
|
+
unresolved_path: display_path(relative_path),
|
|
36
|
+
reason: :incorrect_case,
|
|
37
|
+
requested_specifier: display_path(relative_path),
|
|
38
|
+
suggestions: case_corrections.uniq.map { display_path(it) }.first(MAX_CORRECTIONS)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
corrections = spelling_corrections(relative_path)
|
|
43
|
+
raise ResolveError.new(
|
|
44
|
+
nil,
|
|
45
|
+
unresolved_path: display_path(relative_path),
|
|
46
|
+
reason: :not_found,
|
|
47
|
+
requested_specifier: display_path(relative_path),
|
|
48
|
+
suggestions: corrections.map { display_path(it) }
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :root, :extensions
|
|
55
|
+
|
|
56
|
+
def normalize_relative_path(path)
|
|
57
|
+
expanded = root.join(path.to_s).expand_path
|
|
58
|
+
root_path = root.to_s
|
|
59
|
+
expanded_path = expanded.to_s
|
|
60
|
+
|
|
61
|
+
unless expanded_path == root_path || expanded_path.start_with?("#{root_path}#{File::SEPARATOR}")
|
|
62
|
+
raise ResolveError.new(
|
|
63
|
+
"Resolved path escapes root: #{path}",
|
|
64
|
+
unresolved_path: path.to_s
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
normalize_path(expanded.relative_path_from(root).to_s)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def candidate_paths(relative_path)
|
|
72
|
+
[relative_path, *extensions.map { |extension| "#{relative_path}#{extension}" }]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def path_matches(relative_path)
|
|
76
|
+
states = [[root, false]]
|
|
77
|
+
|
|
78
|
+
relative_path.split("/").each do |part|
|
|
79
|
+
states =
|
|
80
|
+
states.flat_map do |directory, case_mismatch|
|
|
81
|
+
entries = directory_entries(directory)
|
|
82
|
+
matches = entries.include?(part) ? [part] : entries.select { it.casecmp?(part) }.sort
|
|
83
|
+
|
|
84
|
+
matches.map do |entry|
|
|
85
|
+
[directory.join(entry), case_mismatch || entry != part]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
return [] if states.empty?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
states.filter_map do |path, case_mismatch|
|
|
92
|
+
PathMatch.new(path, case_mismatch) if path.file?
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def directory_entries(directory)
|
|
97
|
+
return [] unless directory.directory?
|
|
98
|
+
|
|
99
|
+
directory.children.map { it.basename.to_s }
|
|
100
|
+
rescue SystemCallError
|
|
101
|
+
[]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def spelling_corrections(relative_path)
|
|
105
|
+
aliases = spelling_aliases
|
|
106
|
+
matches =
|
|
107
|
+
DidYouMean::TreeSpellChecker
|
|
108
|
+
.new(dictionary: aliases.keys.sort, separator: "/")
|
|
109
|
+
.correct(relative_path)
|
|
110
|
+
|
|
111
|
+
matches.flat_map { aliases.fetch(it) }.uniq.first(MAX_CORRECTIONS)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def spelling_aliases
|
|
115
|
+
source_files.each_with_object({}) do |path, aliases|
|
|
116
|
+
aliases[path] = [path]
|
|
117
|
+
extension = extensions.find { path.end_with?(it) }
|
|
118
|
+
next unless extension
|
|
119
|
+
|
|
120
|
+
alias_path = path.delete_suffix(extension)
|
|
121
|
+
aliases[alias_path] ||= []
|
|
122
|
+
aliases[alias_path] << path
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def source_files
|
|
127
|
+
Dir
|
|
128
|
+
.glob("**/*", File::FNM_DOTMATCH, base: root.to_s)
|
|
129
|
+
.select { |path| root.join(path).file? }
|
|
130
|
+
.map { normalize_path(it) }
|
|
131
|
+
.sort
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def display_path(relative_path)
|
|
135
|
+
"#{@path_prefix}#{relative_path}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def relative_path_for(path)
|
|
139
|
+
normalize_path(path.relative_path_from(root).to_s)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def normalize_path(path)
|
|
143
|
+
path.tr("\\", "/")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|