herb 0.9.7-arm-linux-gnu → 0.10.0-arm-linux-gnu

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/ext/herb/extconf.rb +1 -0
  4. data/ext/herb/extension.c +108 -0
  5. data/herb.gemspec +1 -1
  6. data/lib/herb/3.2/herb.so +0 -0
  7. data/lib/herb/3.3/herb.so +0 -0
  8. data/lib/herb/3.4/herb.so +0 -0
  9. data/lib/herb/4.0/herb.so +0 -0
  10. data/lib/herb/action_view/render_analyzer.rb +1057 -0
  11. data/lib/herb/ast/erb_render_node.rb +155 -0
  12. data/lib/herb/bootstrap.rb +0 -1
  13. data/lib/herb/cli.rb +253 -19
  14. data/lib/herb/colors.rb +18 -0
  15. data/lib/herb/configuration.rb +49 -13
  16. data/lib/herb/defaults.yml +3 -0
  17. data/lib/herb/dev/runner.rb +445 -0
  18. data/lib/herb/dev/server.rb +207 -0
  19. data/lib/herb/dev/server_entry.rb +128 -0
  20. data/lib/herb/diff_operation.rb +34 -0
  21. data/lib/herb/diff_result.rb +59 -0
  22. data/lib/herb/engine/compiler.rb +56 -3
  23. data/lib/herb/engine/validators/render_validator.rb +92 -0
  24. data/lib/herb/engine.rb +58 -4
  25. data/lib/herb/html/util.rb +16 -0
  26. data/lib/herb/project.rb +1 -6
  27. data/lib/herb/version.rb +1 -1
  28. data/lib/herb.rb +41 -5
  29. data/sig/herb/action_view/render_analyzer.rbs +122 -0
  30. data/sig/herb/ast/erb_render_node.rbs +29 -0
  31. data/sig/herb/colors.rbs +12 -0
  32. data/sig/herb/configuration.rbs +20 -1
  33. data/sig/herb/dev/runner.rbs +59 -0
  34. data/sig/herb/dev/server.rbs +50 -0
  35. data/sig/herb/dev/server_entry.rbs +51 -0
  36. data/sig/herb/diff_operation.rbs +34 -0
  37. data/sig/herb/diff_result.rbs +34 -0
  38. data/sig/herb/engine/compiler.rbs +6 -0
  39. data/sig/herb/engine/validators/render_validator.rbs +21 -0
  40. data/sig/herb/engine.rbs +15 -0
  41. data/sig/herb/html/util.rbs +13 -0
  42. data/sig/herb.rbs +12 -2
  43. data/sig/herb_c_extension.rbs +1 -1
  44. data/sig/vendor/did_you_mean.rbs +6 -0
  45. data/sig/vendor/parallel.rbs +4 -0
  46. data/src/analyze/action_view/attribute_extraction_helpers.c +3 -2
  47. data/src/diff/herb_diff.c +137 -0
  48. data/src/diff/herb_diff_attributes.c +207 -0
  49. data/src/diff/herb_diff_children.c +518 -0
  50. data/src/diff/herb_diff_helpers.c +114 -0
  51. data/src/diff/herb_diff_nodes.c +707 -0
  52. data/src/diff/herb_hash.c +42 -0
  53. data/src/diff/herb_hash_index_map.c +47 -0
  54. data/src/diff/herb_hash_map.c +104 -0
  55. data/src/diff/herb_hash_tree.c +680 -0
  56. data/src/include/diff/herb_diff.h +118 -0
  57. data/src/include/diff/herb_hash.h +25 -0
  58. data/src/include/diff/herb_hash_index_map.h +32 -0
  59. data/src/include/diff/herb_hash_map.h +30 -0
  60. data/src/include/herb.h +1 -0
  61. data/src/include/version.h +1 -1
  62. data/templates/javascript/packages/core/src/config.ts.erb +43 -0
  63. data/templates/rust/src/ast/nodes.rs.erb +1 -1
  64. data/templates/rust/src/config.rs.erb +50 -0
  65. data/templates/src/diff/herb_diff_helpers.c.erb +38 -0
  66. data/templates/src/diff/herb_diff_nodes.c.erb +224 -0
  67. data/templates/src/diff/herb_hash_tree.c.erb +147 -0
  68. data/templates/template.rb +4 -4
  69. metadata +40 -4
  70. data/lib/herb/3.0/herb.so +0 -0
  71. data/lib/herb/3.1/herb.so +0 -0
@@ -0,0 +1,1057 @@
1
+ # frozen_string_literal: true
2
+ # typed: false
3
+
4
+ require "pathname"
5
+
6
+ module Herb
7
+ module ActionView
8
+ class RenderAnalyzer
9
+ include Colors
10
+
11
+ Result = Data.define(:render_calls, :dynamic_calls, :partial_files, :unresolved, :unused, :view_root)
12
+
13
+ class Result
14
+ def issues?
15
+ unresolved.any? || unused.any?
16
+ end
17
+ end
18
+
19
+ attr_reader :project_path, :configuration
20
+
21
+ def initialize(project_path, configuration: nil)
22
+ @project_path = Pathname.new(File.expand_path(project_path))
23
+ @configuration = configuration || Configuration.load(@project_path.to_s)
24
+ end
25
+
26
+ def check!
27
+ start_time = Time.now
28
+
29
+ erb_files = find_erb_files
30
+ view_root = find_view_root
31
+
32
+ if erb_files.empty?
33
+ puts "No ERB files found."
34
+ return false
35
+ end
36
+
37
+ puts ""
38
+ puts "#{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}"
39
+ puts ""
40
+
41
+ if configuration.config_path
42
+ puts "#{green("\u2713")} Using Herb config file at #{dimmed(configuration.config_path.to_s)}"
43
+ else
44
+ puts dimmed("No .herb.yml found, using defaults")
45
+ end
46
+
47
+ puts dimmed("Checking render calls in #{erb_files.count} #{pluralize(erb_files.count, "file")}...")
48
+
49
+ result = analyze(erb_files, view_root)
50
+ duration = Time.now - start_time
51
+
52
+ print_results(result, duration)
53
+
54
+ result.issues?
55
+ end
56
+
57
+ def fully_resolvable?(file_path)
58
+ file_path = @project_path.join(file_path).to_s unless Pathname.new(file_path).absolute?
59
+
60
+ erb_files = find_erb_files
61
+ view_root = find_view_root
62
+
63
+ render_calls_by_file = collect_render_calls_by_file(erb_files)
64
+ partial_files = find_partial_files(view_root)
65
+
66
+ visited = Set.new
67
+ queue = [file_path]
68
+
69
+ while (current = queue.shift)
70
+ next if visited.include?(current)
71
+
72
+ visited << current
73
+
74
+ calls = render_calls_by_file[current] || []
75
+
76
+ calls.each do |call|
77
+ return false if call[:dynamic]
78
+
79
+ partial_ref = call[:partial] || call[:layout]
80
+ next unless partial_ref
81
+
82
+ return false if dynamic_partial?(partial_ref)
83
+
84
+ resolved = resolve_partial(partial_ref, current, partial_files, view_root)
85
+ return false unless resolved
86
+
87
+ queue << resolved
88
+ end
89
+ end
90
+
91
+ true
92
+ end
93
+
94
+ def graph_file!(file_path)
95
+ is_partial = File.basename(file_path).start_with?("_")
96
+
97
+ puts ""
98
+ puts " #{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}"
99
+ puts ""
100
+ puts " #{dimmed("Building render graph...")}"
101
+
102
+ erb_files = find_erb_files
103
+ view_root = find_view_root
104
+
105
+ if erb_files.empty?
106
+ puts "No ERB files found in project."
107
+ return
108
+ end
109
+
110
+ render_calls_by_file = collect_render_calls_by_file(erb_files)
111
+ ruby_partial_references = collect_ruby_render_references
112
+ partial_files = find_partial_files(view_root)
113
+ render_graph = build_render_graph(render_calls_by_file, partial_files, view_root)
114
+
115
+ all_render_calls = render_calls_by_file.values.flatten
116
+ _, dynamic_calls = partition_dynamic(all_render_calls)
117
+ dynamic_prefixes = collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references)
118
+ reachable = compute_reachable(render_graph, partial_files, ruby_partial_references, dynamic_prefixes)
119
+ reverse_graph = Hash.new { |hash, key| hash[key] = [] } #: Hash[String, Array[String]]
120
+
121
+ render_graph.each do |file, partial_names|
122
+ next if file.start_with?("__")
123
+
124
+ partial_names.each do |partial_name|
125
+ reverse_graph[partial_name] << file
126
+ end
127
+ end
128
+
129
+ ruby_partial_references.each do |reference|
130
+ next unless reference.is_a?(String)
131
+
132
+ reverse_graph[reference] << "__ruby__"
133
+ end
134
+
135
+ puts ""
136
+
137
+ if is_partial
138
+ partial_name = partial_name_for_file(file_path, view_root)
139
+
140
+ unless partial_name
141
+ puts "Could not determine partial name for: #{file_path}"
142
+ return
143
+ end
144
+
145
+ display = file_display_name(file_path, view_root)
146
+ status = reachable.include?(partial_name) ? green("\u2713") : yellow("~")
147
+
148
+ puts " #{status} #{bold(partial_name)} #{dimmed(display)}"
149
+ puts ""
150
+
151
+ callers = reverse_graph[partial_name]
152
+
153
+ if callers.any?
154
+ puts " #{bold("Rendered by:")}"
155
+
156
+ callers.each_with_index do |caller_file, index|
157
+ connector = index == callers.size - 1 ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500"
158
+
159
+ if caller_file == "__ruby__"
160
+ puts " #{connector} #{dimmed("[Ruby code]")}"
161
+ else
162
+ caller_display = file_display_name(caller_file, view_root)
163
+ caller_basename = File.basename(caller_file)
164
+ caller_status = caller_basename.start_with?("_") ? dimmed("(partial)") : dimmed("(entry point)")
165
+ puts " #{connector} #{cyan(caller_display)} #{caller_status}"
166
+ end
167
+ end
168
+
169
+ puts ""
170
+ puts " #{bold("Reachable from:")}"
171
+ entry_chains = trace_to_entry_points(partial_name, reverse_graph, partial_files, view_root)
172
+
173
+ if entry_chains.any?
174
+ entry_chains.each_with_index do |chain, index|
175
+ connector = index == entry_chains.size - 1 ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500"
176
+ reversed = chain.reverse
177
+
178
+ chain_display = reversed.each_with_index.map do |name, i|
179
+ if i == 0
180
+ bold(green(name))
181
+ elsif i == reversed.size - 1
182
+ bold(name)
183
+ else
184
+ dimmed(name)
185
+ end
186
+ end.join(dimmed(" \u2192 "))
187
+ puts " #{connector} #{chain_display}"
188
+ end
189
+ else
190
+ puts " #{yellow("(not reachable from any entry point)")}"
191
+ end
192
+ else
193
+ puts " #{dimmed("Not rendered by any file.")}"
194
+ end
195
+
196
+ children = render_graph[file_path] || []
197
+
198
+ if children.any?
199
+ puts ""
200
+ puts " #{bold("Renders:")}"
201
+ print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new)
202
+ end
203
+ else
204
+ display = file_display_name(file_path, view_root)
205
+ puts " #{cyan(display)} #{dimmed("(entry point)")}"
206
+ puts ""
207
+
208
+ children = render_graph[file_path] || []
209
+
210
+ if children.any?
211
+ puts " #{bold("Renders:")}"
212
+ print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new)
213
+ else
214
+ puts " #{dimmed("No render calls in this file.")}"
215
+ end
216
+ end
217
+
218
+ puts ""
219
+ end
220
+
221
+ def graph!
222
+ erb_files = find_erb_files
223
+ view_root = find_view_root
224
+
225
+ if erb_files.empty?
226
+ puts "No ERB files found."
227
+ return
228
+ end
229
+
230
+ puts ""
231
+ puts "#{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}"
232
+ puts ""
233
+
234
+ if configuration.config_path
235
+ puts "#{green("\u2713")} Using Herb config file at #{dimmed(configuration.config_path.to_s)}"
236
+ else
237
+ puts dimmed("No .herb.yml found, using defaults")
238
+ end
239
+
240
+ puts dimmed("Building render graph for #{erb_files.count} #{pluralize(erb_files.count, "file")}...")
241
+
242
+ render_calls_by_file = collect_render_calls_by_file(erb_files)
243
+ ruby_partial_references = collect_ruby_render_references
244
+ partial_files = find_partial_files(view_root)
245
+ render_graph = build_render_graph(render_calls_by_file, partial_files, view_root)
246
+
247
+ all_render_calls = render_calls_by_file.values.flatten
248
+ _, dynamic_calls = partition_dynamic(all_render_calls)
249
+ dynamic_prefixes = collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references)
250
+ reverse_graph = Hash.new { |hash, key| hash[key] = [] } #: Hash[String, Array[String]]
251
+
252
+ render_graph.each do |file, partial_names|
253
+ next if file.start_with?("__")
254
+
255
+ display_name = file_display_name(file, view_root)
256
+
257
+ partial_names.each do |partial_name|
258
+ reverse_graph[partial_name] << display_name
259
+ end
260
+ end
261
+
262
+ ruby_partial_references.each do |reference|
263
+ next unless reference.is_a?(String)
264
+
265
+ reverse_graph[reference] << "#{dimmed("[Ruby]")} #{reference}"
266
+ end
267
+
268
+ entry_points = render_graph.keys.reject { |file|
269
+ file.start_with?("__") || File.basename(file).start_with?("_")
270
+ }.sort
271
+
272
+ reachable = compute_reachable(render_graph, partial_files, ruby_partial_references, dynamic_prefixes)
273
+
274
+ puts ""
275
+ puts separator
276
+ puts ""
277
+
278
+ if entry_points.any?
279
+ puts " #{bold("Entry points:")} #{dimmed("(#{entry_points.count} #{pluralize(entry_points.count, "template")})")}"
280
+
281
+ entry_points.each do |file|
282
+ display = file_display_name(file, view_root)
283
+ partials = render_graph[file] || []
284
+
285
+ puts ""
286
+ puts " #{cyan(display)}"
287
+
288
+ if partials.empty?
289
+ puts " #{dimmed("(no render calls)")}"
290
+ else
291
+ print_partial_tree(partials, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new)
292
+ end
293
+ end
294
+ end
295
+
296
+ ruby_static_references = ruby_partial_references.select { |reference| reference.is_a?(String) }
297
+
298
+ if ruby_static_references.any?
299
+ puts ""
300
+ puts " #{separator}"
301
+ puts ""
302
+ puts " #{bold("Ruby references:")} #{dimmed("(#{ruby_static_references.count} #{pluralize(ruby_static_references.count, "partial")})")}"
303
+
304
+ ruby_static_references.sort.each do |reference|
305
+ resolved = partial_files[reference]
306
+ status = resolved ? green("\u2713") : red("\u2717")
307
+ puts ""
308
+ puts " #{status} #{bold(reference)}"
309
+
310
+ if resolved
311
+ children = render_graph[resolved] || []
312
+ print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new)
313
+ end
314
+ end
315
+ end
316
+
317
+ unreachable = partial_files.except(*reachable)
318
+
319
+ puts ""
320
+ puts " #{separator}"
321
+ puts ""
322
+ puts " #{bold("Partial usage:")} #{dimmed("(who renders each partial)")}"
323
+
324
+ partial_files.keys.sort.each do |name|
325
+ callers = reverse_graph[name]
326
+ status = reachable.include?(name) ? green("\u2713") : yellow("~")
327
+
328
+ puts ""
329
+ puts " #{status} #{bold(name)}"
330
+
331
+ if callers.any?
332
+ callers.sort.each_with_index do |caller_name, index|
333
+ connector = index == callers.size - 1 ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500"
334
+ puts " #{connector} #{dimmed("rendered by")} #{caller_name}"
335
+ end
336
+ else
337
+ puts " #{dimmed("(not rendered by any file)")}"
338
+ end
339
+ end
340
+
341
+ if unreachable.any?
342
+ puts ""
343
+ puts " #{separator}"
344
+ puts ""
345
+ puts " #{bold(yellow("Unreachable partials:"))} #{dimmed("(#{unreachable.count} #{pluralize(unreachable.count, "file")})")}"
346
+
347
+ unreachable.each do |name, file|
348
+ display = file_display_name(file, view_root)
349
+ children = render_graph[file] || []
350
+
351
+ puts ""
352
+ puts " #{yellow("~")} #{bold(name)} #{dimmed(display)}"
353
+
354
+ if children.any?
355
+ print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: " ", visited: Set.new)
356
+ end
357
+ end
358
+ end
359
+
360
+ puts ""
361
+ puts " #{separator}"
362
+ puts ""
363
+ puts " #{bold("Summary:")}"
364
+ puts " #{label("Entry points")} #{cyan(entry_points.count.to_s)}"
365
+ puts " #{label("Partials")} #{cyan(partial_files.count.to_s)}"
366
+ puts " #{label("Reachable")} #{bold(green(reachable.count.to_s))}"
367
+ puts " #{label("Unreachable")} #{unreachable.any? ? bold(yellow(unreachable.count.to_s)) : bold(green("0"))}"
368
+ puts ""
369
+ end
370
+
371
+ def analyze(erb_files = nil, view_root = nil)
372
+ erb_files ||= find_erb_files
373
+ view_root ||= find_view_root
374
+
375
+ render_calls_by_file = collect_render_calls_by_file(erb_files)
376
+ ruby_partial_references = collect_ruby_render_references
377
+ partial_files = find_partial_files(view_root)
378
+
379
+ all_render_calls = render_calls_by_file.values.flatten
380
+ static_calls, dynamic_calls = partition_dynamic(all_render_calls)
381
+
382
+ render_graph = build_render_graph(render_calls_by_file, partial_files, view_root)
383
+
384
+ unresolved = find_unresolved(static_calls, partial_files, view_root)
385
+
386
+ unused = find_unused_by_reachability(
387
+ render_graph, partial_files, ruby_partial_references,
388
+ collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references),
389
+ view_root
390
+ )
391
+
392
+ Result.new(
393
+ render_calls: all_render_calls,
394
+ dynamic_calls: dynamic_calls,
395
+ partial_files: partial_files,
396
+ unresolved: unresolved,
397
+ unused: unused,
398
+ view_root: view_root
399
+ )
400
+ end
401
+
402
+ def analyze_from_collected(render_calls_by_file:, dynamic_prefixes_from_erb: [], layout_refs_from_erb: [])
403
+ view_root = find_view_root
404
+
405
+ @dynamic_prefixes_from_erb = dynamic_prefixes_from_erb
406
+ @layout_refs_from_erb = layout_refs_from_erb
407
+
408
+ ruby_partial_references = collect_ruby_render_references
409
+ partial_files = find_partial_files(view_root)
410
+
411
+ all_render_calls = render_calls_by_file.values.flatten
412
+ static_calls, dynamic_calls = partition_dynamic(all_render_calls)
413
+
414
+ render_graph = build_render_graph(render_calls_by_file, partial_files, view_root)
415
+
416
+ unresolved = find_unresolved(static_calls, partial_files, view_root)
417
+
418
+ unused = find_unused_by_reachability(
419
+ render_graph, partial_files, ruby_partial_references,
420
+ collect_all_dynamic_prefixes(dynamic_calls, ruby_partial_references),
421
+ view_root
422
+ )
423
+
424
+ Result.new(
425
+ render_calls: all_render_calls,
426
+ dynamic_calls: dynamic_calls,
427
+ partial_files: partial_files,
428
+ unresolved: unresolved,
429
+ unused: unused,
430
+ view_root: view_root
431
+ )
432
+ end
433
+
434
+ def print_file_lists(result)
435
+ return unless result.issues?
436
+
437
+ if result.unresolved.any?
438
+ puts "\n"
439
+ puts " #{bold("Unresolved render calls:")}"
440
+ puts " #{dimmed("These render calls reference partials that could not be found on disk.")}"
441
+
442
+ grouped = result.unresolved.group_by { |call| call[:file] }
443
+
444
+ grouped.each do |file, calls|
445
+ relative = relative_path(file)
446
+
447
+ puts ""
448
+ puts " #{cyan(relative)}:"
449
+
450
+ calls.each do |call|
451
+ location = call[:location] ? dimmed("at #{call[:location]}") : nil
452
+ expected = expected_file_path(call[:partial], result.view_root)
453
+ puts " #{red("\u2717")} #{bold(call[:partial])} #{location} #{dimmed("-")} #{dimmed(expected)}"
454
+ end
455
+ end
456
+ end
457
+
458
+ return unless result.unused.any?
459
+
460
+ puts "\n #{separator}" if result.unresolved.any?
461
+ puts "\n"
462
+ puts " #{bold("Unused partials:")}"
463
+ puts " #{dimmed("These partial files are not referenced by any reachable render call.")}"
464
+
465
+ result.unused.each do |name, file|
466
+ relative = relative_path(file)
467
+
468
+ puts ""
469
+ puts " #{cyan(relative)}:"
470
+ puts " #{yellow("~")} #{bold(name)} #{dimmed("not referenced")}"
471
+ end
472
+ end
473
+
474
+ def print_issue_summary(result)
475
+ return unless result.issues?
476
+
477
+ if result.unresolved.any?
478
+ files_count = result.unresolved.map { |call| call[:file] }.uniq.count
479
+
480
+ puts " #{white("Unresolved partials")} #{dimmed("(#{result.unresolved.count} #{pluralize(result.unresolved.count, "reference")} in #{files_count} #{pluralize(files_count, "file")})")}"
481
+ end
482
+
483
+ return unless result.unused.any?
484
+
485
+ puts " #{white("Unused partials")} #{dimmed("(#{result.unused.count} #{pluralize(result.unused.count, "file")})")}"
486
+ end
487
+
488
+ def print_summary_line(result)
489
+ render_parts = [] #: Array[String]
490
+
491
+ partials_only = result.render_calls.count { |call| call[:partial] }
492
+ render_parts << stat(result.render_calls.count, "total", :green)
493
+ render_parts << stat(partials_only, "with partial", :green)
494
+ render_parts << stat(result.dynamic_calls.count, "dynamic", :yellow) if result.dynamic_calls.any?
495
+ other_count = result.render_calls.count - partials_only
496
+ render_parts << stat(other_count, "other", :green) if other_count.positive?
497
+
498
+ partial_parts = [] #: Array[String]
499
+ partial_parts << stat(result.partial_files.count, "on disk", :green)
500
+ partial_parts << stat(result.unresolved.count, "unresolved", :red) if result.unresolved.any?
501
+ partial_parts << stat(result.unused.count, "unused", :yellow) if result.unused.any?
502
+
503
+ puts " #{label("Renders")} #{render_parts.join(" | ")}"
504
+ puts " #{label("Partials")} #{partial_parts.join(" | ")}"
505
+ end
506
+
507
+ private
508
+
509
+ def print_partial_tree(partial_names, render_graph, partial_files, view_root, reachable, indent: "", visited: Set.new)
510
+ partial_names.each_with_index do |name, index|
511
+ is_last = index == partial_names.size - 1
512
+ connector = is_last ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500"
513
+ child_indent = is_last ? " " : "\u2502 "
514
+
515
+ resolved_file = partial_files[name]
516
+ status = if !resolved_file
517
+ red("\u2717")
518
+ elsif reachable.include?(name)
519
+ green("\u2713")
520
+ else
521
+ yellow("~")
522
+ end
523
+
524
+ puts "#{indent}#{connector} #{status} #{name}"
525
+
526
+ next unless resolved_file
527
+ next if visited.include?(name)
528
+
529
+ visited.add(name)
530
+
531
+ children = render_graph[resolved_file] || []
532
+
533
+ if children.any?
534
+ print_partial_tree(children, render_graph, partial_files, view_root, reachable, indent: "#{indent}#{child_indent}", visited: visited)
535
+ end
536
+ end
537
+ end
538
+
539
+ def file_display_name(file, view_root)
540
+ Pathname.new(file).relative_path_from(view_root).to_s
541
+ rescue ArgumentError
542
+ relative_path(file)
543
+ end
544
+
545
+ def compute_reachable(render_graph, partial_files, ruby_references, dynamic_prefixes)
546
+ reachable = Set.new
547
+ queue = [] #: Array[String]
548
+
549
+ render_graph.each_key do |file|
550
+ next if file.start_with?("__")
551
+
552
+ basename = File.basename(file)
553
+ next if basename.start_with?("_")
554
+
555
+ queue << file
556
+ end
557
+
558
+ ruby_references.each do |reference|
559
+ next unless reference.is_a?(String)
560
+
561
+ reachable << reference
562
+ resolved_file = partial_files[reference]
563
+ queue << resolved_file if resolved_file
564
+ end
565
+
566
+ (render_graph["__layout_refs__"] || []).each do |layout_name|
567
+ reachable << layout_name
568
+ resolved_file = partial_files[layout_name]
569
+ queue << resolved_file if resolved_file
570
+ end
571
+
572
+ visited_files = Set.new
573
+
574
+ until queue.empty?
575
+ current_file = queue.shift
576
+ next if visited_files.include?(current_file)
577
+
578
+ visited_files << current_file
579
+
580
+ partial_names = render_graph[current_file] || []
581
+
582
+ partial_names.each do |partial_name|
583
+ next if reachable.include?(partial_name)
584
+
585
+ reachable << partial_name
586
+
587
+ resolved_file = partial_files[partial_name]
588
+ queue << resolved_file if resolved_file && render_graph.key?(resolved_file)
589
+ end
590
+ end
591
+
592
+ partial_files.each_key do |name|
593
+ if dynamic_prefixes.any? { |prefix| name.start_with?("#{prefix}/") }
594
+ reachable << name
595
+ end
596
+ end
597
+
598
+ reachable
599
+ end
600
+
601
+ def trace_to_entry_points(partial_name, reverse_graph, _partial_files, view_root)
602
+ chains = [] #: Array[Array[String]]
603
+ queue = [[partial_name]]
604
+ visited = Set.new([partial_name])
605
+
606
+ until queue.empty?
607
+ current_chain = queue.shift
608
+
609
+ current_name = current_chain.last
610
+ callers = reverse_graph[current_name] || []
611
+
612
+ callers.each do |caller_file|
613
+ next if caller_file == "__ruby__"
614
+
615
+ caller_basename = File.basename(caller_file)
616
+
617
+ if caller_basename.start_with?("_")
618
+ caller_name = partial_name_for_file(caller_file, view_root)
619
+ next unless caller_name
620
+ next if visited.include?(caller_name)
621
+
622
+ visited.add(caller_name)
623
+ queue << (current_chain + [caller_name])
624
+ else
625
+ entry_display = file_display_name(caller_file, view_root)
626
+ chains << (current_chain + [entry_display])
627
+ end
628
+ end
629
+
630
+ if callers.include?("__ruby__")
631
+ chains << (current_chain + ["[Ruby code]"])
632
+ end
633
+ end
634
+
635
+ chains
636
+ end
637
+
638
+ def print_results(result, duration)
639
+ if result.issues?
640
+ puts ""
641
+ puts separator
642
+ end
643
+
644
+ print_file_lists(result)
645
+
646
+ if result.issues?
647
+ puts "\n #{separator}"
648
+ puts "\n"
649
+ puts " #{bold("Issue summary:")}"
650
+ print_issue_summary(result)
651
+ end
652
+
653
+ puts "\n #{separator}"
654
+
655
+ scanned_files = result.render_calls.map { |call| call[:file] }.uniq.count
656
+ issues = result.unresolved.count + result.unused.count
657
+
658
+ puts "\n"
659
+ puts " #{bold("Summary:")}"
660
+
661
+ puts " #{label("Version")} #{cyan(Herb.version)}"
662
+ puts " #{label("Checked")} #{cyan("#{scanned_files} #{pluralize(scanned_files, "file")}")}"
663
+
664
+ print_summary_line(result)
665
+
666
+ puts " #{label("Duration")} #{cyan(format_duration(duration))}"
667
+
668
+ if issues.zero?
669
+ puts ""
670
+ puts " #{bold(green("\u2713"))} #{green("All render calls resolve and all partials are used!")}"
671
+ end
672
+
673
+ puts ""
674
+ end
675
+
676
+ def find_erb_files
677
+ patterns = configuration.file_include_patterns
678
+ exclude = configuration.file_exclude_patterns
679
+
680
+ files = patterns.flat_map { |pattern| Dir[File.join(@project_path, pattern)] }.uniq
681
+
682
+ files.reject do |file|
683
+ relative = Pathname.new(file).relative_path_from(@project_path).to_s
684
+ exclude.any? { |pattern| File.fnmatch?(pattern, relative, File::FNM_PATHNAME) }
685
+ end.sort
686
+ end
687
+
688
+ def find_view_root
689
+ candidates = [
690
+ @project_path.join("app", "views"),
691
+ @project_path
692
+ ]
693
+
694
+ candidates.find(&:directory?) || @project_path
695
+ end
696
+
697
+ def find_partial_files(view_root)
698
+ return {} unless view_root.directory?
699
+
700
+ partials = {} #: Hash[String, String]
701
+
702
+ Dir[File.join(view_root, "**", Herb::PARTIAL_GLOB_PATTERN)].each do |file|
703
+ partial_name = partial_name_for_file(file, view_root)
704
+ partials[partial_name] = file if partial_name
705
+ end
706
+
707
+ partials
708
+ end
709
+
710
+ def partial_name_for_file(file_path, view_root)
711
+ relative = Pathname.new(file_path).relative_path_from(view_root).to_s
712
+
713
+ directory = File.dirname(relative)
714
+ basename = File.basename(relative)
715
+
716
+ return nil unless basename.start_with?("_")
717
+
718
+ name = basename.sub(/\A_/, "").sub(/\..*\z/, "")
719
+
720
+ if directory == "."
721
+ name
722
+ else
723
+ "#{directory}/#{name}"
724
+ end
725
+ end
726
+
727
+ def collect_render_calls_by_file(files)
728
+ @dynamic_prefixes_from_erb = [] #: Array[String]
729
+ @layout_refs_from_erb = [] #: Array[String]
730
+
731
+ ensure_parallel!
732
+
733
+ file_results = Parallel.map(files, in_processes: Parallel.processor_count) do |file|
734
+ process_file_for_render_calls(file)
735
+ end
736
+
737
+ render_calls_by_file = {} #: Hash[String, Array[Hash[Symbol, untyped]]]
738
+
739
+ file_results.each do |file_result|
740
+ next unless file_result
741
+
742
+ render_calls_by_file[file_result[:file]] = file_result[:calls]
743
+ @dynamic_prefixes_from_erb.concat(file_result[:dynamic_prefixes])
744
+ @layout_refs_from_erb.concat(file_result[:layout_references])
745
+ end
746
+
747
+ render_calls_by_file
748
+ end
749
+
750
+ def process_file_for_render_calls(file)
751
+ content = File.read(file)
752
+ result = Herb.parse(content, render_nodes: true)
753
+
754
+ visitor = RenderCallVisitor.new(file)
755
+ visitor.visit(result.value)
756
+ calls = visitor.render_calls.dup
757
+
758
+ visitor_partials = calls.filter_map { |call| call[:partial] }.to_set
759
+
760
+ dynamic_prefixes = [] #: Array[untyped]
761
+ layout_references = [] #: Array[untyped]
762
+
763
+ content.scan(%r{render[\s(]+(?:partial:\s*)?["']([a-z0-9_/]+)["']}) do |match|
764
+ partial = match[0]
765
+ next if visitor_partials.include?(partial)
766
+
767
+ calls << { file: file, partial: partial }
768
+ end
769
+
770
+ content.scan(%r{render\s+(?:partial:\s*)?["']([a-z0-9_/]+)/\#\{}) do |match|
771
+ dynamic_prefixes << match[0]
772
+ end
773
+
774
+ content.scan(%r{render\s+layout:\s*["']([a-z0-9_/]+)["']}) do |match|
775
+ layout_references << match[0]
776
+ end
777
+
778
+ { file: file, calls: calls, dynamic_prefixes: dynamic_prefixes, layout_references: layout_references }
779
+ rescue StandardError => e
780
+ warn "Warning: Could not parse #{file}: #{e.message}"
781
+ nil
782
+ end
783
+
784
+ def ensure_parallel!
785
+ return if defined?(Parallel)
786
+
787
+ Herb.ensure_installed { gem "parallel" } # steep:ignore
788
+ end
789
+
790
+ def collect_ruby_render_references
791
+ references = [] #: Array[untyped]
792
+
793
+ ruby_directories = [
794
+ @project_path.join("app"),
795
+ @project_path.join("lib")
796
+ ]
797
+
798
+ ruby_directories.each do |directory|
799
+ next unless directory.directory?
800
+
801
+ Dir[File.join(directory, "**", "*.rb")].each do |file|
802
+ content = File.read(file)
803
+
804
+ content.scan(%r{(?:render\s+(?:partial:\s*)?|(?:self\.)?partial(?:\s*[:=]\s*|\s*=\s*))["']([a-z0-9_/]+)["']}) do |match|
805
+ references << match[0]
806
+ end
807
+
808
+ content.scan(%r{(?:render\s+(?:partial:\s*)?|(?:self\.)?partial(?:\s*[:=]\s*|\s*=\s*))["']([a-z0-9_/]+)/\#\{}) do |match|
809
+ references << { prefix: match[0] }
810
+ end
811
+ rescue StandardError
812
+ next
813
+ end
814
+ end
815
+
816
+ references
817
+ end
818
+
819
+ def build_render_graph(render_calls_by_file, partial_files, view_root)
820
+ graph = {} #: Hash[String, Array[String]]
821
+
822
+ render_calls_by_file.each do |file, calls|
823
+ resolved_names = [] #: Array[String]
824
+
825
+ calls.each do |call|
826
+ partial_reference = call[:partial] || call[:layout]
827
+ next unless partial_reference
828
+
829
+ resolved = resolve_partial(partial_reference, file, partial_files, view_root)
830
+ if resolved
831
+ resolved_name = partial_name_for_file(resolved, view_root)
832
+ resolved_names << resolved_name if resolved_name
833
+ else
834
+ resolved_names << partial_reference
835
+ end
836
+ end
837
+
838
+ graph[file] = resolved_names.uniq
839
+ end
840
+
841
+ (@layout_refs_from_erb || []).each do |layout_reference|
842
+ graph["__layout_refs__"] ||= []
843
+ graph["__layout_refs__"] << layout_reference
844
+ end
845
+
846
+ graph
847
+ end
848
+
849
+ def find_unused_by_reachability(render_graph, partial_files, ruby_references, dynamic_prefixes, _view_root)
850
+ reachable = Set.new
851
+ queue = [] #: Array[String]
852
+
853
+ render_graph.each_key do |file|
854
+ next if file.start_with?("__")
855
+
856
+ basename = File.basename(file)
857
+ next if basename.start_with?("_")
858
+
859
+ queue << file
860
+ end
861
+
862
+ ruby_references.each do |reference|
863
+ next unless reference.is_a?(String)
864
+
865
+ reachable << reference
866
+ resolved_file = partial_files[reference]
867
+
868
+ queue << resolved_file if resolved_file
869
+ end
870
+
871
+ (render_graph["__layout_refs__"] || []).each do |layout_name|
872
+ reachable << layout_name
873
+ resolved_file = partial_files[layout_name]
874
+ queue << resolved_file if resolved_file
875
+ end
876
+
877
+ visited_files = Set.new
878
+
879
+ until queue.empty?
880
+ current_file = queue.shift
881
+ next if visited_files.include?(current_file)
882
+
883
+ visited_files << current_file
884
+
885
+ partial_names = render_graph[current_file] || []
886
+
887
+ partial_names.each do |partial_name|
888
+ next if reachable.include?(partial_name)
889
+
890
+ reachable << partial_name
891
+
892
+ resolved_file = partial_files[partial_name]
893
+ queue << resolved_file if resolved_file && render_graph.key?(resolved_file)
894
+ end
895
+ end
896
+
897
+ partial_files.each_key do |name|
898
+ if dynamic_prefixes.any? { |prefix| name.start_with?("#{prefix}/") }
899
+ reachable << name
900
+ end
901
+ end
902
+
903
+ partial_files.except(*reachable)
904
+ end
905
+
906
+ def partition_dynamic(render_calls)
907
+ static = [] #: Array[Hash[Symbol, untyped]]
908
+ dynamic = [] #: Array[Hash[Symbol, untyped]]
909
+
910
+ render_calls.each do |call|
911
+ if call[:partial] && dynamic_partial?(call[:partial])
912
+ dynamic << call
913
+ else
914
+ static << call
915
+ end
916
+ end
917
+
918
+ [static, dynamic]
919
+ end
920
+
921
+ def dynamic_partial?(partial_name)
922
+ partial_name.include?("\#{") || partial_name.include?("#\{") || partial_name.match?(%r{[^a-z0-9_/]})
923
+ end
924
+
925
+ def collect_all_dynamic_prefixes(dynamic_calls, ruby_references)
926
+ prefixes = dynamic_calls.filter_map { |call|
927
+ next unless call[:partial]
928
+
929
+ prefix = call[:partial].gsub(/\A["']|["']\z/, "")
930
+ prefix = prefix.split("\#{").first&.chomp("/")
931
+ prefix unless prefix.nil? || prefix.empty?
932
+ }
933
+
934
+ ruby_references.each do |reference|
935
+ prefixes << reference[:prefix] if reference.is_a?(Hash) && reference[:prefix]
936
+ end
937
+
938
+ prefixes.concat(@dynamic_prefixes_from_erb || [])
939
+ prefixes.uniq
940
+ end
941
+
942
+ def find_unresolved(render_calls, partial_files, view_root)
943
+ render_calls.select do |call|
944
+ next false unless call[:partial]
945
+
946
+ !resolve_partial(call[:partial], call[:file], partial_files, view_root)
947
+ end
948
+ end
949
+
950
+ def resolve_partial(partial_name, source_file, partial_files, view_root)
951
+ return partial_files[partial_name] if partial_files.key?(partial_name)
952
+
953
+ source_directory = begin
954
+ Pathname.new(File.dirname(source_file)).relative_path_from(view_root).to_s
955
+ rescue ArgumentError
956
+ nil
957
+ end
958
+
959
+ if source_directory && source_directory != "."
960
+ relative_name = "#{source_directory}/#{partial_name}"
961
+ return partial_files[relative_name] if partial_files.key?(relative_name)
962
+ end
963
+
964
+ unless partial_name.include?("/")
965
+ application_name = "application/#{partial_name}"
966
+ return partial_files[application_name] if partial_files.key?(application_name)
967
+ end
968
+
969
+ nil
970
+ end
971
+
972
+ def expected_file_path(partial_name, view_root)
973
+ parts = partial_name.split("/")
974
+ parts[-1] = "_#{parts[-1]}"
975
+ relative = parts.join("/")
976
+
977
+ relative_root = relative_path(view_root.to_s)
978
+
979
+ Herb::PARTIAL_EXTENSIONS.map { |extension| "#{relative_root}/#{relative}#{extension}" }.join(", ")
980
+ end
981
+
982
+ def label(text, width = 12)
983
+ dimmed(text.ljust(width))
984
+ end
985
+
986
+ def stat(count, text, color)
987
+ value = "#{count} #{text}"
988
+
989
+ if count.positive?
990
+ bold(send(color, value))
991
+ else
992
+ bold(green(value))
993
+ end
994
+ end
995
+
996
+ def separator
997
+ dimmed("\u2500" * 60)
998
+ end
999
+
1000
+ def pluralize(count, singular, plural = nil)
1001
+ count == 1 ? singular : (plural || "#{singular}s")
1002
+ end
1003
+
1004
+ def format_duration(seconds)
1005
+ if seconds < 1
1006
+ "#{(seconds * 1000).round(2)}ms"
1007
+ elsif seconds < 60
1008
+ "#{seconds.round(2)}s"
1009
+ else
1010
+ minutes = (seconds / 60).to_i
1011
+ remaining_seconds = seconds % 60
1012
+
1013
+ "#{minutes}m #{remaining_seconds.round(2)}s"
1014
+ end
1015
+ end
1016
+
1017
+ def relative_path(path)
1018
+ Pathname.new(path).relative_path_from(Pathname.pwd).to_s
1019
+ rescue ArgumentError
1020
+ path.to_s
1021
+ end
1022
+ end
1023
+
1024
+ class RenderCallVisitor < Visitor
1025
+ attr_reader :render_calls
1026
+
1027
+ def initialize(file)
1028
+ @file = file
1029
+ @render_calls = []
1030
+ end
1031
+
1032
+ def visit_erb_render_node(node)
1033
+ call = { file: @file }
1034
+
1035
+ call[:partial] = node.partial_path if node.static_partial?
1036
+ call[:template_path] = node.template_name if node.template_name
1037
+ call[:layout] = node.layout_name if node.layout_name
1038
+ call[:file_path] = node.keywords&.file&.value if node.keywords&.file
1039
+ call[:inline] = true if node.keywords&.inline_template
1040
+ call[:renderable] = node.keywords&.renderable&.value if node.keywords&.renderable
1041
+ call[:dynamic] = true if node.dynamic?
1042
+
1043
+ call[:body] = true if node.keywords&.body
1044
+ call[:plain] = true if node.keywords&.plain
1045
+ call[:html] = true if node.keywords&.html
1046
+
1047
+ if node.location
1048
+ call[:location] = "#{node.location.start.line}:#{node.location.start.column}"
1049
+ end
1050
+
1051
+ @render_calls << call
1052
+
1053
+ super
1054
+ end
1055
+ end
1056
+ end
1057
+ end