klenod-build 0.0.14 → 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: af646580bbf44262cf5f2262b5fd81eb7a77eeb94498d7ae00e44383cc26ce42
4
- data.tar.gz: 6c84bd8dcc4d42be762dabd59bcb918d3aa42418772bd54f68b55e5cd0f3d17d
3
+ metadata.gz: abf589452fadde26fe16d0bbc326e0161e8ed8a719ea516e12e286ef8e1609aa
4
+ data.tar.gz: 9f54e971b5dc88cd07348be0d96e9885928db10c64b4228bfb64e350b202f669
5
5
  SHA512:
6
- metadata.gz: ce727e4c814158c472b04f3fed395faa53c812d9b6b397ea7bdf57f7c30305459fb057631021cdaf83979860f252d91f7c3d86ac2fde9518d92f983c284cb900
7
- data.tar.gz: 72bc4a860585664b9b9c210d89676c50feb0a594d10120d788516ef7cea0c735e983e25145aa89f022b42d6fee0df8064f6f69ac15b45e8c86bd78b55464f389
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,7 +24,7 @@ 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
 
@@ -189,9 +189,7 @@ module Klenod
189
189
  end
190
190
 
191
191
  def direct_dependents(module_id)
192
- records.filter_map do |candidate_id, record|
193
- candidate_id if record.resolved_dependencies.any? { |dependency| dependency.module_id == module_id }
194
- end
192
+ graph.dependents(module_id)
195
193
  end
196
194
 
197
195
  def mark_module_failed(module_id, error)
@@ -206,7 +204,8 @@ module Klenod
206
204
  source_hash = loaded_source.source_hash || Hashing.hexdigest(source)
207
205
  version = cached ? cached.version + 1 : 0
208
206
 
209
- records[module_id] =
207
+ graph.store_record(
208
+ module_id,
210
209
  ModuleRecord.new(
211
210
  module_id,
212
211
  source_hash,
@@ -222,6 +221,7 @@ module Klenod
222
221
  version,
223
222
  :failed
224
223
  )
224
+ )
225
225
  mods[module_id] = FailedModule.new(error)
226
226
  end
227
227
 
@@ -71,9 +71,11 @@ module Klenod
71
71
  asset_generation_concurrency: AssetGenerationQueue::DEFAULT_CONCURRENCY,
72
72
  asset_download_concurrency: AssetGenerationQueue::DEFAULT_DOWNLOAD_CONCURRENCY,
73
73
  profiler: nil,
74
- namespace: Module.new
74
+ namespace: Module.new,
75
+ analysis: false
75
76
  )
76
77
  @profiler = profiler || Profiler.new
78
+ @analysis = analysis
77
79
  @resolver = Resolver.new(source_dir: source_dir, profiler: @profiler)
78
80
  @plugins = plugins
79
81
  @mode = mode
@@ -90,6 +92,16 @@ module Klenod
90
92
  @virtual_metadata = {}
91
93
  @virtual_owners = {}
92
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
93
105
  end
94
106
 
95
107
  def load(specifier)
@@ -211,10 +223,89 @@ module Klenod
211
223
  end
212
224
  end
213
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
+
214
298
  def absolute_path(module_id)
215
299
  @resolver.absolute_path(module_id)
216
300
  end
217
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
+
218
309
  def source_dir
219
310
  @resolver.source_dir
220
311
  end
@@ -235,7 +326,7 @@ module Klenod
235
326
  @virtual_sources.delete(module_id)
236
327
  @virtual_metadata.delete(module_id)
237
328
  @virtual_owners.delete(module_id)
238
- @records.delete(module_id)
329
+ remove_record(module_id)
239
330
  @mods.delete(module_id)
240
331
  end
241
332
  end
@@ -290,7 +381,7 @@ module Klenod
290
381
  mod = instantiate_module(module_id, transform, resolved_dependencies, dependency_records, cached, source)
291
382
  record = build_module_record(module_id, source, source_hash, transformed_hash, transform, resolved_dependencies, mod)
292
383
 
293
- @records[module_id] = record
384
+ store_record(module_id, record)
294
385
  @mods[module_id] = mod
295
386
  record
296
387
  end
@@ -345,7 +436,7 @@ module Klenod
345
436
  transformed_hash = Hashing.hexdigest(transform.code)
346
437
  record = build_module_record(module_id, source, source_hash, transformed_hash, transform, resolved_dependencies, cached)
347
438
 
348
- @records[module_id] = record
439
+ store_record(module_id, record)
349
440
  @mods.delete(module_id)
350
441
  @profiler.progress(:collect_module, module_id: record.id.to_s, total_records: @records.length)
351
442
  record
@@ -851,6 +942,11 @@ module Klenod
851
942
  end
852
943
 
853
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
+
854
950
  @plugins.each do |plugin|
855
951
  @profiler.count(:plugin_load_check)
856
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
@@ -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
@@ -163,7 +163,9 @@ module Klenod
163
163
  return nil unless google_fonts_module?(module_id)
164
164
 
165
165
  url = google_fonts_url_for(module_id)
166
- css = fetch_css(url)
166
+ css = context.analysis? ? cached_css(url) : fetch_css(url)
167
+ return analysis_load(module_id, url, context) unless css
168
+
167
169
  font_faces = FontFaceParser.new(css).font_faces_by_url
168
170
  font_assets = {}
169
171
  rewritten_css =
@@ -220,6 +222,22 @@ module Klenod
220
222
  raise Error, "Could not download Google Fonts asset #{url.inspect}: #{error.message}"
221
223
  end
222
224
 
225
+ # Analysis never touches the network: a warm cache is used as is,
226
+ # and a cold one yields an empty stylesheet that keeps importers
227
+ # transforming.
228
+ def cached_css(url)
229
+ return nil unless @css_cache && !@refresh_cache
230
+
231
+ @css_cache.read(url)
232
+ end
233
+
234
+ def analysis_load(module_id, url, context)
235
+ css_asset = css_asset(module_id, url, "", [])
236
+ css_asset.url = context.asset_url(css_asset.output_path)
237
+ @assets_by_module_id[module_id] = [css_asset]
238
+ ruby_module_source(css_asset.url)
239
+ end
240
+
223
241
  def fetch_css(url)
224
242
  if @css_cache && !@refresh_cache
225
243
  css = @css_cache.read(url)
@@ -136,6 +136,8 @@ module Klenod
136
136
  end
137
137
  rescue RubyParseError => error
138
138
  raise ParseError.new(error, source: source, module_id: module_id)
139
+ rescue RubyImportRewriter::ParseError => error
140
+ raise ParseError.new(RubyParseError.new(error.message, line: error.line), source: source, module_id: module_id)
139
141
  ensure
140
142
  @profiler = previous_profiler
141
143
  @module_id = previous_module_id
@@ -78,6 +78,10 @@ module Klenod
78
78
  @transformer = Transformer.new
79
79
  end
80
80
 
81
+ # The validated `variables` mapping, so tooling such as the language
82
+ # server can tell whether `$name` reads a framework prop.
83
+ attr_reader :variables
84
+
81
85
  def resolve(dependency, _context)
82
86
  styles_dependency = resolve_class_names_runtime(dependency)
83
87
  return styles_dependency if styles_dependency
@@ -91,6 +91,8 @@ module Klenod
91
91
  return nil unless EXTENSIONS.include?(module_id.extname)
92
92
 
93
93
  source_path = context.absolute_path(module_id)
94
+ return analysis_load(module_id, source_path, context) if context.analysis?
95
+
94
96
  source_hash = Hashing.file_hexdigest(source_path)
95
97
  dimensions = image_dimensions(source_path, module_id)
96
98
  image_options = image_options_for(module_id)
@@ -158,6 +160,38 @@ module Klenod
158
160
  raise DecodeError.new(error, module_id: module_id)
159
161
  end
160
162
 
163
+ # Analysis keeps the module shape (dimensions, content type, a
164
+ # single asset reference) without hashing the file, decoding it, or
165
+ # describing variants nobody will generate.
166
+ def analysis_load(module_id, source_path, context)
167
+ source_hash = Hashing.file_key(source_path)
168
+ dimensions = image_dimensions(source_path, module_id)
169
+ asset = static_image_asset(module_id, source_path, source_hash, dimensions)
170
+ asset.url = context.asset_url(asset.output_path)
171
+ image_runtime_dependency = image_runtime_dependency_for(module_id)
172
+ transform =
173
+ TransformResult.new(
174
+ image_module_source(module_id, [asset], image_runtime_dependency, context, placeholder: nil),
175
+ [image_runtime_dependency],
176
+ nil,
177
+ [asset],
178
+ [],
179
+ {}
180
+ )
181
+
182
+ LoadResult.new(image_source(module_id), source_hash, transform)
183
+ end
184
+
185
+ def image_runtime_dependency_for(module_id)
186
+ Dependency
187
+ .create(
188
+ specifier: IMAGE_RUNTIME_SPECIFIER,
189
+ importer_id: module_id,
190
+ kind: :image_runtime
191
+ )
192
+ .with(id: "#{module_id}:image_runtime")
193
+ end
194
+
161
195
  def default_image_asset(module_id, source_path, source_hash, dimensions, image_options, queue)
162
196
  return static_image_asset(module_id, source_path, source_hash, dimensions) unless generated_default_image?(image_options)
163
197
 
@@ -26,6 +26,12 @@ module Klenod
26
26
  class Plugin < Klenod::Build::Plugin
27
27
  INTL_FILE_RE = /\.intl\.(?<locale>[^\/]+)\.toml\z/
28
28
 
29
+ CachedTranslations = Data.define(:mtime, :size, :translations)
30
+
31
+ def initialize
32
+ @cache = {}
33
+ end
34
+
29
35
  def translations_for(context, module_id)
30
36
  base = module_id.path.delete_suffix(module_id.extname)
31
37
  pattern = context.absolute_path(ModuleId.new("#{base}.intl.*.toml", nil)).to_s
@@ -39,18 +45,36 @@ module Klenod
39
45
  end
40
46
  end
41
47
 
48
+ # Changed or removed companions leave the cache immediately, so it
49
+ # only ever holds live files. Nothing else needs invalidating: the
50
+ # owning Haml module is re-transformed by HamlPlugin's own hook.
51
+ def invalidate_module_ids(paths, _context)
52
+ paths.each { |path| @cache.delete(File.expand_path(path)) }
53
+ []
54
+ end
55
+
42
56
  private
43
57
 
44
- # Read the file here rather than using TomlRB.load_file, so a parse
45
- # failure can carry the source for the excerpt.
58
+ # Parsed translations are kept per file and validated by stat, so a
59
+ # module that transforms often, as in the language server, does not
60
+ # re-read and re-parse companions that did not change. Read the file
61
+ # here rather than using TomlRB.load_file, so a parse failure can
62
+ # carry the source for the excerpt.
46
63
  def translations_from(path, module_id)
64
+ stat = File.stat(path)
65
+ cached = @cache[path]
66
+ return cached.translations if cached && cached.mtime == stat.mtime && cached.size == stat.size
67
+
47
68
  source = File.read(path)
69
+ translations =
70
+ begin
71
+ TomlRB.parse(source)
72
+ rescue TomlRB::Error => error
73
+ raise ParseError.new(error, source: source, module_id: companion_id(path, module_id))
74
+ end
48
75
 
49
- begin
50
- TomlRB.parse(source)
51
- rescue TomlRB::Error => error
52
- raise ParseError.new(error, source: source, module_id: companion_id(path, module_id))
53
- end
76
+ @cache[path] = CachedTranslations.new(stat.mtime, stat.size, translations)
77
+ translations
54
78
  end
55
79
 
56
80
  # The companion is not a module in the graph, but its id resolves to a
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../asset"
4
4
  require_relative "../hashing"
5
+ require_relative "../load_result"
5
6
  require_relative "../plugin"
6
7
  require_relative "../transform_result"
7
8
 
@@ -21,6 +22,13 @@ module Klenod
21
22
  ".woff2" => "font/woff2"
22
23
  }.freeze
23
24
 
25
+ # Analysis never needs the bytes of a font, so the file is not read.
26
+ def load(module_id, context)
27
+ return nil unless context.analysis? && CONTENT_TYPES.key?(module_id.extname)
28
+
29
+ LoadResult.new("", Hashing.short(module_id.to_s), TransformResult.new("Default = nil\n", [], nil, [], [], {}))
30
+ end
31
+
24
32
  def transform(module_id, code, context)
25
33
  return super unless CONTENT_TYPES.key?(module_id.extname)
26
34
 
@@ -64,6 +64,7 @@ module Klenod
64
64
  def clear_cache
65
65
  @resolved_module_ids.clear
66
66
  @absolute_paths.clear
67
+ @filesystem_resolver.clear_cache
67
68
  end
68
69
 
69
70
  private
@@ -39,6 +39,18 @@ module Klenod
39
39
  IMPORT_SOURCE_PATTERN = /(?<![A-Za-z0-9_])(?:import|lazy_import|import_glob)\s*(?:\(|["'])/
40
40
  FAST_LITERAL_IMPORT_PATTERN = /(?<![A-Za-z0-9_])(import|lazy_import)\s*\(\s*("(?:\\.|[^"\\#])*")\s*\)/
41
41
 
42
+ # Ruby that does not parse, reported at its line in the enclosing
43
+ # source. Haml wraps it into its own parse error with a source excerpt.
44
+ class ParseError < Klenod::Build::Error
45
+ attr_reader :line, :column
46
+
47
+ def initialize(message, line:, column:)
48
+ @line = line
49
+ @column = column
50
+ super(message)
51
+ end
52
+ end
53
+
42
54
  def initialize(module_id:, kind:, source_dir: nil, profiler: nil, dependency_id_offset: 0, source_line_offset: 0, source_column_offset: 0)
43
55
  @module_id = module_id
44
56
  @kind = kind
@@ -54,7 +66,12 @@ module Klenod
54
66
  fast_result = rewrite_literal_import_calls(code)
55
67
  return fast_result if fast_result
56
68
 
57
- ast = measure(:ruby_import_parse) { SyntaxTree.parse(code) }
69
+ ast =
70
+ begin
71
+ measure(:ruby_import_parse) { SyntaxTree.parse(code) }
72
+ rescue SyntaxTree::Parser::ParseError => error
73
+ raise ParseError.new(error.message, line: error.lineno + @source_line_offset, column: error.column + 1 + @source_column_offset)
74
+ end
58
75
  calls = measure(:ruby_import_scan) { import_calls(ast) }
59
76
  expanded = expand_import_calls(calls)
60
77
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Klenod
4
4
  module Build
5
- VERSION = "0.0.14"
5
+ VERSION = "0.0.15"
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.14
4
+ version: 0.0.15
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.14
18
+ version: 0.0.15
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.14
25
+ version: 0.0.15
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: async
28
28
  requirement: !ruby/object:Gem::Requirement