glyphs 0.2.1 → 0.2.2

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: dffbe50e2d6b992be18142abb575887e4b11ee8b26ad2afec2a3d94f0ed418cd
4
- data.tar.gz: 59e4a5c55154ae1075f0c4cf0c7a3b2a1f767fc12416dd48b85d39b0bbed119a
3
+ metadata.gz: 245c7101b5dad69bf73859eea7a5d7c9027f1573d4e1b4ccbcde2bed3e61266f
4
+ data.tar.gz: 585c169414705979b1c45bf02ad87de324c5e6a16fb6a8ddb0e631961820e533
5
5
  SHA512:
6
- metadata.gz: ef3a208721a23a5ab7272de80644228046b5f5bc42834d4f2f5f59f7da78bf141775cf6f8a192243abdcfdebdc444eab3bb87df33409fa429cc4571c76c4e0e5
7
- data.tar.gz: cdc2a32800aa6c3a503dbccd0c8ee524e120fe1d418ba417bcf905f00e283fcc56c24be29dfec7f2dc4e0a6c57b268d2cb2577d9f5cc9b9b201483a6c0c93f2b
6
+ metadata.gz: c5dfa80d58dbf2cc918f7a0ea2b74937156356137abca9c7297f0a974c1ee079176f335b6a5d33599f81f9d6af8f8efe324d9ab841f52126750bc80fb01abb38
7
+ data.tar.gz: 4a6b23ac1e990068f529e2ad7ed54fa078391256894d59b8a2390b5a9e773fd26a0dc55b11dcd3bfb00e13548791e80bcf1a5659bdd2ca82d26a5db1495893f1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+
7
+ - **Dynamic icon calls are now resolved from source.** `SourceScanner` no longer
8
+ silently skips `LucideIcon(some_var)` / `PhosphorIcon(tile[:icon])` — it harvests
9
+ the literal name from two places so the pruner keeps it:
10
+ - _file-scoped_: a file that dynamically renders a library keeps every
11
+ icon-name-shaped literal in that file for that library (ternaries, `case`,
12
+ locals);
13
+ - _declaration-based_: literals in icon-declaration positions anywhere (a hash
14
+ pair keyed `/icon/i`, or a constant named `/ICON/`) are kept for every
15
+ dynamically-rendered library, closing the cross-file gap (e.g. a notifier
16
+ `ICON = :bell` constant rendered from a view).
17
+
18
+ This makes `keep_icons` a last-resort escape hatch (DB/ENV/gem-chrome names)
19
+ rather than the primary mechanism. Only literals are harvested, so the scanner
20
+ never invents a reference.
21
+
3
22
  ## [0.2.0] - 2026-07-07
4
23
 
5
24
  ### Changed
data/README.md CHANGED
@@ -114,14 +114,41 @@ set for development.
114
114
  It scans your source (`app/**/*.rb`, `lib/**/*.rb` with a real parser, plus
115
115
  `.erb/.haml/.slim` text) for `LucideIcon(:house)`, legacy helpers, generic
116
116
  `Icon(:x, library: :lucide)`, and `iconify lucide--house` class strings, then
117
- keeps that set **plus** two safety nets: the `keep_icons` allowlist (for
118
- dynamic/data-driven names a static scan can't see) and every configured
119
- `fallback_icons` (a pruned fallback would 500 on the next missing icon).
117
+ keeps that set **plus** two safety nets: the `keep_icons` allowlist and every
118
+ configured `fallback_icons` (a pruned fallback would 500 on the next missing
119
+ icon).
120
+
121
+ ### Dynamic calls are resolved automatically
122
+
123
+ Most apps render most icons dynamically — `LucideIcon(tile[:icon])`,
124
+ `PhosphorIcon(notification.icon)` — where the name lives in a `{ icon: :activity }`
125
+ hash, a `CHANNEL_ICONS` map, or an `ICON = :bell` constant, sometimes in a
126
+ different file from the render. A naïve static scan can't read those names and
127
+ would prune the icons they use.
128
+
129
+ The scanner resolves them from source, so you rarely need `keep_icons` at all:
130
+
131
+ - **File-scoped** — a file that renders a library dynamically (`LucideIcon(x)`)
132
+ keeps every icon-name-shaped literal in that file for that library. Catches
133
+ ternaries (`@open ? "caret-up" : "caret-down"`), `case/when`, and locals.
134
+ - **Declaration-based** — literals in icon-declaration positions *anywhere* — a
135
+ hash pair keyed like an icon (`icon: :gear`, `menu_icon: "house"`) or a
136
+ constant named like one (`ICON = :bell`, `STATUS_ICONS = { .. => :warning }`) —
137
+ are kept for every dynamically-rendered library, so a name declared in one
138
+ file and rendered from another survives.
139
+
140
+ Only literals are harvested, so the scanner never invents a reference; the
141
+ worst case is keeping a coincidentally icon-named string, which the post-prune
142
+ verification tolerates.
143
+
144
+ ### `keep_icons` — the last-resort escape hatch
145
+
146
+ For names a static scan genuinely can't see — read from a database, an ENV var,
147
+ or a gem's own chrome — list them explicitly:
120
148
 
121
149
  ```ruby
122
150
  # config/initializers/glyphs.rb
123
151
  Glyphs.configure do |config|
124
- # Icons referenced dynamically (names from config, a DB, or a gem's chrome).
125
152
  # Flat list or per-library hash; names or fnmatch globs.
126
153
  config.keep_icons = %w[menu palette search circle-*]
127
154
  # config.keep_icons = { lucide: %w[menu palette], phosphor: %w[lock] }
@@ -47,14 +47,47 @@ module Glyphs
47
47
  @pruner ||= IconPruner.new(
48
48
  icons_root: @icons_root,
49
49
  references:,
50
- keep_icons: config.keep_icons,
50
+ keep_icons: keep_icons,
51
51
  fallback_icons: config.fallback_icons,
52
52
  dry_run: @dry_run
53
53
  )
54
54
  end
55
55
 
56
+ def scanner
57
+ @scanner ||= SourceScanner.new(root: @root, **scanner_options)
58
+ end
59
+
56
60
  def references
57
- @references ||= SourceScanner.new(root: @root, **scanner_options).call
61
+ @references ||= scanner.call
62
+ end
63
+
64
+ # The pruner's keep-set: the configured keep_icons MERGED with the scanner's
65
+ # advisory per-library dynamic keeps (names harvested from dynamic call
66
+ # sites). Both are advisory — kept if present, never asserted by verify!.
67
+ #
68
+ # With no dynamic keeps, the configured value passes straight through (flat
69
+ # list or hash — the pruner accepts both). Otherwise everything folds into a
70
+ # per-library hash; a flat configured list applies to every kept library.
71
+ def keep_icons
72
+ dynamic = scanner.dynamic_keeps
73
+ return config.keep_icons if dynamic.empty?
74
+
75
+ merged = Hash.new { |hash, key| hash[key] = [] }
76
+ dynamic.each { |library, names| merged[library].concat(names.to_a) }
77
+ merge_configured_keeps(merged)
78
+ merged.transform_values(&:uniq)
79
+ end
80
+
81
+ # Folds the configured keep_icons into the per-library `merged` hash: a hash
82
+ # merges per library; a flat list applies to every library already present.
83
+ def merge_configured_keeps(merged)
84
+ case config.keep_icons
85
+ when Hash
86
+ config.keep_icons.each { |library, names| merged[library.to_sym].concat(Array(names)) }
87
+ else
88
+ flat = Array(config.keep_icons)
89
+ merged.each_key { |library| merged[library].concat(flat) }
90
+ end
58
91
  end
59
92
 
60
93
  def scanner_options
@@ -50,15 +50,38 @@ module Glyphs
50
50
  @extra_globs = Array(extra_globs)
51
51
  end
52
52
 
53
+ # Confirmed references: icons named by a LITERAL in an icon call (or an
54
+ # iconify string). These are guaranteed-real — the pruner keeps them AND the
55
+ # runner asserts every one still resolves on disk after a prune.
53
56
  def call
54
- references = Set.new
55
- ruby_files.each { |path| scan_ruby(path, references) }
56
- (template_files - ruby_files).each { |path| scan_template(path, references) }
57
- references
57
+ scan
58
+ @references
59
+ end
60
+
61
+ # Advisory per-library keeps harvested from DYNAMIC call sites (see
62
+ # DynamicHarvest). `{ library => Set[names] }`. These may include literals
63
+ # that aren't real icons (a CSS class, a method name), so — like the
64
+ # configured keep_icons — they are kept if present but NEVER asserted by
65
+ # verification. Feed them into the pruner's keep path, not the runner's
66
+ # expected-files set.
67
+ def dynamic_keeps
68
+ scan
69
+ @dynamic_keeps
58
70
  end
59
71
 
60
72
  private
61
73
 
74
+ def scan
75
+ return if @scanned
76
+
77
+ @references = Set.new
78
+ harvest = DynamicHarvest.new
79
+ ruby_files.each { |path| scan_ruby(path, @references, harvest) }
80
+ (template_files - ruby_files).each { |path| scan_template(path, @references) }
81
+ @dynamic_keeps = harvest.to_h
82
+ @scanned = true
83
+ end
84
+
62
85
  attr_reader :root
63
86
 
64
87
  def ruby_files
@@ -73,9 +96,11 @@ module Glyphs
73
96
  patterns.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }.uniq
74
97
  end
75
98
 
76
- def scan_ruby(path, references)
99
+ def scan_ruby(path, references, harvest)
77
100
  result = Prism.parse_file(path)
78
- CallVisitor.new(references).visit(result.value)
101
+ visitor = CallVisitor.new(references)
102
+ visitor.visit(result.value)
103
+ harvest.absorb(visitor)
79
104
  rescue StandardError => e
80
105
  warn "[Glyphs::SourceScanner] skipped #{path}: #{e.class}: #{e.message}"
81
106
  end
@@ -127,12 +152,71 @@ module Glyphs
127
152
  end
128
153
  end
129
154
 
155
+ # Accumulates the evidence needed to resolve DYNAMIC icon calls — ones whose
156
+ # name the AST can't read (`LucideIcon(tile[:icon])`) — across every scanned
157
+ # Ruby file, then turns that evidence into IconReferences.
158
+ #
159
+ # Two sources of candidate names, both harvested only from literals so we
160
+ # never invent a reference:
161
+ #
162
+ # 1. file-scoped — a file that dynamically renders library L contributes
163
+ # every icon-name-shaped literal in that file as a candidate for L.
164
+ # Catches ternaries, case/when, and locals near the call.
165
+ # 2. declaration-based — literals in icon-declaration positions ANYWHERE
166
+ # (a hash pair keyed `/icon/i`, or a constant named `/ICON/`) are
167
+ # candidates for EVERY dynamically-rendered library, since a bare
168
+ # `ICON = :bell` in one file is often rendered from another.
169
+ #
170
+ # Names resolve at the library's default variant: a dynamic call names no
171
+ # variant in practice, and the pruner keeps per (library, variant).
172
+ class DynamicHarvest
173
+ def initialize
174
+ @dynamic_libraries = Set.new
175
+ @file_literals = Hash.new { |hash, key| hash[key] = Set.new }
176
+ @declaration_literals = Set.new
177
+ end
178
+
179
+ # Folds one file's visitor results into the running accumulator.
180
+ def absorb(visitor)
181
+ @declaration_literals.merge(visitor.declaration_literals)
182
+ return if visitor.dynamic_libraries.empty?
183
+
184
+ @dynamic_libraries.merge(visitor.dynamic_libraries)
185
+ visitor.dynamic_libraries.each do |library|
186
+ @file_literals[library].merge(visitor.file_literals)
187
+ end
188
+ end
189
+
190
+ # `{ library => Set[candidate names] }` for every dynamically-rendered
191
+ # library. Advisory: names may not be real icons, so callers keep them but
192
+ # must not assert they exist.
193
+ def to_h
194
+ @dynamic_libraries.to_h do |library|
195
+ [library, @file_literals[library] | @declaration_literals]
196
+ end
197
+ end
198
+ end
199
+
130
200
  # Walks the Prism AST collecting icon references from call nodes.
131
201
  class CallVisitor < Prism::Visitor
132
202
  GENERIC_CALLS = %i[Icon icon].freeze
133
203
 
204
+ # A literal that could be an icon name: lowercase, digits, dashes/underscores.
205
+ # Excludes CSS classes (spaces/slashes), paths, and interpolated strings.
206
+ ICON_NAME_LITERAL = /\A[a-z][a-z0-9_-]*\z/
207
+
208
+ # Libraries this file renders with a dynamic (non-literal) first argument.
209
+ attr_reader :dynamic_libraries
210
+ # Icon-name-shaped literals anywhere in this file (file-scoped candidates).
211
+ attr_reader :file_literals
212
+ # Literals in icon-declaration positions (`icon:` pairs, `ICON` constants).
213
+ attr_reader :declaration_literals
214
+
134
215
  def initialize(references)
135
216
  @references = references
217
+ @dynamic_libraries = Set.new
218
+ @file_literals = Set.new
219
+ @declaration_literals = Set.new
136
220
  super()
137
221
  end
138
222
 
@@ -146,6 +230,27 @@ module Glyphs
146
230
  library = library.to_sym
147
231
  references << IconReference.new(library:, variant: IconReference.default_variant_for(library), name:)
148
232
  end
233
+ collect_literal(@file_literals, node)
234
+ super
235
+ end
236
+
237
+ def visit_symbol_node(node)
238
+ collect_literal(@file_literals, node)
239
+ super
240
+ end
241
+
242
+ # `ICON = :bell` / `CHANNEL_ICONS = { .. => :whatsapp_logo }` — a constant
243
+ # named like an icon is a declaration of icon name(s), wherever it lives.
244
+ def visit_constant_write_node(node)
245
+ collect_declaration(node.value) if node.name.to_s.match?(/ICON/i)
246
+ super
247
+ end
248
+
249
+ # `icon: :activity` / `menu_icon: "gear"` — a hash pair keyed like an icon
250
+ # declares an icon name, wherever it lives.
251
+ def visit_assoc_node(node)
252
+ key = node.key
253
+ collect_declaration(node.value) if key.is_a?(Prism::SymbolNode) && key.unescaped.match?(/icon/i)
149
254
  super
150
255
  end
151
256
 
@@ -164,7 +269,13 @@ module Glyphs
164
269
 
165
270
  def record_component(node, library)
166
271
  name = literal_string(first_argument(node))
167
- return unless name
272
+ # A dynamic first arg (variable, method call, hash lookup) can't be read
273
+ # off the call — flag the library so the harvest resolves its name from
274
+ # file-scoped and declaration literals instead.
275
+ unless name
276
+ @dynamic_libraries << library if first_argument(node)
277
+ return
278
+ end
168
279
 
169
280
  variant = variant_for(node, library)
170
281
  return if variant == :dynamic
@@ -222,6 +333,29 @@ module Glyphs
222
333
  def literal_symbol_or_string(value_node)
223
334
  value_node.unescaped if value_node.is_a?(Prism::SymbolNode) || value_node.is_a?(Prism::StringNode)
224
335
  end
336
+
337
+ # Adds a single symbol/string literal to `set` when it's shaped like an
338
+ # icon name (dasherized). Interpolated strings (StringNode with no static
339
+ # `unescaped`) and non-name-shaped literals are ignored.
340
+ def collect_literal(set, node)
341
+ raw = node.unescaped
342
+ return unless raw.is_a?(String) && raw.match?(ICON_NAME_LITERAL)
343
+
344
+ set << raw.tr("_", "-")
345
+ end
346
+
347
+ # Harvests icon names from a declaration value: a bare literal, an array of
348
+ # literals (`ICON = %i[a b]`), or a hash's values (`{ "sms" => :device }`).
349
+ def collect_declaration(value_node)
350
+ case value_node
351
+ when Prism::SymbolNode, Prism::StringNode
352
+ collect_literal(@declaration_literals, value_node)
353
+ when Prism::ArrayNode
354
+ value_node.elements.each { |element| collect_declaration(element) }
355
+ when Prism::HashNode
356
+ value_node.elements.grep(Prism::AssocNode).each { |assoc| collect_declaration(assoc.value) }
357
+ end
358
+ end
225
359
  end
226
360
  end
227
361
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Glyphs
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glyphs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson