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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +29 -0
  3. data/lib/klenod/build/asset.rb +235 -0
  4. data/lib/klenod/build/asset_compression.rb +58 -0
  5. data/lib/klenod/build/asset_generation_queue.rb +50 -0
  6. data/lib/klenod/build/cli/application.rb +108 -0
  7. data/lib/klenod/build/cli.rb +3 -0
  8. data/lib/klenod/build/config.rb +142 -0
  9. data/lib/klenod/build/context.rb +370 -0
  10. data/lib/klenod/build/dependency.rb +21 -0
  11. data/lib/klenod/build/errors.rb +175 -0
  12. data/lib/klenod/build/filesystem_resolver.rb +147 -0
  13. data/lib/klenod/build/graph/invalidator.rb +246 -0
  14. data/lib/klenod/build/graph.rb +864 -0
  15. data/lib/klenod/build/graphviz.rb +282 -0
  16. data/lib/klenod/build/hashing.rb +21 -0
  17. data/lib/klenod/build/invalidation_result.rb +55 -0
  18. data/lib/klenod/build/load_result.rb +7 -0
  19. data/lib/klenod/build/loaded_module.rb +38 -0
  20. data/lib/klenod/build/module_id.rb +100 -0
  21. data/lib/klenod/build/module_record.rb +22 -0
  22. data/lib/klenod/build/plugin.rb +35 -0
  23. data/lib/klenod/build/plugins/class_names_runtime.rb +125 -0
  24. data/lib/klenod/build/plugins/component_defaults.rb +12 -0
  25. data/lib/klenod/build/plugins/data_plugin.rb +117 -0
  26. data/lib/klenod/build/plugins/gem_import_plugin.rb +125 -0
  27. data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.json +17687 -0
  28. data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.rb +105 -0
  29. data/lib/klenod/build/plugins/google_fonts_plugin/font_metrics.txt +31 -0
  30. data/lib/klenod/build/plugins/google_fonts_plugin.rb +387 -0
  31. data/lib/klenod/build/plugins/haml_plugin/companions.rb +40 -0
  32. data/lib/klenod/build/plugins/haml_plugin/errors.rb +114 -0
  33. data/lib/klenod/build/plugins/haml_plugin/helper_source.rb +123 -0
  34. data/lib/klenod/build/plugins/haml_plugin/parser.rb +85 -0
  35. data/lib/klenod/build/plugins/haml_plugin/transformer/ruby_builder.rb +1039 -0
  36. data/lib/klenod/build/plugins/haml_plugin/transformer.rb +766 -0
  37. data/lib/klenod/build/plugins/haml_plugin.rb +369 -0
  38. data/lib/klenod/build/plugins/image_plugin.rb +401 -0
  39. data/lib/klenod/build/plugins/intl_plugin.rb +34 -0
  40. data/lib/klenod/build/plugins/markdown_compiler.rb +180 -0
  41. data/lib/klenod/build/plugins/markdown_plugin.rb +161 -0
  42. data/lib/klenod/build/plugins/router_plugin.rb +942 -0
  43. data/lib/klenod/build/plugins/ruby_plugin.rb +34 -0
  44. data/lib/klenod/build/plugins/svg_plugin.rb +197 -0
  45. data/lib/klenod/build/plugins.rb +22 -0
  46. data/lib/klenod/build/profiler.rb +79 -0
  47. data/lib/klenod/build/resolution_error_formatter.rb +42 -0
  48. data/lib/klenod/build/resolver.rb +95 -0
  49. data/lib/klenod/build/ruby_import_rewriter.rb +436 -0
  50. data/lib/klenod/build/source_map/editor.rb +110 -0
  51. data/lib/klenod/build/source_map/map.rb +168 -0
  52. data/lib/klenod/build/source_map/vlq.rb +68 -0
  53. data/lib/klenod/build/source_map.rb +12 -0
  54. data/lib/klenod/build/transform_result.rb +12 -0
  55. data/lib/klenod/build/version.rb +7 -0
  56. data/lib/klenod/build/watched_pattern.rb +11 -0
  57. data/lib/klenod/build/watcher.rb +141 -0
  58. data/lib/klenod/build.rb +15 -0
  59. 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