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,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+ require "syntax_tree"
5
+
6
+ require_relative "dependency"
7
+ require_relative "errors"
8
+ require_relative "watched_pattern"
9
+
10
+ module Klenod
11
+ module Build
12
+ class RubyImportRewriter
13
+ ImportCall =
14
+ Data.define(:specifier, :location, :dynamic, :method_name, :eager_override) do
15
+ def eager
16
+ eager_override.nil? ? RubyImportRewriter::IMPORT_METHODS.fetch(method_name).fetch(:eager) : eager_override
17
+ end
18
+
19
+ def replacement
20
+ RubyImportRewriter::IMPORT_METHODS.fetch(method_name).fetch(:replacement)
21
+ end
22
+ end
23
+ Result = Data.define(:code, :dependencies, :watched_patterns)
24
+
25
+ IMPORT_METHODS = {
26
+ "import" => {
27
+ replacement: "__klenod_import__",
28
+ eager: true
29
+ },
30
+ "lazy_import" => {
31
+ replacement: "__klenod_lazy_import__",
32
+ eager: false
33
+ },
34
+ "import_glob" => {
35
+ replacement: nil,
36
+ eager: true
37
+ }
38
+ }.freeze
39
+ IMPORT_SOURCE_PATTERN = /(?<![A-Za-z0-9_])(?:import|lazy_import|import_glob)\s*(?:\(|["'])/
40
+ FAST_LITERAL_IMPORT_PATTERN = /(?<![A-Za-z0-9_])(import|lazy_import)\s*\(\s*("(?:\\.|[^"\\#])*")\s*\)/
41
+
42
+ def initialize(module_id:, kind:, source_dir: nil, profiler: nil, dependency_id_offset: 0, source_line_offset: 0, source_column_offset: 0)
43
+ @module_id = module_id
44
+ @kind = kind
45
+ @source_dir = source_dir && Pathname.new(source_dir).expand_path
46
+ @profiler = profiler
47
+ @dependency_id_offset = dependency_id_offset
48
+ @source_line_offset = source_line_offset
49
+ @source_column_offset = source_column_offset
50
+ end
51
+
52
+ def rewrite(code)
53
+ return Result.new(code, [], []) unless import_source?(code)
54
+ fast_result = rewrite_literal_import_calls(code)
55
+ return fast_result if fast_result
56
+
57
+ ast = measure(:ruby_import_parse) { SyntaxTree.parse(code) }
58
+ calls = measure(:ruby_import_scan) { import_calls(ast) }
59
+ expanded = expand_import_calls(calls)
60
+
61
+ rewritten = measure(:ruby_import_rewrite_source) { rewrite_import_calls(code, expanded) }
62
+ Result.new(
63
+ rewritten,
64
+ expanded.flat_map(&:dependencies),
65
+ expanded.flat_map(&:watched_patterns)
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ ExpandedImport = Data.define(:call, :dependencies, :replacement_source, :watched_patterns)
72
+
73
+ def import_source?(code)
74
+ code.match?(IMPORT_SOURCE_PATTERN)
75
+ end
76
+
77
+ def rewrite_literal_import_calls(code)
78
+ matches = []
79
+ code.to_enum(:scan, IMPORT_SOURCE_PATTERN).each do
80
+ start = Regexp.last_match.begin(0)
81
+ match = FAST_LITERAL_IMPORT_PATTERN.match(code, start)
82
+ return nil unless match && match.begin(0) == start
83
+
84
+ matches << match
85
+ end
86
+ return nil if matches.empty?
87
+ return nil unless bare_import_tokens?(code, matches)
88
+
89
+ dependencies =
90
+ matches.each_with_index.map do |match, index|
91
+ specifier =
92
+ begin
93
+ match[2].undump
94
+ rescue RuntimeError
95
+ return nil
96
+ end
97
+
98
+ Dependency
99
+ .create(
100
+ specifier: specifier,
101
+ importer_id: @module_id,
102
+ kind: @kind,
103
+ loc: source_location_for_offset(code, match.begin(0))
104
+ )
105
+ .with(eager: IMPORT_METHODS.fetch(match[1]).fetch(:eager))
106
+ .with(id: "#{@module_id}:dependency:#{@dependency_id_offset + index}")
107
+ end
108
+
109
+ rewritten =
110
+ matches
111
+ .zip(dependencies)
112
+ .reverse_each
113
+ .each_with_object(code.dup) do |(match, dependency), source|
114
+ replacement = IMPORT_METHODS.fetch(match[1]).fetch(:replacement)
115
+ source[match.begin(0)...match.end(0)] = "#{replacement}(#{dependency.id.inspect})"
116
+ end
117
+
118
+ Result.new(rewritten, dependencies, [])
119
+ end
120
+
121
+ def bare_import_tokens?(code, matches)
122
+ match_starts = matches.to_h { |match| [match.begin(1), match[1]] }
123
+ return true if match_starts.empty?
124
+
125
+ line_offsets = line_offsets_for(code)
126
+ previous_significant_token = nil
127
+ matched = {}
128
+
129
+ Ripper.lex(code).each do |(line, column), type, token, _state|
130
+ offset = line_offsets.fetch(line - 1) + column
131
+ expected = match_starts[offset]
132
+ if expected
133
+ return false unless type == :on_ident && token == expected
134
+ return false if receiver_token?(previous_significant_token)
135
+
136
+ matched[offset] = true
137
+ end
138
+
139
+ previous_significant_token = [type, token] unless insignificant_token?(type)
140
+ end
141
+
142
+ matched.length == match_starts.length
143
+ end
144
+
145
+ def receiver_token?(token)
146
+ token == [:on_period, "."] || token == [:on_op, "::"] || token == [:on_op, "&."]
147
+ end
148
+
149
+ def line_offsets_for(code)
150
+ offsets = [0]
151
+ code.each_line(chomp: false) { |line| offsets << offsets.last + line.length }
152
+ offsets
153
+ end
154
+
155
+ def insignificant_token?(type)
156
+ type == :on_sp || type == :on_ignored_nl || type == :on_nl || type == :on_comment
157
+ end
158
+
159
+ def measure(name, &block)
160
+ return yield unless @profiler
161
+
162
+ @profiler.measure(name, module_id: @module_id.to_s, kind: @kind, &block)
163
+ end
164
+
165
+ def import_calls(node)
166
+ calls = []
167
+ walk(node) do |child|
168
+ if child.instance_of?(::SyntaxTree::CallNode)
169
+ next unless child.instance_variable_get(:@receiver).nil?
170
+ next unless IMPORT_METHODS.key?(child.instance_variable_get(:@message)&.value)
171
+
172
+ calls << build_import_call(child)
173
+ elsif child.instance_of?(::SyntaxTree::Command)
174
+ next unless IMPORT_METHODS.key?(child.instance_variable_get(:@message)&.value)
175
+
176
+ calls << build_import_command(child)
177
+ end
178
+ end
179
+ calls
180
+ end
181
+
182
+ def build_import_call(node)
183
+ arguments = node.instance_variable_get(:@arguments)
184
+ parts = arguments&.instance_variable_get(:@arguments)&.instance_variable_get(:@parts) || []
185
+ build_import_from_parts(node, parts)
186
+ end
187
+
188
+ def build_import_command(node)
189
+ arguments = node.instance_variable_get(:@arguments)
190
+ parts = arguments&.instance_variable_get(:@parts) || []
191
+ build_import_from_parts(node, parts)
192
+ end
193
+
194
+ def build_import_from_parts(node, parts)
195
+ return build_import_glob_from_parts(node, parts) if node.instance_variable_get(:@message).value == "import_glob"
196
+
197
+ first = unwrap_paren(parts.first)
198
+ string_parts = first&.instance_variable_get(:@parts)
199
+ literal =
200
+ (parts.length == 1) &&
201
+ first&.class&.name == "SyntaxTree::StringLiteral" &&
202
+ string_parts&.length == 1 &&
203
+ string_parts.first.instance_of?(::SyntaxTree::TStringContent)
204
+
205
+ ImportCall.new(
206
+ literal ? string_parts.first.value : nil,
207
+ node.instance_variable_get(:@location),
208
+ !literal,
209
+ node.instance_variable_get(:@message).value,
210
+ nil
211
+ )
212
+ end
213
+
214
+ def build_import_glob_from_parts(node, parts)
215
+ first = unwrap_paren(parts.first)
216
+ string_parts = first&.instance_variable_get(:@parts)
217
+ literal =
218
+ first&.class&.name == "SyntaxTree::StringLiteral" &&
219
+ string_parts&.length == 1 &&
220
+ string_parts.first.instance_of?(::SyntaxTree::TStringContent)
221
+
222
+ eager = true
223
+ valid_options = true
224
+ if parts.length == 2
225
+ eager = eager_option_value(parts.fetch(1))
226
+ valid_options = !eager.nil?
227
+ elsif parts.length != 1
228
+ valid_options = false
229
+ end
230
+
231
+ ImportCall.new(
232
+ (literal && valid_options) ? string_parts.first.value : nil,
233
+ node.instance_variable_get(:@location),
234
+ !literal || !valid_options,
235
+ node.instance_variable_get(:@message).value,
236
+ eager
237
+ )
238
+ end
239
+
240
+ def eager_option_value(node)
241
+ return unless node.instance_of?(::SyntaxTree::BareAssocHash)
242
+
243
+ assocs = node.instance_variable_get(:@assocs)
244
+ return unless assocs.length == 1
245
+
246
+ assoc = assocs.fetch(0)
247
+ key = assoc.instance_variable_get(:@key)
248
+ return unless key.instance_of?(::SyntaxTree::Label) && key.value == "eager:"
249
+
250
+ bool_value(assoc.instance_variable_get(:@value))
251
+ end
252
+
253
+ def bool_value(node)
254
+ return unless node.instance_of?(::SyntaxTree::VarRef)
255
+
256
+ value = node.instance_variable_get(:@value)
257
+ return true if value.respond_to?(:value) && value.value == "true"
258
+ return false if value.respond_to?(:value) && value.value == "false"
259
+
260
+ nil
261
+ end
262
+
263
+ def unwrap_paren(node)
264
+ return node unless node.instance_of?(::SyntaxTree::Paren)
265
+
266
+ statements = node.instance_variable_get(:@contents)
267
+ body = statements&.instance_variable_get(:@body) || []
268
+ (body.length == 1) ? body.first : node
269
+ end
270
+
271
+ def expand_import_calls(calls)
272
+ dependency_index = @dependency_id_offset
273
+
274
+ calls.map do |call|
275
+ raise_dynamic_import!(call) if call.dynamic
276
+
277
+ if call.method_name == "import_glob"
278
+ dependencies, watched_patterns = expand_glob_import(call, dependency_index)
279
+ dependency_index += dependencies.length
280
+ ExpandedImport.new(call, dependencies, glob_replacement_source(dependencies, eager: call.eager), watched_patterns)
281
+ else
282
+ dependency = build_dependency(call, dependency_index)
283
+ dependency_index += 1
284
+ ExpandedImport.new(call, [dependency], "#{call.replacement}(#{dependency.id.inspect})", [])
285
+ end
286
+ end
287
+ end
288
+
289
+ def raise_dynamic_import!(call)
290
+ expected = (call.method_name == "import_glob") ? "import_glob(\"...\")" : "import(\"...\")"
291
+ raise DynamicImportError, "Only literal #{expected} calls are supported in #{@module_id}"
292
+ end
293
+
294
+ def build_dependency(call, dependency_index)
295
+ Dependency
296
+ .create(
297
+ specifier: call.specifier,
298
+ importer_id: @module_id,
299
+ kind: @kind,
300
+ loc: source_location(call.location)
301
+ )
302
+ .with(eager: call.eager)
303
+ .with(id: "#{@module_id}:dependency:#{dependency_index}")
304
+ end
305
+
306
+ def expand_glob_import(call, dependency_index)
307
+ source_dir = @source_dir || raise(DynamicImportError, "Cannot expand import_glob in #{@module_id} without a source directory")
308
+ path_pattern, query = call.specifier.split("?", 2)
309
+ absolute_pattern = absolute_pattern_for(path_pattern)
310
+ assert_pattern_inside_source_dir!(absolute_pattern)
311
+
312
+ matches =
313
+ Dir
314
+ .glob(absolute_pattern.to_s)
315
+ .select { |path| File.file?(path) }
316
+ .sort
317
+
318
+ dependencies =
319
+ matches.each_with_index.map do |absolute_path, index|
320
+ specifier = specifier_for_glob_match(path_pattern, absolute_path)
321
+ specifier = "#{specifier}?#{query}" if query
322
+
323
+ Dependency
324
+ .create(
325
+ specifier: specifier,
326
+ importer_id: @module_id,
327
+ kind: @kind,
328
+ loc: source_location(call.location)
329
+ )
330
+ .with(eager: call.eager)
331
+ .with(metadata: {glob_key: specifier.split("?", 2).first})
332
+ .with(id: "#{@module_id}:dependency:#{dependency_index + index}")
333
+ end
334
+
335
+ watched_patterns = [
336
+ WatchedPattern.new(
337
+ @module_id,
338
+ absolute_pattern.relative_path_from(source_dir).to_s,
339
+ :import_glob,
340
+ {specifier: call.specifier}
341
+ )
342
+ ]
343
+
344
+ [dependencies, watched_patterns]
345
+ end
346
+
347
+ def source_location(location)
348
+ SourceLocation.new(
349
+ @module_id.to_s,
350
+ location.start_line + @source_line_offset,
351
+ location.start_column + 1 + @source_column_offset
352
+ )
353
+ end
354
+
355
+ def source_location_for_offset(source, offset)
356
+ prefix = source[0...offset]
357
+ line = prefix.count("\n") + 1
358
+ column = prefix.length - (prefix.rindex("\n") || -1)
359
+ SourceLocation.new(@module_id.to_s, line + @source_line_offset, column + @source_column_offset)
360
+ end
361
+
362
+ def absolute_pattern_for(path_pattern)
363
+ source_dir = @source_dir
364
+
365
+ if path_pattern.start_with?("/")
366
+ source_dir.join(path_pattern.delete_prefix("/"))
367
+ elsif path_pattern.start_with?(".")
368
+ source_dir.join(@module_id.dirname, path_pattern)
369
+ else
370
+ source_dir.join(path_pattern)
371
+ end.expand_path
372
+ end
373
+
374
+ def assert_pattern_inside_source_dir!(absolute_pattern)
375
+ pattern = absolute_pattern.to_s
376
+ source = @source_dir.to_s
377
+ return if pattern == source || pattern.start_with?("#{source}/")
378
+
379
+ raise DynamicImportError, "Glob import escapes source_dir: #{pattern}"
380
+ end
381
+
382
+ def specifier_for_glob_match(path_pattern, absolute_path)
383
+ relative_source_path = Pathname.new(absolute_path).relative_path_from(@source_dir).to_s
384
+ return "/#{relative_source_path}" if path_pattern.start_with?("/")
385
+ return relative_source_path unless path_pattern.start_with?(".")
386
+
387
+ importer_dir = @source_dir.join(@module_id.dirname)
388
+ relative = Pathname.new(absolute_path).relative_path_from(importer_dir).to_s
389
+ relative.start_with?(".") ? relative : "./#{relative}"
390
+ end
391
+
392
+ def glob_replacement_source(dependencies, eager:)
393
+ import_method = eager ? "__klenod_import__" : "__klenod_lazy_import__"
394
+ entries =
395
+ dependencies.map do |dependency|
396
+ key = dependency.metadata.fetch(:glob_key)
397
+ "#{key.inspect} => #{import_method}(#{dependency.id.inspect})"
398
+ end
399
+
400
+ "{#{entries.join(", ")}}"
401
+ end
402
+
403
+ def rewrite_import_calls(code, expanded)
404
+ expanded
405
+ .reverse_each
406
+ .each_with_object(code.dup) do |import, rewritten|
407
+ call = import.call
408
+ next rewritten if call.dynamic
409
+
410
+ location = call.location
411
+ original = code[location.start_char...location.end_char]
412
+ unless original.match?(/\A#{Regexp.escape(call.method_name)}\s*\(/)
413
+ raise DynamicImportError, "Could not safely rewrite import at #{location.start_line}:#{location.start_column}"
414
+ end
415
+
416
+ rewritten[location.start_char...location.end_char] = import.replacement_source
417
+ end
418
+ end
419
+
420
+ def walk(value, &block)
421
+ yield value
422
+
423
+ case value
424
+ when Array
425
+ value.each { |item| walk(item, &block) }
426
+ else
427
+ return unless value.respond_to?(:instance_variables)
428
+
429
+ value.instance_variables.each do |ivar|
430
+ walk(value.instance_variable_get(ivar), &block)
431
+ end
432
+ end
433
+ end
434
+ end
435
+ end
436
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "map"
4
+
5
+ module Klenod
6
+ module Build
7
+ module SourceMap
8
+ Edit = Data.define(:start_offset, :end_offset, :replacement) do
9
+ def self.replace(start_offset, end_offset, replacement)
10
+ new(start_offset, end_offset, replacement)
11
+ end
12
+
13
+ def self.delete(start_offset, end_offset)
14
+ new(start_offset, end_offset, "")
15
+ end
16
+ end
17
+
18
+ EditResult = Data.define(:code, :source_map)
19
+
20
+ class Editor
21
+ def initialize(code, source_map)
22
+ @code = code
23
+ @source_map = source_map
24
+ @line_starts = line_starts(code)
25
+ end
26
+
27
+ def apply(edits)
28
+ edits = edits.sort_by(&:start_offset)
29
+ validate_edits!(edits)
30
+ code = apply_code_edits(edits)
31
+ final_line_starts = line_starts(code)
32
+
33
+ segments =
34
+ @source_map.segments.filter_map do |segment|
35
+ offset = offset_for(segment.generated_line, segment.generated_column)
36
+ translated_offset = translate_offset(offset, edits)
37
+ next unless translated_offset
38
+
39
+ line, column = line_column_for(translated_offset, final_line_starts)
40
+ segment.with_generated(line, column)
41
+ end
42
+
43
+ EditResult.new(code, @source_map.with(segments: segments))
44
+ end
45
+
46
+ private
47
+
48
+ def validate_edits!(edits)
49
+ edits.each_cons(2) do |current, successor|
50
+ next if current.end_offset <= successor.start_offset
51
+
52
+ raise ArgumentError, "Source map edits must not overlap"
53
+ end
54
+ end
55
+
56
+ def apply_code_edits(edits)
57
+ result = +""
58
+ offset = 0
59
+
60
+ edits.each do |edit|
61
+ result << @code[offset...edit.start_offset]
62
+ result << edit.replacement
63
+ offset = edit.end_offset
64
+ end
65
+
66
+ result << @code[offset..]
67
+ result
68
+ end
69
+
70
+ def translate_offset(offset, edits)
71
+ delta = 0
72
+
73
+ edits.each do |edit|
74
+ return offset + delta if offset < edit.start_offset
75
+
76
+ if offset < edit.end_offset
77
+ return nil if edit.replacement.empty?
78
+ return edit.start_offset + delta if offset == edit.start_offset
79
+
80
+ return nil
81
+ end
82
+
83
+ delta += edit.replacement.length - (edit.end_offset - edit.start_offset)
84
+ end
85
+
86
+ offset + delta
87
+ end
88
+
89
+ def offset_for(line, column)
90
+ @line_starts.fetch(line) + column
91
+ end
92
+
93
+ def line_starts(code)
94
+ starts = [0]
95
+ code.each_char.with_index do |char, index|
96
+ starts << index + 1 if char == "\n"
97
+ end
98
+ starts
99
+ end
100
+
101
+ def line_column_for(offset, line_starts)
102
+ index = line_starts.bsearch_index { |line_start| line_start > offset }
103
+ line = index ? index - 1 : line_starts.length - 1
104
+
105
+ [line, offset - line_starts.fetch(line)]
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "vlq"
6
+
7
+ module Klenod
8
+ module Build
9
+ module SourceMap
10
+ Segment = Data.define(:generated_line, :generated_column, :source_index, :original_line, :original_column, :name_index) do
11
+ def with_generated(line, column)
12
+ self.class.new(line, column, source_index, original_line, original_column, name_index)
13
+ end
14
+
15
+ def with_original(line:, column:)
16
+ self.class.new(generated_line, generated_column, source_index, line, column, name_index)
17
+ end
18
+
19
+ def mapped?
20
+ source_index && original_line && original_column
21
+ end
22
+ end
23
+
24
+ class Map
25
+ attr_reader :version, :source_root, :sources, :sources_content, :names, :segments
26
+
27
+ def self.parse(json)
28
+ hash = JSON.parse(json)
29
+ new(
30
+ version: hash.fetch("version"),
31
+ source_root: hash["sourceRoot"],
32
+ sources: hash.fetch("sources"),
33
+ sources_content: hash["sourcesContent"],
34
+ names: hash.fetch("names", []),
35
+ segments: decode_mappings(hash.fetch("mappings"))
36
+ )
37
+ end
38
+
39
+ def self.decode_mappings(mappings)
40
+ segments = []
41
+ previous_source_index = 0
42
+ previous_original_line = 0
43
+ previous_original_column = 0
44
+ previous_name_index = 0
45
+
46
+ mappings.split(";", -1).each_with_index do |line, generated_line|
47
+ previous_generated_column = 0
48
+ next if line.empty?
49
+
50
+ line.split(",").each do |encoded_segment|
51
+ values = VLQ.decode(encoded_segment)
52
+ previous_generated_column += values.fetch(0)
53
+
54
+ if values.length == 1
55
+ segments << Segment.new(generated_line, previous_generated_column, nil, nil, nil, nil)
56
+ next
57
+ end
58
+
59
+ previous_source_index += values.fetch(1)
60
+ previous_original_line += values.fetch(2)
61
+ previous_original_column += values.fetch(3)
62
+ name_index =
63
+ if values.length > 4
64
+ previous_name_index += values.fetch(4)
65
+ end
66
+
67
+ segments << Segment.new(
68
+ generated_line,
69
+ previous_generated_column,
70
+ previous_source_index,
71
+ previous_original_line,
72
+ previous_original_column,
73
+ name_index
74
+ )
75
+ end
76
+ end
77
+
78
+ segments
79
+ end
80
+
81
+ def initialize(version:, source_root:, sources:, sources_content:, names:, segments:)
82
+ @version = version
83
+ @source_root = source_root
84
+ @sources = sources
85
+ @sources_content = sources_content
86
+ @names = names
87
+ @segments = segments
88
+ end
89
+
90
+ def to_json(*)
91
+ JSON.generate(to_h)
92
+ end
93
+
94
+ def to_h
95
+ {
96
+ "version" => version,
97
+ "sourceRoot" => source_root,
98
+ "mappings" => encode_mappings,
99
+ "sources" => sources,
100
+ "sourcesContent" => sources_content,
101
+ "names" => names
102
+ }.compact
103
+ end
104
+
105
+ def with(sources: @sources, sources_content: @sources_content, segments: @segments)
106
+ self.class.new(
107
+ version: version,
108
+ source_root: source_root,
109
+ sources: sources,
110
+ sources_content: sources_content,
111
+ names: names,
112
+ segments: segments
113
+ )
114
+ end
115
+
116
+ def map_original_lines(source_index:)
117
+ with(
118
+ segments: segments.map do |segment|
119
+ if segment.source_index == source_index
120
+ segment.with_original(line: yield(segment.original_line), column: segment.original_column)
121
+ else
122
+ segment
123
+ end
124
+ end
125
+ )
126
+ end
127
+
128
+ def encode_mappings
129
+ return "" if segments.empty?
130
+
131
+ grouped = segments.group_by(&:generated_line)
132
+ max_line = segments.map(&:generated_line).max
133
+ previous_source_index = 0
134
+ previous_original_line = 0
135
+ previous_original_column = 0
136
+ previous_name_index = 0
137
+
138
+ (0..max_line).map do |generated_line|
139
+ previous_generated_column = 0
140
+ (grouped[generated_line] || [])
141
+ .sort_by(&:generated_column)
142
+ .map do |segment|
143
+ values = [segment.generated_column - previous_generated_column]
144
+ previous_generated_column = segment.generated_column
145
+
146
+ if segment.mapped?
147
+ values << segment.source_index - previous_source_index
148
+ values << segment.original_line - previous_original_line
149
+ values << segment.original_column - previous_original_column
150
+ previous_source_index = segment.source_index
151
+ previous_original_line = segment.original_line
152
+ previous_original_column = segment.original_column
153
+
154
+ if segment.name_index
155
+ values << segment.name_index - previous_name_index
156
+ previous_name_index = segment.name_index
157
+ end
158
+ end
159
+
160
+ VLQ.encode(values)
161
+ end
162
+ .join(",")
163
+ end.join(";")
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end