klenod-build 0.0.13 → 0.0.15

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b99708644a67ef46ca03814e2ea175347cfdea450ac8ac33d53e8a7b0c9a9f4
4
- data.tar.gz: f7b5935d6d9ddcbd442dfdb95873c5e23fe3ed5c28ab891c4c5996b26ce8b9f0
3
+ metadata.gz: abf589452fadde26fe16d0bbc326e0161e8ed8a719ea516e12e286ef8e1609aa
4
+ data.tar.gz: 9f54e971b5dc88cd07348be0d96e9885928db10c64b4228bfb64e350b202f669
5
5
  SHA512:
6
- metadata.gz: 1cf296164f848ae53f748d9dff80dffcccaae0abcbad3eee0b0071366042ae5ad3c66069526baadb02ec34154b90453c0cf464998613fd286293f7b896b88108
7
- data.tar.gz: 726527a490d7dc39cd1eb13515f80351dcdbfcf1735519131a52c74ac046b041580dcf131090e3484a17d98c385dc750ab721a3688b5f54ae8c98f9b10610bd8
6
+ metadata.gz: af84591cca45a914ffbf087f308eea54d264cb14a0e03d6e60732a806d5e4cf44b00d291679b1f3dfe1f6d39f58bda043ff6b1de625b9d904667d2db5f8a6c07
7
+ data.tar.gz: 8f5fbdc8411f73fee7b7f6b86f803d7e04e588db97dc3d4d5019b7bf08cc63a1d73b05d82a97e74135fc2370be072b00524b7df3ac301c0ed2f4eb025c4352d7
@@ -113,7 +113,8 @@ module Klenod
113
113
  asset_generation_concurrency: AssetGenerationQueue::DEFAULT_CONCURRENCY,
114
114
  asset_download_concurrency: AssetGenerationQueue::DEFAULT_DOWNLOAD_CONCURRENCY,
115
115
  profiler: nil,
116
- namespace: Module.new
116
+ namespace: Module.new,
117
+ analysis: false
117
118
  )
118
119
  @source_dir = source_dir
119
120
  plugins = plugins.to_a if plugins.equal?(DEFAULT_PLUGINS)
@@ -131,12 +132,17 @@ module Klenod
131
132
  asset_generation_concurrency: asset_generation_concurrency,
132
133
  asset_download_concurrency: asset_download_concurrency,
133
134
  profiler: profiler,
134
- namespace: namespace
135
+ namespace: namespace,
136
+ analysis: analysis
135
137
  )
136
138
  end
137
139
 
138
140
  attr_reader :graph, :mode, :base, :asset_origin
139
141
 
142
+ def analysis?
143
+ @graph.analysis?
144
+ end
145
+
140
146
  def evaluate(specifier)
141
147
  @graph.load(specifier)
142
148
  end
@@ -15,6 +15,14 @@ module Klenod
15
15
  @root = Pathname.new(root).expand_path
16
16
  @extensions = extensions.freeze
17
17
  @path_prefix = path_prefix.to_s
18
+ @spelling_index = nil
19
+ end
20
+
21
+ # Forget the cached file listing behind did-you-mean suggestions. The
22
+ # owning resolver clears it whenever files change, so a suggestion never
23
+ # walks the source tree more than once between file events.
24
+ def clear_cache
25
+ @spelling_index = nil
18
26
  end
19
27
 
20
28
  def resolve(relative_path)
@@ -101,14 +109,21 @@ module Klenod
101
109
  []
102
110
  end
103
111
 
112
+ SpellingIndex = Data.define(:aliases, :checker)
113
+
104
114
  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)
115
+ index = spelling_index
116
+ matches = index.checker.correct(relative_path)
110
117
 
111
- matches.flat_map { aliases.fetch(it) }.uniq.first(MAX_CORRECTIONS)
118
+ matches.flat_map { index.aliases.fetch(it) }.uniq.first(MAX_CORRECTIONS)
119
+ end
120
+
121
+ def spelling_index
122
+ @spelling_index ||=
123
+ begin
124
+ aliases = spelling_aliases
125
+ SpellingIndex.new(aliases, DidYouMean::TreeSpellChecker.new(dictionary: aliases.keys.sort, separator: "/"))
126
+ end
112
127
  end
113
128
 
114
129
  def spelling_aliases
@@ -24,10 +24,11 @@ module Klenod
24
24
  errors = []
25
25
 
26
26
  removed_module_ids.each do |module_id|
27
- records.delete(module_id)
27
+ graph.remove_record(module_id)
28
28
  mods.delete(module_id)
29
29
  end
30
30
 
31
+ failed_reload_ids = []
31
32
  reloaded_module_ids =
32
33
  reload_module_ids.filter_map do |module_id|
33
34
  if evaluated_module_ids.include?(module_id)
@@ -36,12 +37,15 @@ module Klenod
36
37
  graph.collect_module(module_id, force: true)
37
38
  end
38
39
  module_id
39
- rescue => e
40
+ rescue StandardError, ScriptError => e
41
+ # ScriptError too: a syntax error in a module is not a
42
+ # StandardError, and letting it escape here kills the watcher
43
+ # thread rather than reporting the module that failed.
40
44
  mark_module_failed(module_id, e)
41
- errors << [module_id, e]
45
+ failed_reload_ids << module_id
46
+ record_error(errors, module_id, e)
42
47
  nil
43
48
  end
44
- failed_reload_ids = errors.map(&:first) & reload_module_ids
45
49
  blocked_dependent_ids = dependent_closure(failed_reload_ids)
46
50
  blocked_dependent_ids.each { |module_id| mods.delete(module_id) }
47
51
 
@@ -58,8 +62,8 @@ module Klenod
58
62
  graph.collect_module(module_id, force: true)
59
63
  nil
60
64
  end
61
- rescue => e
62
- errors << [module_id, e]
65
+ rescue StandardError, ScriptError => e
66
+ record_error(errors, module_id, e)
63
67
  nil
64
68
  end
65
69
  asset_updates = diff_assets(previous_assets, graph.assets)
@@ -80,6 +84,16 @@ module Klenod
80
84
 
81
85
  private
82
86
 
87
+ # One failure, reported once. A module whose dependency already failed
88
+ # in this invalidation re-raises that same error when it reloads, so a
89
+ # broken companion would otherwise be reported once for itself and
90
+ # again for every module that imports it.
91
+ def record_error(errors, module_id, error)
92
+ return if errors.any? { |(_id, recorded)| recorded.equal?(error) }
93
+
94
+ errors << [module_id, error]
95
+ end
96
+
83
97
  attr_reader :graph, :resolver, :source_loader
84
98
 
85
99
  def records
@@ -175,9 +189,7 @@ module Klenod
175
189
  end
176
190
 
177
191
  def direct_dependents(module_id)
178
- records.filter_map do |candidate_id, record|
179
- candidate_id if record.resolved_dependencies.any? { |dependency| dependency.module_id == module_id }
180
- end
192
+ graph.dependents(module_id)
181
193
  end
182
194
 
183
195
  def mark_module_failed(module_id, error)
@@ -192,7 +204,8 @@ module Klenod
192
204
  source_hash = loaded_source.source_hash || Hashing.hexdigest(source)
193
205
  version = cached ? cached.version + 1 : 0
194
206
 
195
- records[module_id] =
207
+ graph.store_record(
208
+ module_id,
196
209
  ModuleRecord.new(
197
210
  module_id,
198
211
  source_hash,
@@ -208,6 +221,7 @@ module Klenod
208
221
  version,
209
222
  :failed
210
223
  )
224
+ )
211
225
  mods[module_id] = FailedModule.new(error)
212
226
  end
213
227
 
@@ -7,6 +7,7 @@ require "klenod/runtime/mod"
7
7
  require "klenod/runtime/bundle"
8
8
  require_relative "asset_generation_queue"
9
9
  require_relative "errors"
10
+ require_relative "source_error"
10
11
  require_relative "graph/invalidator"
11
12
  require_relative "hashing"
12
13
  require_relative "invalidation_result"
@@ -24,9 +25,12 @@ module Klenod
24
25
  include TSort
25
26
 
26
27
  AsyncResult = Data.define(:value, :error) do
28
+ # ScriptError too, so a syntax error in a module comes back to the
29
+ # parent graph path instead of surfacing as an unhandled Async::Task
30
+ # exception.
27
31
  def self.capture
28
32
  new(yield, nil)
29
- rescue => e
33
+ rescue StandardError, ScriptError => e
30
34
  new(nil, e)
31
35
  end
32
36
 
@@ -67,9 +71,11 @@ module Klenod
67
71
  asset_generation_concurrency: AssetGenerationQueue::DEFAULT_CONCURRENCY,
68
72
  asset_download_concurrency: AssetGenerationQueue::DEFAULT_DOWNLOAD_CONCURRENCY,
69
73
  profiler: nil,
70
- namespace: Module.new
74
+ namespace: Module.new,
75
+ analysis: false
71
76
  )
72
77
  @profiler = profiler || Profiler.new
78
+ @analysis = analysis
73
79
  @resolver = Resolver.new(source_dir: source_dir, profiler: @profiler)
74
80
  @plugins = plugins
75
81
  @mode = mode
@@ -86,6 +92,16 @@ module Klenod
86
92
  @virtual_metadata = {}
87
93
  @virtual_owners = {}
88
94
  @loading_tasks = {}
95
+ @source_overrides = {}
96
+ @dependents = {}
97
+ end
98
+
99
+ # Analysis mode collects the graph for tooling such as the language
100
+ # server. Plugins must keep the record shape but skip work whose result
101
+ # is never used there: network fetches, hashing whole files, compiling
102
+ # JavaScript, and building asset bytes.
103
+ def analysis?
104
+ @analysis
89
105
  end
90
106
 
91
107
  def load(specifier)
@@ -207,10 +223,89 @@ module Klenod
207
223
  end
208
224
  end
209
225
 
226
+ # Editor buffers take precedence over files on disk for the given
227
+ # module until cleared. The next collection re-reads and, when the text
228
+ # changed, re-transforms the module. A caller that already transformed
229
+ # the same text can pass that `transform` so collection reuses it.
230
+ def override_source(module_id, source, transform: nil)
231
+ @source_overrides[module_id] = LoadResult.new(source, nil, transform)
232
+ end
233
+
234
+ def clear_source_override(module_id)
235
+ @source_overrides.delete(module_id)
236
+ end
237
+
238
+ def source_override?(module_id)
239
+ @source_overrides.key?(module_id)
240
+ end
241
+
242
+ # Module ids whose collected record imports the given module, eagerly
243
+ # or lazily.
244
+ def dependents(module_id)
245
+ @dependents.fetch(module_id) { [] }.to_a
246
+ end
247
+
248
+ def store_record(module_id, record)
249
+ previous = @records[module_id]
250
+ previous&.resolved_dependencies&.each { |dependency| @dependents[dependency.module_id]&.delete(module_id) }
251
+ record.resolved_dependencies.each { |dependency| (@dependents[dependency.module_id] ||= Set.new) << module_id }
252
+ @records[module_id] = record
253
+ end
254
+
255
+ def remove_record(module_id)
256
+ record = @records.delete(module_id)
257
+ record&.resolved_dependencies&.each { |dependency| @dependents[dependency.module_id]&.delete(module_id) }
258
+ record
259
+ end
260
+
261
+ # Collect everything reachable from a collected module through eager
262
+ # and lazy dependencies. Each module the walk collects itself is
263
+ # yielded with `nil`; a module that fails to collect is yielded with
264
+ # its error and the walk continues past it.
265
+ def collect_reachable(module_id)
266
+ queue = @records.fetch(module_id).resolved_dependencies.dup
267
+ seen = Set.new([module_id])
268
+ index = 0
269
+
270
+ while index < queue.length
271
+ dependency_id = queue[index].module_id
272
+ index += 1
273
+ next unless seen.add?(dependency_id)
274
+
275
+ record = @records[dependency_id]
276
+ unless record
277
+ begin
278
+ record = collect_module(dependency_id)
279
+ rescue StandardError, ScriptError => error
280
+ yield dependency_id, error if block_given?
281
+ next
282
+ end
283
+ yield dependency_id, nil if block_given?
284
+ end
285
+ queue.concat(record.resolved_dependencies)
286
+ end
287
+ end
288
+
289
+ # Transform source for a module without collecting it. Editor tooling
290
+ # such as the language server uses this to analyze unsaved text with the
291
+ # same plugins as a build, without adding a record or evaluating anything.
292
+ def transform_source(module_id, source)
293
+ transform = transform_module_source(module_id, source)
294
+ assert_supported_transform!(module_id, source, transform)
295
+ transform
296
+ end
297
+
210
298
  def absolute_path(module_id)
211
299
  @resolver.absolute_path(module_id)
212
300
  end
213
301
 
302
+ # Forget resolved paths so files created, renamed, or deleted outside
303
+ # a build invalidation are looked up again. Invalidation does this
304
+ # itself; tooling that only transforms must ask for it.
305
+ def clear_resolver_cache
306
+ @resolver.clear_cache
307
+ end
308
+
214
309
  def source_dir
215
310
  @resolver.source_dir
216
311
  end
@@ -231,7 +326,7 @@ module Klenod
231
326
  @virtual_sources.delete(module_id)
232
327
  @virtual_metadata.delete(module_id)
233
328
  @virtual_owners.delete(module_id)
234
- @records.delete(module_id)
329
+ remove_record(module_id)
235
330
  @mods.delete(module_id)
236
331
  end
237
332
  end
@@ -281,11 +376,12 @@ module Klenod
281
376
  dependency_records = load_eager_dependency_records(resolved_dependencies)
282
377
  transform = finalize_transform_result(module_id, transform, resolved_dependencies, dependency_records)
283
378
  assert_supported_transform!(module_id, source, transform)
379
+ assert_generated_ruby_parses!(module_id, transform)
284
380
  transformed_hash = Hashing.hexdigest(transform.code)
285
- mod = instantiate_module(module_id, transform, resolved_dependencies, dependency_records, cached)
381
+ mod = instantiate_module(module_id, transform, resolved_dependencies, dependency_records, cached, source)
286
382
  record = build_module_record(module_id, source, source_hash, transformed_hash, transform, resolved_dependencies, mod)
287
383
 
288
- @records[module_id] = record
384
+ store_record(module_id, record)
289
385
  @mods[module_id] = mod
290
386
  record
291
387
  end
@@ -336,10 +432,11 @@ module Klenod
336
432
  dependency_records = collect_eager_dependency_records(resolved_dependencies)
337
433
  transform = finalize_transform_result(module_id, transform, resolved_dependencies, dependency_records)
338
434
  assert_supported_transform!(module_id, source, transform)
435
+ assert_generated_ruby_parses!(module_id, transform)
339
436
  transformed_hash = Hashing.hexdigest(transform.code)
340
437
  record = build_module_record(module_id, source, source_hash, transformed_hash, transform, resolved_dependencies, cached)
341
438
 
342
- @records[module_id] = record
439
+ store_record(module_id, record)
343
440
  @mods.delete(module_id)
344
441
  @profiler.progress(:collect_module, module_id: record.id.to_s, total_records: @records.length)
345
442
  record
@@ -347,22 +444,31 @@ module Klenod
347
444
  end
348
445
 
349
446
  def evaluate_module(module_id)
350
- return @mods.fetch(module_id) if @mods.key?(module_id)
447
+ if @mods.key?(module_id)
448
+ mod = @mods.fetch(module_id)
449
+ # A failed reload is remembered so later demand raises the stored
450
+ # error rather than serving stale exports.
451
+ raise mod.error if mod.is_a?(FailedModule)
452
+
453
+ return mod
454
+ end
351
455
 
352
456
  record = @records.fetch(module_id) { collect_module(module_id) }
353
457
  raise_failed_module!(record)
354
458
  evaluate_eager_dependencies(record.resolved_dependencies)
355
459
  dependency_records = dependency_records_for(eager_dependencies(record.resolved_dependencies))
356
460
  mod =
357
- Runtime::Mod.new(
358
- module_id.to_s,
359
- record.transformed_source,
360
- imports: imports_for(record.resolved_dependencies, dependency_records),
361
- source_map: record.source_map,
362
- version: record.version,
363
- eval_path: eval_path_for(module_id),
364
- namespace: namespace
365
- )
461
+ evaluating(module_id, record.transformed_source, record.source) do
462
+ Runtime::Mod.new(
463
+ module_id.to_s,
464
+ record.transformed_source,
465
+ imports: imports_for(record.resolved_dependencies, dependency_records),
466
+ source_map: record.source_map,
467
+ version: record.version,
468
+ eval_path: eval_path_for(module_id),
469
+ namespace: namespace
470
+ )
471
+ end
366
472
 
367
473
  @mods[module_id] = mod
368
474
  end
@@ -532,20 +638,50 @@ module Klenod
532
638
  raise UnsupportedFileError, "No plugin transformed #{module_id.path.inspect}. Add a plugin for #{module_id.extname.inspect} files."
533
639
  end
534
640
 
641
+ # A plugin that generates Ruby can generate Ruby that does not parse.
642
+ # Without this the failure waits until the module is evaluated, and a
643
+ # production build never evaluates application modules, so the bundle
644
+ # would ship broken. Ruby modules are already checked by RubyPlugin
645
+ # against their original source, which reports better locations.
646
+ def assert_generated_ruby_parses!(module_id, transform)
647
+ return if ruby_module_extension?(module_id.extname)
648
+ return if Prism.parse_success?(transform.code)
649
+
650
+ raise GeneratedRubyError.new(Prism.parse(transform.code), source: transform.code, module_id: module_id)
651
+ end
652
+
535
653
  def ruby_module_extension?(extname)
536
654
  extname.empty? || extname == ".rb"
537
655
  end
538
656
 
539
- def instantiate_module(module_id, transform, resolved_dependencies, dependency_records, cached)
540
- Runtime::Mod.new(
541
- module_id.to_s,
542
- transform.code,
543
- imports: imports_for(resolved_dependencies, dependency_records),
544
- source_map: transform.source_map,
545
- version: cached ? cached.version + 1 : 0,
546
- eval_path: eval_path_for(module_id),
547
- namespace: namespace
548
- )
657
+ def instantiate_module(module_id, transform, resolved_dependencies, dependency_records, cached, source)
658
+ evaluating(module_id, transform.code, source) do
659
+ Runtime::Mod.new(
660
+ module_id.to_s,
661
+ transform.code,
662
+ imports: imports_for(resolved_dependencies, dependency_records),
663
+ source_map: transform.source_map,
664
+ version: cached ? cached.version + 1 : 0,
665
+ eval_path: eval_path_for(module_id),
666
+ namespace: namespace
667
+ )
668
+ end
669
+ end
670
+
671
+ # Evaluating a module runs its Ruby, and a syntax error there is a
672
+ # ScriptError that escapes every `rescue => e` in the build. Only a syntax
673
+ # error is wrapped: a LoadError from the module's own `require` is also a
674
+ # ScriptError, and reporting that as a syntax error would be wrong.
675
+ #
676
+ # The reported line refers to the evaluated source, so the excerpt has to
677
+ # come from that -- except for a Ruby module, whose transform only
678
+ # rewrites import calls in place and leaves every line where it was. There
679
+ # the original source reads better.
680
+ def evaluating(module_id, evaluated_source, original_source)
681
+ yield
682
+ rescue ::SyntaxError => error
683
+ excerpt_source = ruby_module_extension?(module_id.extname) ? original_source : evaluated_source
684
+ raise GeneratedRubyError.new(error, source: excerpt_source || evaluated_source, module_id: module_id)
549
685
  end
550
686
 
551
687
  def eval_path_for(module_id)
@@ -670,7 +806,7 @@ module Klenod
670
806
 
671
807
  tasks.each_with_object({}) do |(dependency_id, child_task), records|
672
808
  records[dependency_id] = AsyncResult.unwrap(child_task.wait)
673
- rescue => e
809
+ rescue StandardError, ScriptError => e
674
810
  first_error ||= e
675
811
  end.tap do
676
812
  raise first_error if first_error
@@ -806,6 +942,11 @@ module Klenod
806
942
  end
807
943
 
808
944
  def load_source(module_id)
945
+ if (override = @source_overrides[module_id])
946
+ @profiler.count(:source_override_hit)
947
+ return override
948
+ end
949
+
809
950
  @plugins.each do |plugin|
810
951
  @profiler.count(:plugin_load_check)
811
952
  loaded =
@@ -13,6 +13,13 @@ module Klenod
13
13
  Digest::SHA256.file(path.to_s).hexdigest
14
14
  end
15
15
 
16
+ # A cheap change-detection key for a file when its bytes are not
17
+ # needed, e.g. graph analysis that skips asset work.
18
+ def self.file_key(path)
19
+ stat = File.stat(path.to_s)
20
+ short("#{path}:#{stat.mtime.to_f}:#{stat.size}")
21
+ end
22
+
16
23
  def self.short(value, length: 16)
17
24
  hexdigest(value)[0, length]
18
25
  end
@@ -38,6 +38,23 @@ module Klenod
38
38
  :asset_updates,
39
39
  :errors
40
40
  ) do
41
+ # An invalidation that blew up before it could inspect any module, so
42
+ # subscribers still get told about the failure instead of the build
43
+ # going quiet.
44
+ def self.failed(error, module_id: nil)
45
+ new(
46
+ changed_module_ids: [].freeze,
47
+ removed_module_ids: [].freeze,
48
+ reloaded_module_ids: [].freeze,
49
+ reevaluated_module_ids: [].freeze,
50
+ added_assets: [].freeze,
51
+ changed_assets: [].freeze,
52
+ removed_assets: [].freeze,
53
+ asset_updates: [].freeze,
54
+ errors: [[module_id, error]].freeze
55
+ )
56
+ end
57
+
41
58
  def asset_changes
42
59
  AssetChanges.new(added_assets, changed_assets, removed_assets)
43
60
  end
@@ -5,6 +5,7 @@ require "toml-rb"
5
5
  require "yaml"
6
6
 
7
7
  require_relative "../plugin"
8
+ require_relative "../source_error"
8
9
  require_relative "../transform_result"
9
10
 
10
11
  module Klenod
@@ -16,14 +17,24 @@ module Klenod
16
17
  end
17
18
 
18
19
  class Plugin < Klenod::Build::Plugin
20
+ # A format that cannot fail to parse declares no wrapped errors, and
21
+ # `rescue *[]` then catches nothing.
22
+ WRAPPED_ERRORS = [].freeze
23
+
19
24
  def self.extensions(*values)
20
25
  const_set(:EXTENSIONS, values.freeze)
21
26
  end
22
27
 
28
+ # Names the SourceError to raise and the library exceptions it wraps.
29
+ def self.parse_error(error_class, *wrapped)
30
+ const_set(:PARSE_ERROR, error_class)
31
+ const_set(:WRAPPED_ERRORS, wrapped.freeze)
32
+ end
33
+
23
34
  def transform(module_id, code, _context)
24
35
  return super unless self.class::EXTENSIONS.include?(module_id.extname)
25
36
 
26
- data = parse(code)
37
+ data = parse_source(code, module_id)
27
38
  TransformResult.new(module_source(data), [], nil, [], [], {data: data})
28
39
  end
29
40
 
@@ -41,6 +52,15 @@ module Klenod
41
52
 
42
53
  private
43
54
 
55
+ # Each format's library raises its own exception type. Wrapping them
56
+ # here keeps every `parse` hook a one-liner and gives all four formats
57
+ # the same report.
58
+ def parse_source(code, module_id)
59
+ parse(code)
60
+ rescue *self.class::WRAPPED_ERRORS => error
61
+ raise self.class::PARSE_ERROR.new(error, source: code, module_id: module_id)
62
+ end
63
+
44
64
  def module_source(data)
45
65
  <<~RUBY
46
66
  Default = #{data.inspect}
@@ -54,8 +74,32 @@ module Klenod
54
74
  Plugin.new(...)
55
75
  end
56
76
 
77
+ class ParseError < Klenod::Build::SourceError
78
+ # "expected ':' after object key, got: '2' at line 3 column 7"
79
+ LOCATION = /\s+at line \d+ column \d+\z/
80
+
81
+ def kind
82
+ "JSON parse error"
83
+ end
84
+
85
+ private
86
+
87
+ def location(error)
88
+ return nil unless error.line
89
+
90
+ # The location is rendered as the title and the caret, so drop the
91
+ # copy the parser appends to its message.
92
+ Location.new(
93
+ line: error.line,
94
+ column: error.column,
95
+ detail: error.message.sub(LOCATION, "")
96
+ )
97
+ end
98
+ end
99
+
57
100
  class Plugin < DataPlugin::Plugin
58
101
  extensions ".json"
102
+ parse_error ParseError, JSON::ParserError
59
103
 
60
104
  private
61
105
 
@@ -70,8 +114,32 @@ module Klenod
70
114
  Plugin.new(...)
71
115
  end
72
116
 
117
+ class ParseError < Klenod::Build::SourceError
118
+ def kind
119
+ "YAML parse error"
120
+ end
121
+
122
+ private
123
+
124
+ def location(error)
125
+ # Psych::DisallowedClass and friends carry no location.
126
+ return nil unless error.is_a?(Psych::SyntaxError)
127
+
128
+ # One-based. libyaml points at the construct it was parsing when it
129
+ # gave up, which is not always the offending line, so `context` is
130
+ # worth surfacing.
131
+ Location.new(
132
+ line: error.line,
133
+ column: error.column,
134
+ detail: error.problem,
135
+ hints: [error.context&.capitalize].compact
136
+ )
137
+ end
138
+ end
139
+
73
140
  class Plugin < DataPlugin::Plugin
74
141
  extensions ".yaml", ".yml"
142
+ parse_error ParseError, Psych::Exception
75
143
 
76
144
  private
77
145
 
@@ -86,8 +154,34 @@ module Klenod
86
154
  Plugin.new(...)
87
155
  end
88
156
 
157
+ class ParseError < Klenod::Build::SourceError
158
+ # "Failed to parse input on line 2 at offset 9\ninvalid =\n\n ^"
159
+ LOCATION = /\AFailed to parse input on line (?<line>\d+) at offset (?<column>\d+)/
160
+
161
+ def kind
162
+ "TOML parse error"
163
+ end
164
+
165
+ private
166
+
167
+ def location(error)
168
+ found = LOCATION.match(error.message)
169
+ return nil unless found
170
+
171
+ # toml-rb embeds its own caret diagram in the message; we render our
172
+ # own from the line and column instead.
173
+ Location.new(
174
+ line: found[:line].to_i,
175
+ # citrus reports a zero-based offset into the line.
176
+ column: found[:column].to_i + 1,
177
+ detail: "Could not parse TOML"
178
+ )
179
+ end
180
+ end
181
+
89
182
  class Plugin < DataPlugin::Plugin
90
183
  extensions ".toml"
184
+ parse_error ParseError, TomlRB::Error
91
185
 
92
186
  private
93
187
 
@@ -39,11 +39,11 @@ module Klenod
39
39
  raise error.with_resolution_context(dependency: dependency, importer_id: dependency.importer_id)
40
40
  end
41
41
 
42
- def load(module_id, _context)
42
+ def load(module_id, context)
43
43
  return nil unless module_id.scheme == :gem
44
44
 
45
45
  path = path_for(module_id)
46
- LoadResult.new(path.binread, Hashing.file_hexdigest(path), nil)
46
+ LoadResult.new(path.binread, context.analysis? ? Hashing.file_key(path) : Hashing.file_hexdigest(path), nil)
47
47
  end
48
48
 
49
49
  private