klenod-build 0.0.16 → 0.0.18

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: f6e40006fd1c7bfbe3441e5ae89f60b136a4fb6c6ca24da762346c785e61983c
4
- data.tar.gz: 8927ec3112ab13d5ba3e936820f532528fae9f8556967c9bfaba352e9362898a
3
+ metadata.gz: b36ee8205987d239a6301d55da7f4ef92548303139d04b5bbc2246ba6e94e8e3
4
+ data.tar.gz: 8f6db6e13f72e4b290b91d115df9e38e8e907100db677baea5a5fec047bc6ae0
5
5
  SHA512:
6
- metadata.gz: 31dedad665ee4b642d6b8517a9bb9fe2e9bf20a1a93c430a45c29c0ba9db497a40b06553be170965ba3e6a62adec01d0500fcfb3a7e370b6fc59ef6aa5c50ec7
7
- data.tar.gz: 181a6b2156410d3fd6f5b33a5830a03c0b304e7a3eba1920fa4dc75b043cd14aa0ace57d7a275502c79c59267f89136a416b330a286591c66f0c26cb44dc385d
6
+ metadata.gz: d9572bdc8295bfa42c28a88057e6625d1b105cfb8b7c9f51bc7d426ffb0e090c472237f86f549258da64b1095931921e5259a78a41050590b4d67254c92590c5
7
+ data.tar.gz: e4b49528fe297b00c61d9f45bd8b780e88d485c9c3c4e1f37c1a28ce63ee128af0c64bccdaa68567197f0aa5beae51f568fd4371cb4d4a273c82ca7204a73b19
@@ -374,6 +374,7 @@ module Klenod
374
374
  transform = loaded_source.transform || transform_module_source(module_id, source)
375
375
  resolved_dependencies = resolve_transform_dependencies(module_id, transform)
376
376
  dependency_records = load_eager_dependency_records(resolved_dependencies)
377
+ assert_eager_import_names!(module_id, source, resolved_dependencies, dependency_records)
377
378
  transform = finalize_transform_result(module_id, transform, resolved_dependencies, dependency_records)
378
379
  assert_supported_transform!(module_id, source, transform)
379
380
  assert_generated_ruby_parses!(module_id, transform)
@@ -430,6 +431,7 @@ module Klenod
430
431
  transform = loaded_source.transform || transform_module_source(module_id, source)
431
432
  resolved_dependencies = resolve_transform_dependencies(module_id, transform)
432
433
  dependency_records = collect_eager_dependency_records(resolved_dependencies)
434
+ assert_eager_import_names!(module_id, source, resolved_dependencies, dependency_records)
433
435
  transform = finalize_transform_result(module_id, transform, resolved_dependencies, dependency_records)
434
436
  assert_supported_transform!(module_id, source, transform)
435
437
  assert_generated_ruby_parses!(module_id, transform)
@@ -462,7 +464,7 @@ module Klenod
462
464
  Runtime::Mod.new(
463
465
  module_id.to_s,
464
466
  record.transformed_source,
465
- imports: imports_for(record.resolved_dependencies, dependency_records),
467
+ imports: imports_for(module_id, record.source, record.resolved_dependencies, dependency_records),
466
468
  source_map: record.source_map,
467
469
  version: record.version,
468
470
  eval_path: eval_path_for(module_id),
@@ -554,7 +556,11 @@ module Klenod
554
556
  record
555
557
  .resolved_dependencies
556
558
  .reject { skip_asset_dependency?(it, type:) }
557
- .map(&:module_id)
559
+ if type == :css
560
+ regular_dependencies, companion_styles = dependency_ids.partition { |dependency| dependency.dependency.kind != :companion_style }
561
+ dependency_ids = [*regular_dependencies, *companion_styles]
562
+ end
563
+ dependency_ids = dependency_ids.map(&:module_id)
558
564
 
559
565
  if module_id.extname == ".css"
560
566
  dependency_ids.flat_map { |dependency_id| ordered_module_ids_for_assets(dependency_id, seen, type: type) } + [module_id]
@@ -659,7 +665,7 @@ module Klenod
659
665
  Runtime::Mod.new(
660
666
  module_id.to_s,
661
667
  transform.code,
662
- imports: imports_for(resolved_dependencies, dependency_records),
668
+ imports: imports_for(module_id, source, resolved_dependencies, dependency_records),
663
669
  source_map: transform.source_map,
664
670
  version: cached ? cached.version + 1 : 0,
665
671
  eval_path: eval_path_for(module_id),
@@ -690,7 +696,7 @@ module Klenod
690
696
  source_dir.join(module_id.path).to_s
691
697
  end
692
698
 
693
- def imports_for(resolved_dependencies, dependency_records)
699
+ def imports_for(module_id, source, resolved_dependencies, dependency_records)
694
700
  resolved_dependencies.to_h do |resolved_dependency|
695
701
  value =
696
702
  if resolved_dependency.dependency.eager
@@ -702,6 +708,7 @@ module Klenod
702
708
  evaluate_module(resolved_dependency.module_id)
703
709
  record = @records.fetch(resolved_dependency.module_id)
704
710
  raise_failed_module!(record)
711
+ assert_import_name!(module_id, source, resolved_dependency, record)
705
712
  import_value(resolved_dependency, record)
706
713
  end
707
714
  end
@@ -898,10 +905,9 @@ module Klenod
898
905
  @records.to_h do |module_id, record|
899
906
  imports =
900
907
  record.resolved_dependencies.to_h do |resolved_dependency|
901
- [
902
- resolved_dependency.dependency.id,
903
- runtime_import_spec(resolved_dependency, @records.fetch(resolved_dependency.module_id))
904
- ]
908
+ target = @records.fetch(resolved_dependency.module_id)
909
+ assert_import_name!(module_id, record.source, resolved_dependency, target)
910
+ [resolved_dependency.dependency.id, runtime_import_spec(resolved_dependency, target)]
905
911
  end
906
912
 
907
913
  [
@@ -990,6 +996,35 @@ module Klenod
990
996
  end
991
997
  end
992
998
 
999
+ # Lazy targets are not collected until bundle or call time, so only the
1000
+ # eager imports can be checked while the importer is collected.
1001
+ def assert_eager_import_names!(module_id, source, resolved_dependencies, dependency_records)
1002
+ resolved_dependencies.each do |resolved_dependency|
1003
+ next unless resolved_dependency.dependency.eager
1004
+
1005
+ record = dependency_records.fetch(resolved_dependency.dependency.id)
1006
+ assert_import_name!(module_id, source, resolved_dependency, record)
1007
+ end
1008
+ end
1009
+
1010
+ # A named import (`import("./x", :Bar)`) is checked against the constants
1011
+ # the target defines statically, recorded by the Ruby plugin as
1012
+ # metadata[:ruby_constants]. A missing Default is not an error: the
1013
+ # import then returns the Exports module.
1014
+ def assert_import_name!(module_id, source, resolved_dependency, record)
1015
+ name = resolved_dependency.dependency.metadata[:import_name]
1016
+ return unless name
1017
+
1018
+ constants = record.metadata[:ruby_constants]
1019
+ return if constants&.include?(name)
1020
+
1021
+ raise MissingExportError.new(
1022
+ MissingExportError::Report.new(resolved_dependency.dependency, record.id, name, constants),
1023
+ source: source,
1024
+ module_id: module_id
1025
+ )
1026
+ end
1027
+
993
1028
  def import_value(resolved_dependency, record)
994
1029
  raise_failed_module!(record)
995
1030
 
@@ -22,7 +22,7 @@ module Klenod
22
22
  return nil unless module_id.scheme == :virtual && module_id == CLASS_NAMES_MODULE_ID
23
23
 
24
24
  source = class_names_runtime_source
25
- LoadResult.new(source, nil, TransformResult.new(source, [], nil, [], [], {}))
25
+ LoadResult.new(source, nil, TransformResult.new(source, [], nil, [], [], {ruby_constants: [:Default]}))
26
26
  end
27
27
 
28
28
  def class_names_runtime_dependency(module_id)
@@ -12,7 +12,8 @@ module Klenod
12
12
  .create(
13
13
  specifier: HAML_HELPER_SPECIFIER,
14
14
  importer_id: module_id,
15
- kind: :haml_helper
15
+ kind: :haml_helper,
16
+ metadata: {import_name: :Default}
16
17
  )
17
18
  .with(id: "#{module_id}:haml_helper")
18
19
  end
@@ -35,6 +35,20 @@ module Klenod
35
35
  result
36
36
  end
37
37
 
38
+ # Haml's parenthesized-attribute parser accepts only a bare variable
39
+ # after `=`. For example, it reads `video_id=video.id` as the value
40
+ # `video` plus a `.id` class. Extend that form to ordinary Ruby
41
+ # member and index expressions while leaving all other attributes on
42
+ # Haml's native parsing path.
43
+ def parse_new_attributes(text)
44
+ balanced, rest = ::Haml::Util.balance(text, "(", ")")
45
+ pairs = balanced && extended_attribute_pairs(balanced[1...-1])
46
+ return super unless pairs&.any? { |_name, value| value.match?(/[.\[]/) }
47
+
48
+ dynamic = pairs.reduce("{") { |source, (name, value)| "#{source}#{::Haml::Util.inspect_obj(name)} => #{value}," } << "}"
49
+ [[{}, dynamic], rest, @line.index + 1]
50
+ end
51
+
38
52
  def annotate_tag_nodes(root)
39
53
  queue = root.children.dup
40
54
  until queue.empty?
@@ -62,6 +76,59 @@ module Klenod
62
76
  {shorthand: shorthand, literal: literal}
63
77
  end
64
78
 
79
+ def extended_attribute_pairs(source)
80
+ pairs = []
81
+ index = 0
82
+
83
+ loop do
84
+ index += 1 while source[index]&.match?(/\s/)
85
+ break if index >= source.length
86
+
87
+ name = source[index..].match(/\A[-:@#\w.]+/)&.to_s
88
+ return nil unless name
89
+
90
+ index += name.length
91
+ index += 1 while source[index]&.match?(/\s/)
92
+ return nil unless source[index] == "="
93
+
94
+ index += 1
95
+ index += 1 while source[index]&.match?(/\s/)
96
+ value_start = index
97
+ depth = 0
98
+ quote = nil
99
+ escaped = false
100
+
101
+ while index < source.length
102
+ character = source[index]
103
+ if quote
104
+ if escaped
105
+ escaped = false
106
+ elsif character == "\\"
107
+ escaped = true
108
+ elsif character == quote
109
+ quote = nil
110
+ end
111
+ else
112
+ case character
113
+ when "'", '"' then quote = character
114
+ when "(", "[", "{" then depth += 1
115
+ when ")", "]", "}" then depth -= 1
116
+ when " ", "\t"
117
+ break if depth.zero? && source[index..].match?(/\A\s+[-:@#\w.]+\s*=/)
118
+ end
119
+ end
120
+ index += 1
121
+ end
122
+
123
+ value = source[value_start...index].strip
124
+ return nil if value.empty? || depth.negative? || quote
125
+
126
+ pairs << [name, value]
127
+ end
128
+
129
+ pairs
130
+ end
131
+
65
132
  def literal_class_names_from_old_attributes(source)
66
133
  parsed = ::Haml::AttributeParser.parse(source)
67
134
  return [] unless parsed&.key?("class")
@@ -489,7 +489,9 @@ module Klenod
489
489
  def render_ruby_filter(node)
490
490
  source = to_source(node)
491
491
  parsed = (node if node.is_a?(Fragment) && node.node?) || statements(source)
492
- return statements("begin\n#{indent(source, 2)}\n nil\nend") unless parsed
492
+ unless parsed&.node?
493
+ raise_ruby_parse_error(source, line_no: nil, context: "Could not parse Ruby filter")
494
+ end
493
495
 
494
496
  fragment(
495
497
  ast_begin([
@@ -497,8 +499,6 @@ module Klenod
497
499
  nil_node
498
500
  ])
499
501
  )
500
- rescue SyntaxTree::Parser::ParseError
501
- statements("begin\n#{indent(source, 2)}\n nil\nend")
502
502
  end
503
503
 
504
504
  def format_node(node)
@@ -575,16 +575,20 @@ module Klenod
575
575
  line_offsets = [0]
576
576
  source.each_line(chomp: false) { |line| line_offsets << line_offsets.last + line.length }
577
577
 
578
- Ripper
579
- .lex(source)
580
- .filter_map do |(line, column), type, token, _state|
578
+ tokens = Ripper.lex(source)
579
+
580
+ tokens
581
+ .each_with_index
582
+ .filter_map do |((line, column), type, token, _state), index|
581
583
  kind, prefix, pattern = VARIABLE_TOKEN_KINDS[type]
582
584
  receiver = @variables[kind]
583
585
  if type == :on_gvar && token == "$*" && receiver
584
586
  [offset_for(line_offsets, line, column), token.length, receiver]
585
587
  elsif receiver && token.match?(pattern)
586
588
  name = token.delete_prefix(prefix)
587
- [offset_for(line_offsets, line, column), token.length, "(#{receiver})[#{symbol_source(name)}]"]
589
+ replacement = "(#{receiver})[#{symbol_source(name)}]"
590
+ replacement = "{#{replacement}}" if tokens[index - 1]&.fetch(1) == :on_embvar
591
+ [offset_for(line_offsets, line, column), token.length, replacement]
588
592
  end
589
593
  end
590
594
  .reverse_each
@@ -549,6 +549,9 @@ module Klenod
549
549
  source_column_offset = source.lines.fetch(node.line, "").match(/\A\s*/).to_s.length
550
550
  text = import_rewriter.call(text, source_line_offset: node.line, source_column_offset: source_column_offset)
551
551
  end
552
+ unless builder.statements(text).node?
553
+ builder.ruby_parse_error(text, line_no: node.line + 1, context: "Could not parse Ruby filter")
554
+ end
552
555
  heredoc_body_lines = ruby_heredoc_body_lines(text)
553
556
  source = +""
554
557
  text.each_line.with_index(node.line + 1) do |line, line_no|
@@ -97,7 +97,9 @@ module Klenod
97
97
 
98
98
  return nil unless module_id.scheme == :virtual && module_id == HAML_HELPER_MODULE_ID
99
99
 
100
- LoadResult.new(haml_helper_source, nil, TransformResult.new(haml_helper_source, [], nil, [], [], {}))
100
+ # Loaded with a ready transform, so the Ruby plugin never scans it: declare
101
+ # the Default export it defines.
102
+ LoadResult.new(haml_helper_source, nil, TransformResult.new(haml_helper_source, [], nil, [], [], {ruby_constants: [:Default]}))
101
103
  end
102
104
 
103
105
  def transform(module_id, code, context)
@@ -136,7 +138,7 @@ module Klenod
136
138
  specifier: "/markdown-components",
137
139
  importer_id: module_id,
138
140
  kind: :markdown_components,
139
- metadata: {optional: true}
141
+ metadata: {optional: true, import_name: :Default}
140
142
  )
141
143
  .with(id: "#{module_id}:markdown_components")
142
144
  dependencies << markdown_components_dependency
@@ -205,11 +207,11 @@ module Klenod
205
207
  styles_source: styles_source,
206
208
  translations_source: translations_source,
207
209
  i18n_source: i18n_source_for(builder),
208
- haml_helper_source: haml_helper_dependency && builder.constant_assignment("HamlHelper", "#{builder.import_call(haml_helper_dependency.id).source}::Default"),
210
+ haml_helper_source: haml_helper_dependency && builder.constant_assignment("HamlHelper", builder.import_call(haml_helper_dependency.id).source),
209
211
  styleable: !style_dependencies.empty?,
210
212
  profiler: context.profiler,
211
213
  import_rewriter: import_rewriter,
212
- markdown_components_source: markdown_components_dependency ? "__klenod_import__(#{markdown_components_dependency.id.inspect})::Default" : "{}",
214
+ markdown_components_source: markdown_components_dependency ? "__klenod_import__(#{markdown_components_dependency.id.inspect})" : "{}",
213
215
  variables: @variables,
214
216
  event_handler: @event_handler,
215
217
  cache_static_subtrees: @cache_static_subtrees
@@ -246,19 +248,26 @@ module Klenod
246
248
  def import_value(resolved_dependency, record, context)
247
249
  styles_import = class_names_runtime_import_value(resolved_dependency, record, context)
248
250
  return styles_import if styles_import
249
- return nil unless record.id.extname == ".haml"
251
+ return nil unless default_export?(record)
250
252
 
251
- context.mods.fetch(record.id).const_get(:Exports)::Default
253
+ context.mods.fetch(record.id).const_get(:Exports).const_get(:Default, false)
252
254
  end
253
255
 
254
256
  def runtime_import_value(resolved_dependency, record, _context)
255
257
  styles_import = class_names_runtime_runtime_import_value(resolved_dependency, record)
256
258
  return styles_import if styles_import
257
- return Runtime::DefaultImport.new(:Default) if record.id.extname == ".haml"
259
+ return Runtime::DefaultImport.new(:Default) if default_export?(record)
258
260
 
259
261
  super
260
262
  end
261
263
 
264
+ # Components and the helper module both export Default. The helper is
265
+ # unwrapped here as well as by the Ruby plugin, so a context built
266
+ # without the Ruby plugin still renders.
267
+ def default_export?(record)
268
+ record.id.extname == ".haml" || record.id == HAML_HELPER_MODULE_ID
269
+ end
270
+
262
271
  def invalidate_module_ids(paths, context)
263
272
  paths
264
273
  .filter_map { |path| companion_owner_module_id(path, context) }
@@ -168,13 +168,13 @@ module Klenod
168
168
  specifier: "/markdown-components",
169
169
  importer_id: module_id,
170
170
  kind: :markdown_components,
171
- metadata: {optional: true}
171
+ metadata: {optional: true, import_name: :Default}
172
172
  )
173
173
  .with(id: dependency_id(module_id))
174
174
  end
175
175
 
176
176
  def components_source_for(dependency)
177
- dependency ? "__klenod_import__(#{dependency.id.inspect})::Default" : "{}"
177
+ dependency ? "__klenod_import__(#{dependency.id.inspect})" : "{}"
178
178
  end
179
179
 
180
180
  def dependency_id(module_id)
@@ -53,7 +53,7 @@ module Klenod
53
53
  def transform(module_id, code, context)
54
54
  return TransformResult.identity(code) unless module_id.extname == ".rb"
55
55
 
56
- assert_parses!(module_id, code)
56
+ program = parse!(module_id, code)
57
57
 
58
58
  result =
59
59
  begin
@@ -68,21 +68,77 @@ module Klenod
68
68
  rescue SyntaxTree::Parser::ParseError => error
69
69
  raise ParseError.new(error, source: code, module_id: module_id)
70
70
  end
71
- TransformResult.new(result.code, result.dependencies, nil, [], result.watched_patterns, {})
71
+ TransformResult.new(
72
+ result.code,
73
+ result.dependencies,
74
+ nil,
75
+ [],
76
+ result.watched_patterns,
77
+ {ruby_constants: top_level_constants(program)}
78
+ )
79
+ end
80
+
81
+ # The value an importer receives: the requested constant, or Default
82
+ # when the module defines one. Nil keeps the Exports module.
83
+ def import_value(resolved_dependency, record, context)
84
+ name = import_constant_name(resolved_dependency, record)
85
+ return nil unless name
86
+
87
+ context.mods.fetch(record.id).const_get(:Exports).const_get(name, false)
88
+ end
89
+
90
+ def runtime_import_value(resolved_dependency, record, _context)
91
+ name = import_constant_name(resolved_dependency, record)
92
+ return nil unless name
93
+
94
+ Runtime::DefaultImport.new(name)
72
95
  end
73
96
 
74
97
  private
75
98
 
76
99
  # The rewriter only parses a file that contains an import it cannot
77
- # rewrite literally, so without this check most syntax errors would
100
+ # rewrite literally, so without this parse most syntax errors would
78
101
  # not surface until the module was evaluated -- and a production build
79
102
  # never evaluates application modules, so the bundle would ship
80
- # broken. Prism is the parser CRuby itself uses, and validating costs
81
- # well under a tenth of a millisecond per file.
82
- def assert_parses!(module_id, code)
83
- return if Prism.parse_success?(code)
103
+ # broken. Prism is the parser CRuby itself uses, and parsing costs
104
+ # well under a millisecond per file. The same tree tells us which
105
+ # constants the module exports.
106
+ def parse!(module_id, code)
107
+ result = Prism.parse(code)
108
+ raise ParseError.new(result, source: code, module_id: module_id) if result.failure?
109
+
110
+ result.value
111
+ end
112
+
113
+ # The constants a module defines at its top level, meaning directly
114
+ # on its Exports module. Only static definitions count: `const_set`,
115
+ # constants inside `if` or `begin`, `A::B = 1`, and `class ::A` are
116
+ # not exports.
117
+ def top_level_constants(program)
118
+ program.statements.body.flat_map { |node| defined_constant_names(node) }.uniq
119
+ end
120
+
121
+ def defined_constant_names(node)
122
+ case node
123
+ when Prism::ConstantWriteNode
124
+ [node.name]
125
+ when Prism::ClassNode, Prism::ModuleNode
126
+ node.constant_path.is_a?(Prism::ConstantReadNode) ? [node.constant_path.name] : []
127
+ when Prism::MultiWriteNode
128
+ [*node.lefts, *node.rights].grep(Prism::ConstantTargetNode).map(&:name)
129
+ else
130
+ []
131
+ end
132
+ end
133
+
134
+ # Records without a constant list were not transformed by this plugin
135
+ # and keep whatever value other plugins or the graph supply.
136
+ def import_constant_name(resolved_dependency, record)
137
+ constants = record.metadata[:ruby_constants]
138
+ return nil unless constants
84
139
 
85
- raise ParseError.new(Prism.parse(code), source: code, module_id: module_id)
140
+ name = resolved_dependency.dependency.metadata.fetch(:import_name, :Default)
141
+ constants.include?(name) ? name : nil
86
142
  end
87
143
  end
88
144
  end
@@ -11,7 +11,7 @@ module Klenod
11
11
  module Build
12
12
  class RubyImportRewriter
13
13
  ImportCall =
14
- Data.define(:specifier, :location, :dynamic, :method_name, :eager_override) do
14
+ Data.define(:specifier, :location, :dynamic, :method_name, :eager_override, :import_name) do
15
15
  def eager
16
16
  eager_override.nil? ? RubyImportRewriter::IMPORT_METHODS.fetch(method_name).fetch(:eager) : eager_override
17
17
  end
@@ -213,21 +213,33 @@ module Klenod
213
213
 
214
214
  first = unwrap_paren(parts.first)
215
215
  string_parts = first&.instance_variable_get(:@parts)
216
- literal =
217
- (parts.length == 1) &&
216
+ literal_specifier =
218
217
  first&.class&.name == "SyntaxTree::StringLiteral" &&
219
218
  string_parts&.length == 1 &&
220
219
  string_parts.first.instance_of?(::SyntaxTree::TStringContent)
220
+ import_name = (parts.length == 2) ? constant_symbol_value(parts.fetch(1)) : nil
221
+ literal = literal_specifier && (parts.length == 1 || !import_name.nil?)
221
222
 
222
223
  ImportCall.new(
223
224
  literal ? string_parts.first.value : nil,
224
225
  node.instance_variable_get(:@location),
225
226
  !literal,
226
227
  node.instance_variable_get(:@message).value,
227
- nil
228
+ nil,
229
+ literal ? import_name : nil
228
230
  )
229
231
  end
230
232
 
233
+ # The optional second argument of `import`: a symbol literal spelled like
234
+ # a constant, such as `:Bar`. Anything else (`:bar`, `:"Bar"`, a string,
235
+ # a variable) is reported as a dynamic import.
236
+ def constant_symbol_value(node)
237
+ return unless node.instance_of?(::SyntaxTree::SymbolLiteral)
238
+
239
+ value = node.instance_variable_get(:@value)
240
+ value.instance_of?(::SyntaxTree::Const) ? value.value.to_sym : nil
241
+ end
242
+
231
243
  def build_import_glob_from_parts(node, parts)
232
244
  first = unwrap_paren(parts.first)
233
245
  string_parts = first&.instance_variable_get(:@parts)
@@ -250,7 +262,8 @@ module Klenod
250
262
  node.instance_variable_get(:@location),
251
263
  !literal || !valid_options,
252
264
  node.instance_variable_get(:@message).value,
253
- eager
265
+ eager,
266
+ nil
254
267
  )
255
268
  end
256
269
 
@@ -304,20 +317,29 @@ module Klenod
304
317
  end
305
318
 
306
319
  def raise_dynamic_import!(call)
307
- expected = (call.method_name == "import_glob") ? "import_glob(\"...\")" : "import(\"...\")"
320
+ expected =
321
+ if call.method_name == "import_glob"
322
+ "import_glob(\"...\")"
323
+ else
324
+ "#{call.method_name}(\"...\") or #{call.method_name}(\"...\", :Constant)"
325
+ end
308
326
  raise DynamicImportError, "Only literal #{expected} calls are supported in #{@module_id}"
309
327
  end
310
328
 
311
329
  def build_dependency(call, dependency_index)
312
- Dependency
313
- .create(
314
- specifier: call.specifier,
315
- importer_id: @module_id,
316
- kind: @kind,
317
- loc: source_location(call.location)
318
- )
319
- .with(eager: call.eager)
320
- .with(id: "#{@module_id}:dependency:#{dependency_index}")
330
+ dependency =
331
+ Dependency
332
+ .create(
333
+ specifier: call.specifier,
334
+ importer_id: @module_id,
335
+ kind: @kind,
336
+ loc: source_location(call.location)
337
+ )
338
+ .with(eager: call.eager)
339
+ .with(id: "#{@module_id}:dependency:#{dependency_index}")
340
+ return dependency unless call.import_name
341
+
342
+ dependency.with(metadata: {import_name: call.import_name}.freeze)
321
343
  end
322
344
 
323
345
  def expand_glob_import(call, dependency_index)
@@ -110,6 +110,43 @@ module Klenod
110
110
  end
111
111
  end
112
112
 
113
+ # A named import (`import("./x", :Bar)`) of a constant the target module
114
+ # does not define at its top level.
115
+ #
116
+ # The check uses the constants the Ruby plugin recorded statically, so a
117
+ # typo fails while collecting, with an excerpt of the import, instead of
118
+ # surfacing as a NameError from inside module evaluation -- or, worse, at
119
+ # runtime, since a production build never evaluates application modules.
120
+ class MissingExportError < SourceError
121
+ Report = Data.define(:dependency, :target_id, :name, :constants)
122
+
123
+ def kind
124
+ "Missing export"
125
+ end
126
+
127
+ private
128
+
129
+ def location(report)
130
+ Location.new(
131
+ line: report.dependency.loc&.line,
132
+ column: report.dependency.loc&.column,
133
+ detail: "#{report.target_id} does not define #{report.name}",
134
+ hints: [hint_for(report)]
135
+ )
136
+ end
137
+
138
+ def hint_for(report)
139
+ case report.constants
140
+ when nil
141
+ "Named imports need a Ruby module; #{report.target_id} is not one"
142
+ when []
143
+ "#{report.target_id} defines no top-level constants; import it without a name to get its Exports module"
144
+ else
145
+ "It defines #{report.constants.join(", ")}"
146
+ end
147
+ end
148
+ end
149
+
113
150
  # Ruby generated from another format that does not parse.
114
151
  #
115
152
  # This is a bug in the plugin that generated it rather than in anything the
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Klenod
4
4
  module Build
5
- VERSION = "0.0.16"
5
+ VERSION = "0.0.18"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: klenod-build
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.16
4
+ version: 0.0.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrés Alin
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.0.16
18
+ version: 0.0.18
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.0.16
25
+ version: 0.0.18
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: async
28
28
  requirement: !ruby/object:Gem::Requirement