keela 0.4.1 → 0.5.0

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: 1b1c9fad8b959b91c3af1518da72d36f9f82bd649b4c21a6a0cf261246cbbcdb
4
- data.tar.gz: 24c7b946587cb844ae9c08c921ffecacfb8b262404b2c177f0368d803cc52861
3
+ metadata.gz: 0755e73eaeeb61f9f97ffa109fd8c421316e7725b83ef2502a37c5ba0111858f
4
+ data.tar.gz: d66e1092dd578f28ae3d727b179b601fcd7068671550f9186e53c76a543d1763
5
5
  SHA512:
6
- metadata.gz: e0fcd131e331240b852f7d657113aa0e9610aa4a7dd465b78576478c11abdeca5cd4140c9037cbbd378f53e23e4137d6a255eeb8d28950f0f82f6ad0eeabc7fd
7
- data.tar.gz: 7d71ea4b1a2c601f0aba8901fbe31f95ba055eb784614213afa386321e096bb2364ecd832b60356f08c8cc42ed2e07e9bdcfc4c3a794fe218931834ac7df7c39
6
+ metadata.gz: bf117842ed4d8891671dac92ee34c48b5433f50795f9a90963211c1e650df11b31e72c2d142a6fc489f3dfb040989341764ab6eba4d2ea70432828bd6ed1c1dc
7
+ data.tar.gz: ac2d7352393d70240f7ad1541f2ff476e5ed7c8def8b13212721a527df4e7c285b2c31519333ebe712aab31e9a186a997420598e7e7662af181353cf9f8b96ca
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-21
11
+
12
+ ### Added
13
+
14
+ - **`partials` strategy**, which detects unused Rails view partials (`_*.html.{erb,haml,slim}`) in `app/views` and `ee/app/views`. It resolves explicit renders (`render "users/form"`, `render partial:`/`layout:`), relative bareword renders against the calling view or controller directory, `render_to_string`/`render_to_body`, and positional underscore paths (`render_to_string("shared/notes/_note")`). Opt-in via `--type partials` (not in the default set) while its false-positive rate is validated. Collection renders (`render @users`) and dynamic renders are documented limitations ([#82](https://github.com/kerrizor/keela/pull/82))
15
+ - **`partials` strategy: configurable custom render-helper allowlist.** App-specific helpers that render a partial from an explicit string path (e.g. GitLab's `view_to_html_string`, `tabs_json`) can be registered under `strategies.partials.render_helpers`, so they are recognized as usages instead of false-flagging the partial as unused. Configured helpers are matched only for explicit paths (positional path strings, positional underscore paths, and `partial:`/`layout:` keys), never for bareword-relative resolution, which stays limited to `render`/`render_to_string`/`render_to_body`. Default behavior is unchanged when unset ([#87](https://github.com/kerrizor/keela/pull/87))
16
+ - `Keela::Source`, which holds the concatenated source and its case-folded view
17
+ - `Strategy#used?` and `Strategy#prepare`, so a strategy can give a cheaper equivalent of its `usage_regex`. `Strategy#usage_regex` is unchanged, so existing custom strategies keep working
18
+
19
+ ### Changed
20
+
21
+ - CI now tests against Ruby 3.3, 3.4, and 4.0; dropped end-of-life Ruby 3.1 and 3.2. Minimum required Ruby is now 3.3.0
22
+ - **Usage detection is faster.** On a 47.5 MB codebase of 24,783 files and 42,053 definitions, a full run went from 41s to 18.5s of wall time, and from 438s to 130s of CPU time. The report is unchanged
23
+ - **The `attributes` strategy checks its two patterns separately.** It matched one pattern built as an alternation of a bare reference and an `@ivar` reference. An alternation has no single mandatory literal, so the regexp engine cannot fast-forward to a candidate position and scans the whole source instead. Each pattern now keeps its literal
24
+ - **The `delegations` strategy folds the source instead of matching case-insensitively.** Only three ASCII fold targets have a non-ASCII source: U+017F folds to `s`, U+212A folds to `k`, and the eszett folds to `ss`. So once the scanned source is not entirely 7-bit ASCII, a `//i` pattern whose literal begins with `s` or `k` can no longer be found with a plain byte search, and takes 100x longer or worse. A single non-ASCII character in any scanned file is enough to put a whole run on that path, and method names beginning with `s` are common, so delegations such as `squash_never?` and `sentry_issue` each cost about a second. The source is now case-folded once per run and matched with a case-sensitive pattern. It falls back to the case-insensitive pattern when folding would not give the same answer, which only happens with non-ASCII source, for example a character that folds to two characters, or a non-ASCII character that folds into ASCII
25
+
26
+ ### Fixed
27
+
28
+ - **`partials` strategy: a render inside a single-line ERB comment no longer counts as a usage.** A commented-out `<%# render "users/old_form" %>` (including the trim variants `<%-# ... %>` and `<%# ... -%>`) previously marked the partial used, hiding effectively dead partials. Single-line ERB comment tags are now stripped before render-matching in both the explicit-path and bareword/positional-underscore paths. Multi-line ERB comments and HAML comments remain out of scope and are still counted ([#88](https://github.com/kerrizor/keela/pull/88))
29
+ - Usage matches can no longer span a file boundary. The concatenated source joined files with no separator, so a file whose last line lacked a trailing newline glued onto the next file's first line, letting a match exist across a gap that is in no single file. This silently marked some unused code as used. Each file is now newline-terminated before concatenation, affecting every strategy ([#86](https://github.com/kerrizor/keela/pull/86))
30
+ - Unused entries are no longer listed more than once per file in reports and baselines when a single definition is extracted from multiple lines (e.g. the same method delegated twice, or a constant declared and referenced) ([#72](https://github.com/kerrizor/keela/pull/72))
31
+
10
32
  ## [0.4.1] - 2026-08-21
11
33
 
12
34
  ### Fixed
data/README.md CHANGED
@@ -2,17 +2,17 @@
2
2
 
3
3
  **Your Ruby codebase's forensic investigator.**
4
4
 
5
- Like her namesake the famous springer spaniel who helped solve cases by finding microscopic traces that eluded forensic teams Keela sniffs out the dead code that `grep` missed.
5
+ Like her namesake (the famous springer spaniel who helped solve cases by finding microscopic traces that eluded forensic teams), Keela sniffs out the dead code that `grep` missed.
6
6
 
7
7
  ```
8
- 🔍 6 strategies: methods, scopes, constants, delegates, attrs, i18n
9
- 🎯 Baseline mode only bark at NEW dead code
8
+ 🔍 7 strategies: methods, scopes, constants, delegates, attrs, i18n, partials
9
+ 🎯 Baseline mode: only bark at NEW dead code
10
10
  📊 JSON output for CI pipelines
11
11
  ```
12
12
 
13
13
  ## Why Remove Unused Code?
14
14
 
15
- Dead code isn't harmless — it's actively costly:
15
+ Dead code isn't harmless. It's actively costly:
16
16
 
17
17
  - **Cognitive overhead**: Developers read and try to understand code that doesn't matter, slowing down onboarding and feature work
18
18
  - **CI minutes**: Tests for unused methods still run, burning compute time on every pipeline
@@ -20,7 +20,7 @@ Dead code isn't harmless — it's actively costly:
20
20
  - **Refactoring friction**: Unused code creates dependencies that make refactoring harder ("wait, is this called somewhere?")
21
21
  - **Security surface**: More code means more potential vulnerabilities, even in paths users never hit
22
22
 
23
- Most codebases accumulate dead code gradually a feature flag that's always on, a method replaced but never deleted, a scope that lost its last caller. Keela helps you find it and clean it up.
23
+ Most codebases accumulate dead code gradually: a feature flag that's always on, a method replaced but never deleted, a scope that lost its last caller. Keela helps you find it and clean it up.
24
24
 
25
25
  ## Installation
26
26
 
@@ -97,18 +97,18 @@ keela --report
97
97
 
98
98
  ## What "Unused" Means
99
99
 
100
- Keela defines "unused" as **unused in production code** not just unused anywhere.
100
+ Keela defines "unused" as **unused in production code**, not just unused anywhere.
101
101
 
102
102
  By default, Keela scans `app/`, `lib/`, and `config/` directories. It intentionally
103
103
  excludes `spec/` and `test/` directories because:
104
104
 
105
- 1. **Tests aren't usage** Code that only exists to be tested isn't providing
105
+ 1. **Tests aren't usage.** Code that only exists to be tested isn't providing
106
106
  application value. If you delete the unused code, you delete its tests too.
107
107
 
108
- 2. **Tests can cover dead code** A method with 100% test coverage can still be
108
+ 2. **Tests can cover dead code.** A method with 100% test coverage can still be
109
109
  dead if nothing in the application calls it.
110
110
 
111
- 3. **Cleaner signal** Including test files would hide genuinely unused code
111
+ 3. **Cleaner signal.** Including test files would hide genuinely unused code
112
112
  behind "but it has tests!" false negatives.
113
113
 
114
114
  If Keela flags something that's only used in tests, consider whether the code
@@ -131,6 +131,10 @@ keela
131
131
  keela --type methods
132
132
  keela --type scopes
133
133
 
134
+ # Opt-in strategies (not in the default run)
135
+ keela --type i18n_keys
136
+ keela --type partials
137
+
134
138
  # Combine multiple types
135
139
  keela --type methods,scopes,constants
136
140
 
@@ -190,10 +194,11 @@ Keela detects several types of unused code:
190
194
  | **delegations** | Unused delegate declarations | `delegate :unused, to: :target` |
191
195
  | **attributes** | Unused attr_* declarations | `attr_accessor :unused_attr` |
192
196
  | **i18n_keys** | Unused translation keys | `en.users.unused_key` in locale YAML |
197
+ | **partials** | Unused Rails view partials | `app/views/users/_unused.html.erb` |
193
198
 
194
199
  Run all strategies (default) or target specific ones with `--type`.
195
200
 
196
- **Note:** The `i18n_keys` strategy is not included in `--type all` because it requires scanning YAML locale files. Run it explicitly with `--type i18n_keys`.
201
+ **Note:** The `i18n_keys` and `partials` strategies are not included in `--type all`. Run them explicitly with `--type i18n_keys` or `--type partials`.
197
202
 
198
203
  ### I18n Keys (Beta)
199
204
 
@@ -208,9 +213,22 @@ The following patterns ARE now supported:
208
213
 
209
214
  Review results carefully and use the exclusion file for known false positives.
210
215
 
216
+ ### Partials (Beta)
217
+
218
+ The `partials` strategy is **beta** and may produce false positives. It detects unused Rails view partials (`_*.html.{erb,haml,slim}`) in `app/views` and `ee/app/views`, and resolves usage through `render` (and `render_to_string`/`render_to_body`) calls in views and controllers: explicit paths (`render "users/form"`, `render partial:`/`layout:`), relative bareword renders against the calling file's directory, and positional underscore paths (`render_to_string("shared/notes/_note")`).
219
+
220
+ It cannot detect:
221
+
222
+ - **Collection/object rendering** - `render @users` infers the partial from the object's class at runtime, which static analysis cannot resolve
223
+ - **Dynamic rendering** - `render partial_name` where the partial name is a variable
224
+
225
+ **Custom render helpers** (project-specific methods that render a partial from a string path, e.g. `view_to_html_string`) are not recognized by default, but you can register them with the `render_helpers` option so their partials are not false-flagged. See [Customizing Render Helpers Per Strategy](#customizing-render-helpers-per-strategy).
226
+
227
+ Partials only reached via the undetectable patterns above may be reported as unused. Review results carefully and use the exclusion file (or baseline) for known false positives.
228
+
211
229
  ## Limitations
212
230
 
213
- Keela uses static analysis it reads your code without executing it. This means some patterns are **fundamentally undetectable**.
231
+ Keela uses static analysis: it reads your code without executing it. This means some patterns are **fundamentally undetectable**.
214
232
 
215
233
  ### What Keela CAN Detect
216
234
 
@@ -233,17 +251,17 @@ respond_to?(:optional_method)
233
251
  **Dynamic dispatch** with interpolation or variables cannot be analyzed statically:
234
252
 
235
253
  ```ruby
236
- # ❌ Interpolated symbols what method does this call?
254
+ # ❌ Interpolated symbols: what method does this call?
237
255
  public_send(:"add_#{role}", user)
238
256
 
239
- # ❌ Variable method names could be anything
257
+ # ❌ Variable method names: could be anything
240
258
  send(method_name)
241
259
 
242
260
  # ❌ Computed method names
243
261
  define_method(compute_name) { }
244
262
  ```
245
263
 
246
- To detect these, Keela would need to trace all possible runtime values essentially becoming a Ruby interpreter. This is not a bug; it's a fundamental limitation of static analysis.
264
+ To detect these, Keela would need to trace all possible runtime values, essentially becoming a Ruby interpreter. This is not a bug; it's a fundamental limitation of static analysis.
247
265
 
248
266
  ### Handling False Positives
249
267
 
@@ -313,6 +331,23 @@ strategies:
313
331
 
314
332
  This is useful when your project has code in non-standard locations (like `lib/` or enterprise edition directories) that you want Keela to check for unused definitions.
315
333
 
334
+ ### Customizing Render Helpers Per Strategy
335
+
336
+ The `partials` strategy recognizes `render`, `render_to_string`, and `render_to_body` when detecting partial usage. If your project renders partials through custom helper methods that take a string path, register them under `render_helpers` so their partials are not reported as unused:
337
+
338
+ ```yaml
339
+ # keela.yml
340
+ strategies:
341
+ partials:
342
+ render_helpers:
343
+ - view_to_html_string
344
+ - tabs_json
345
+ ```
346
+
347
+ With the above, a call like `view_to_html_string("shared/notes/_note")` marks `shared/notes/note` as used.
348
+
349
+ Configured helpers are matched for **explicit paths only** (positional path strings, positional underscore paths, and `partial:`/`layout:` keys). They are deliberately not used for relative bareword resolution (`view_to_html_string("form")` is not resolved against the calling file's directory), since an arbitrary helper name is too ambiguous to safely resolve a bare name. Relative bareword resolution stays limited to `render`/`render_to_string`/`render_to_body`.
350
+
316
351
  ### Customizing Which Files to Scan
317
352
 
318
353
  There are two approaches:
@@ -542,6 +577,10 @@ Like her namesake, this gem finds the dead code that `grep` and your IDE missed.
542
577
 
543
578
  *Good girl, Keela.* 🦴
544
579
 
580
+ ## Changelog
581
+
582
+ See [CHANGELOG.md](CHANGELOG.md) for a list of notable changes to each release.
583
+
545
584
  ## License
546
585
 
547
586
  MIT License. See [LICENSE.txt](LICENSE.txt).
data/exe/keela CHANGED
@@ -31,7 +31,7 @@ OptionParser.new do |opts|
31
31
  opts.separator ""
32
32
  opts.separator "Options:"
33
33
 
34
- opts.on("--type TYPES", "Comma-separated types to detect: methods, scopes, constants, delegations, attributes, i18n_keys, all (default: all)") do |types|
34
+ opts.on("--type TYPES", "Comma-separated types to detect: methods, scopes, constants, delegations, attributes, i18n_keys, partials, all (default: all)") do |types|
35
35
  options[:types] = types.split(",").map { |t| t.strip.to_sym }
36
36
  end
37
37
 
@@ -141,10 +141,12 @@ STRATEGY_MAP = {
141
141
  constants: Keela::Strategies::Constants,
142
142
  delegations: Keela::Strategies::Delegations,
143
143
  attributes: Keela::Strategies::Attributes,
144
- i18n_keys: Keela::Strategies::I18nKeys
144
+ i18n_keys: Keela::Strategies::I18nKeys,
145
+ partials: Keela::Strategies::Partials
145
146
  }.freeze
146
147
 
147
- # Default strategies for "all" (excludes i18n_keys which requires different config)
148
+ # Default strategies for "all". Excludes i18n_keys and partials, which are
149
+ # opt-in via --type until we're confident in their false-positive rate.
148
150
  DEFAULT_STRATEGIES = %i[methods scopes constants delegations attributes].freeze
149
151
 
150
152
  VALID_TYPES = (STRATEGY_MAP.keys + [:all]).freeze
data/lib/keela/scanner.rb CHANGED
@@ -208,24 +208,35 @@ module Keela
208
208
  end
209
209
 
210
210
  def find_unused(definitions, show_progress: false)
211
- source_code = source_files.values.flatten.join
211
+ source = Source.from_source_files(source_files)
212
212
 
213
213
  # Get additional used names from strategy-specific detection
214
214
  # (e.g., I18n lazy lookup)
215
215
  additional_used = strategy.additional_used_names(source_files)
216
216
 
217
+ # Build the source views the strategy needs here, in the parent, so that
218
+ # the workers Parallel forks below inherit them instead of each building
219
+ # its own copy of a string the size of the whole codebase.
220
+ strategy.prepare(source)
221
+
217
222
  progress_label = show_progress ? "Checking #{strategy.name}" : nil
218
223
 
219
224
  unused = Parallel.flat_map(definitions, progress: progress_label) do |definition|
220
225
  # Check if marked as used by additional detection
221
226
  next [] if additional_used.include?(definition[:name])
222
227
 
223
- regex = strategy.usage_regex(definition[:name])
224
- regex.match?(source_code) ? [] : definition
228
+ strategy.used?(definition[:name], source) ? [] : definition
225
229
  end
226
230
 
231
+ # A single logical definition can be extracted from multiple lines
232
+ # (e.g. the same method delegated twice, or a constant declared and
233
+ # referenced), so guard against listing the same name twice per file.
234
+ #
227
235
  unused.each do |unused_def|
228
- @unused_collection[unused_def[:file]] << unused_def[:name]
236
+ names = @unused_collection[unused_def[:file]]
237
+ next if names.include?(unused_def[:name])
238
+
239
+ names << unused_def[:name]
229
240
  if unused_def[:line]
230
241
  @source_locations["#{unused_def[:file]}:#{unused_def[:name]}"] = unused_def[:line]
231
242
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keela
4
+ # The concatenated source that usage detection is matched against, plus
5
+ # lazily-derived views of it.
6
+ #
7
+ # Built once and shared by every strategy in a run. Derived views are memoized
8
+ # because they cost about as much as a single usage check but are identical for
9
+ # every definition in the run.
10
+ #
11
+ class Source
12
+ # The ASCII block as a String#delete range. Built from its code points
13
+ # rather than written as a literal so that this file stays free of the
14
+ # control characters the range ends in.
15
+ ASCII_RANGE = [0x00, 0x7F].map { |code_point| code_point.chr(Encoding::UTF_8) }.join("-").freeze
16
+
17
+ attr_reader :text
18
+
19
+ def initialize(text)
20
+ @text = text
21
+ end
22
+
23
+ # Build from the scanner's { filename => [lines] } hash.
24
+ #
25
+ # Each file's lines are joined as-is, then a newline is guaranteed at the
26
+ # end of every non-empty file before files are concatenated. Without that
27
+ # boundary a file whose last line lacks a trailing newline glues onto the
28
+ # next file's first line, letting a usage match span a gap that exists in
29
+ # no single file (issue #83). Empty files contribute nothing.
30
+ #
31
+ def self.from_source_files(source_files)
32
+ new(source_files.values.map { |lines| terminate(lines.join) }.join)
33
+ end
34
+
35
+ def self.terminate(text)
36
+ return text if text.empty? || text.end_with?("\n")
37
+
38
+ "#{text}\n"
39
+ end
40
+ private_class_method :terminate
41
+
42
+ # Unicode full case folding of #text.
43
+ #
44
+ # Lets a strategy drop //i from its pattern and match case-sensitively
45
+ # against this instead, which avoids a sharp performance cliff.
46
+ #
47
+ # Only three ASCII fold targets have a non-ASCII source: U+017F folds to
48
+ # "s", U+212A folds to "k", and the eszett folds to "ss". So once the string
49
+ # being searched is not entirely 7-bit ASCII, a //i pattern whose literal
50
+ # BEGINS with "s" or "k" can no longer be found with a plain byte search,
51
+ # and takes 100x longer or worse. A literal with "s" in the middle is partly
52
+ # affected; one ending in "s" is not.
53
+ #
54
+ # A single non-ASCII character in any scanned file is enough to put a whole
55
+ # run on that path, and method names beginning with "s" are common, so this
56
+ # is easy to hit by accident.
57
+ #
58
+ # Full folding, not #downcase, because //i folds too: /strasse/i matches the
59
+ # eszett spelling of "STRASSE".
60
+ #
61
+ def folded
62
+ @folded ||= text.downcase(:fold)
63
+ end
64
+
65
+ # Whether matching a folded ASCII literal against #folded decides exactly
66
+ # what matching that literal against #text under //i would decide.
67
+ #
68
+ # Two things can break the equivalence, both only reachable with non-ASCII
69
+ # text:
70
+ #
71
+ # - A character that folds to more than one character (an eszett folds to
72
+ # "ss") shifts everything after it, so a pattern's lookarounds see
73
+ # different neighbours than they would in #text. Folding never maps a
74
+ # character to nothing, so an unchanged total length proves every
75
+ # character mapped to exactly one and positions are preserved.
76
+ #
77
+ # - A character that folds into ASCII (U+212A KELVIN SIGN folds to "k",
78
+ # U+017F LATIN SMALL LETTER LONG S folds to "s") puts ASCII letters in the
79
+ # folded view where #text had none. A guard such as (?<![a-z_]) would then
80
+ # see a letter that //i never matches in #text.
81
+ #
82
+ # Neither occurs in ASCII-only text, so this holds for essentially all code.
83
+ # Strategies fall back to their //i pattern when it does not.
84
+ #
85
+ def fold_preserves_matching?
86
+ return @fold_preserves_matching if defined?(@fold_preserves_matching)
87
+
88
+ @fold_preserves_matching = folded.length == text.length && !non_ascii_folds_into_ascii?
89
+ end
90
+
91
+ private
92
+
93
+ def non_ascii_folds_into_ascii?
94
+ non_ascii_characters.downcase(:fold).match?(/[[:ascii:]]/)
95
+ end
96
+
97
+ # Only the non-ASCII characters of #text, which is a tiny string even for a
98
+ # large codebase.
99
+ #
100
+ # #delete with a character range rather than a regexp: a regexp scan of a
101
+ # string this size costs seconds, this costs milliseconds.
102
+ #
103
+ def non_ascii_characters
104
+ text.delete(ASCII_RANGE)
105
+ end
106
+ end
107
+ end
@@ -24,16 +24,36 @@ module Keela
24
24
  end
25
25
 
26
26
  def usage_regex(name)
27
- # Match usage of the attribute:
28
- # - Getter: obj.name, name (without receiver)
29
- # - Setter: obj.name = value, self.name = value
30
- # - Instance variable: @name (direct access)
31
- #
32
- # Exclude:
33
- # - Symbol notation (:name)
34
- # - The attr_* definition itself
35
- # - Partial word matches (username shouldn't match name)
36
- /(?:(?<!:)(?<!attr_accessor\s)(?<!attr_reader\s)(?<!attr_writer\s)(?<![a-z_])#{Regexp.quote(name)}(?!\w)|@#{Regexp.quote(name)}(?!\w))/
27
+ Regexp.union(*usage_regexes(name))
28
+ end
29
+
30
+ # The two ways an attribute can be used, as separate patterns:
31
+ # - bare reference: obj.name, name, self.name = value
32
+ # - instance variable: @name
33
+ #
34
+ # Excluded by the lookarounds:
35
+ # - symbol notation (:name)
36
+ # - the attr_* declaration itself
37
+ # - partial word matches (username must not match name)
38
+ #
39
+ # These are kept apart rather than combined into one alternation because
40
+ # an alternation has no single mandatory literal, so the regexp engine
41
+ # cannot fast-forward to a candidate position and scans the whole source
42
+ # instead. Checked separately, each pattern keeps its literal.
43
+ #
44
+ def usage_regexes(name)
45
+ quoted = Regexp.quote(name)
46
+
47
+ [
48
+ /(?<!:)(?<!attr_accessor\s)(?<!attr_reader\s)(?<!attr_writer\s)(?<![a-z_])#{quoted}(?!\w)/,
49
+ /@#{quoted}(?!\w)/
50
+ ]
51
+ end
52
+
53
+ # Equivalent to matching #usage_regex, because the caller asks only
54
+ # whether a match exists and not where it is.
55
+ def used?(name, source)
56
+ usage_regexes(name).any? { |regex| regex.match?(source.text) }
37
57
  end
38
58
 
39
59
  def skip_comments?
@@ -58,9 +58,36 @@ module Keela
58
58
  /(?<!:)(?<!delegate\s)(?<![a-z_])#{Regexp.quote(name)}(?!\w)/i
59
59
  end
60
60
 
61
+ def prepare(source)
62
+ source.fold_preserves_matching?
63
+ end
64
+
65
+ # Equivalent to matching #usage_regex, with the case folding moved from
66
+ # the pattern onto the source so that the pattern can drop //i. See
67
+ # Source#folded for why //i is the expensive part.
68
+ #
69
+ # The two decide alike because every guard in the pattern is
70
+ # case-symmetric: (?<!:) and (?!\w) do not involve case, and under //i
71
+ # both (?<![a-z_]) and (?<!delegate\s) already match their uppercase
72
+ # forms, which a folded source no longer contains. That argument needs an
73
+ # ASCII literal and a source whose folding preserves positions, so fall
74
+ # back to the //i pattern when either does not hold.
75
+ #
76
+ def used?(name, source)
77
+ return usage_regex(name).match?(source.text) unless name.ascii_only? && source.fold_preserves_matching?
78
+
79
+ folded_usage_regex(name).match?(source.folded)
80
+ end
81
+
61
82
  def skip_comments?
62
83
  true
63
84
  end
85
+
86
+ private
87
+
88
+ def folded_usage_regex(name)
89
+ /(?<!:)(?<!delegate\s)(?<![a-z_])#{Regexp.quote(name.downcase(:fold))}(?!\w)/
90
+ end
64
91
  end
65
92
  end
66
93
  end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keela
4
+ module Strategies
5
+ # Detects unused Rails view partials (_*.html.{erb,haml,slim}) under
6
+ # app/views/** and ee/app/views/**.
7
+ #
8
+ # Definitions come from filenames, not file contents (like i18n_keys), so
9
+ # this strategy overrides #extract_definitions_from_file. The logical name
10
+ # is the path with the app/views/ (or ee/app/views/) prefix, leading _, and
11
+ # extension stripped: app/views/users/_form.html.erb -> "users/form".
12
+ #
13
+ # Usage is detected two ways:
14
+ # 1. Explicit paths via #usage_regex: render "users/form",
15
+ # render partial: "users/form", render layout: "users/form". layout:
16
+ # renders a partial as a layout, so it counts as a use.
17
+ # 2. Relative (bareword) renders via #additional_used_names: render "form"
18
+ # (and render partial:/layout: "form") is resolved against the CALLING
19
+ # file's directory. In a view, the directory is the view's own dir; in a
20
+ # controller, it is derived from the controller name
21
+ # (users_controller.rb -> users).
22
+ #
23
+ # Known limitations (not implemented, by design):
24
+ # - Collection/object render (render @users -> users/_user) is NOT
25
+ # resolved. A singularize heuristic could silently hide dead code on a
26
+ # wrong guess, so these partials may be reported as unused; suppress via
27
+ # the baseline.
28
+ # - Dynamic render with a variable (render partial_name) is statically
29
+ # unsolvable, so it is not resolved (same posture as send for methods).
30
+ # - shared/* partials are treated like any other partial; false positives
31
+ # belong in the baseline rather than a hard-coded skip.
32
+ #
33
+ class Partials < Strategy
34
+ # The intermediate directory is optional so a partial directly under
35
+ # app/views/ (app/views/_foo.html.erb) matches too; without this the file
36
+ # filter and #extract_definitions_from_file disagree and root partials are
37
+ # silently skipped.
38
+ #
39
+ DEFINITION_FILE_REGEX = %r{(?:ee/)?app/views/(?:.*/)?_[^/]+\.html\.(?:erb|haml|slim)$}.freeze
40
+
41
+ # Captures the path under app/views/ so we can derive a partial name:
42
+ # app/views/users/_form.html.erb -> "users/_form"
43
+ #
44
+ NAME_PATH_REGEX = %r{(?:ee/)?app/views/(.+)\.html\.(?:erb|haml|slim)$}.freeze
45
+
46
+ # The directory of a calling VIEW, used to resolve bareword renders.
47
+ # app/views/users/show.html.erb -> "users"
48
+ #
49
+ VIEW_DIR_REGEX = %r{(?:ee/)?app/views/(.+)/[^/]+\.html\.(?:erb|haml|slim)$}.freeze
50
+
51
+ # A controller path, used to derive the implicit view directory for
52
+ # bareword renders made from controllers.
53
+ # app/controllers/users_controller.rb -> "users"
54
+ # app/controllers/admin/users_controller.rb -> "admin/users"
55
+ #
56
+ CONTROLLER_REGEX = %r{(?:ee/)?app/controllers/(.+)_controller\.rb$}.freeze
57
+
58
+ # The render family we recognize: render, plus render_to_string and
59
+ # render_to_body, which render partials the same way (Rails/GitLab use both
60
+ # off-request, e.g. in mailers and background jobs). An optional opening
61
+ # paren after the method lets us match both the no-parens same-line form
62
+ # (render_to_string partial: "x/y") and the contiguous parens form
63
+ # (render_to_string(partial: "x/y")). Multi-line detached partial: args are
64
+ # out of scope; see issue #84.
65
+ #
66
+ RENDER_METHOD = /render(?:_to_string|_to_body)?\s*\(?\s*/
67
+
68
+ # A bareword render: render "form" / render 'form' / render partial: "form"
69
+ # / render layout: "form". layout: renders a partial as a layout, so it
70
+ # counts as a use just like partial:. Only barewords (no slash) are
71
+ # captured here; paths with a slash are handled by #usage_regex.
72
+ #
73
+ # The leading negative lookbehind anchors the method on its left so that
74
+ # prerender/foo_render(_to_string) do not count as a render.
75
+ #
76
+ # This uses RENDER_METHOD, not #render_method_pattern, on purpose:
77
+ # configured render_helpers must never enable bareword-relative resolution.
78
+ # A bare 'form' passed to an arbitrary app helper is too ambiguous to
79
+ # safely resolve against the caller's directory.
80
+ #
81
+ BAREWORD_RENDER_REGEX = /(?<!\w)#{RENDER_METHOD}(?:(?:partial|layout):\s*)?["']([^"'\/]+)["']/
82
+
83
+ def name
84
+ "partials"
85
+ end
86
+
87
+ def default_definition_file_pattern
88
+ DEFINITION_FILE_REGEX
89
+ end
90
+
91
+ # Definitions come from filenames, not contents.
92
+ def extract_definitions_from_file(filepath, _lines)
93
+ return [] unless filepath =~ NAME_PATH_REGEX
94
+
95
+ # "users/_form" -> "users/form"; "_form" -> "form"
96
+ name = Regexp.last_match(1).sub(%r{(^|/)_}, '\1')
97
+
98
+ [{ name: name, file: filepath }]
99
+ end
100
+
101
+ def extract_definition(_line)
102
+ # Not used; definitions come from #extract_definitions_from_file.
103
+ nil
104
+ end
105
+
106
+ def usage_regex(name)
107
+ # render "users/form", render 'users/form',
108
+ # render partial: "users/form", render layout: "users/form"
109
+ # (single/double quotes, tolerant space). layout: renders the partial as
110
+ # a layout, so it counts as a use. render_to_string / render_to_body are
111
+ # recognized too via RENDER_METHOD.
112
+ #
113
+ # The leading negative lookbehind anchors the method on its left so that
114
+ # prerender/foo_render do not count as a render.
115
+ #
116
+ # Configured render_helpers extend the method token here (explicit-path
117
+ # matching only) via #render_method_pattern.
118
+ #
119
+ /(?<!\w)#{render_method_pattern}(?:(?:partial|layout):\s*)?["']#{Regexp.quote(name)}["']/
120
+ end
121
+
122
+ def skip_comments?
123
+ true
124
+ end
125
+
126
+ # Build the comment-stripped view once in the parent so workers inherit it
127
+ # copy-on-write instead of each rebuilding a codebase-sized string.
128
+ #
129
+ def prepare(source)
130
+ @stripped_source = source
131
+ @stripped_text = strip_erb_comments(source.text)
132
+ end
133
+
134
+ # Match against a view with single-line ERB comments removed, so a render
135
+ # inside <%# ... %> does not count as a usage. Falls back to stripping on
136
+ # demand if #prepare was not run for this source (defensive: #used? can be
137
+ # called directly in tests, or on a different source than #prepare saw).
138
+ #
139
+ def used?(name, source)
140
+ usage_regex(name).match?(stripped_text_for(source))
141
+ end
142
+
143
+ # Resolve bareword renders against the calling file's directory so that a
144
+ # short render marks the fully-qualified partial used.
145
+ #
146
+ def additional_used_names(source_files)
147
+ used = Set.new
148
+
149
+ source_files.each do |filepath, lines|
150
+ content = strip_erb_comments(lines.join("\n"))
151
+
152
+ # Positional-underscore paths are absolute logical names, so they are
153
+ # resolved regardless of the caller's directory. "shared/notes/_note"
154
+ # -> "shared/notes/note" (strip the leading _ of the basename only).
155
+ #
156
+ content.scan(positional_underscore_regex).each do |(path)|
157
+ used << path.sub(%r{/_([^/]+)$}, '/\1')
158
+ end
159
+
160
+ dir = caller_directory(filepath)
161
+ next unless dir
162
+
163
+ content.scan(BAREWORD_RENDER_REGEX).each do |(bareword)|
164
+ used << "#{dir}/#{bareword}"
165
+ end
166
+ end
167
+
168
+ used
169
+ end
170
+
171
+ private
172
+
173
+ # The comment-stripped text for +source+, using the view #prepare built
174
+ # when it saw this same source, otherwise stripping on demand.
175
+ #
176
+ def stripped_text_for(source)
177
+ return @stripped_text if defined?(@stripped_source) && @stripped_source.equal?(source)
178
+
179
+ strip_erb_comments(source.text)
180
+ end
181
+
182
+ # Blank out single-line ERB comment tags so renders inside them are not
183
+ # matched. Matches <% (optional -) then #, up to %> (optional -), on one
184
+ # line; a <%= output %> or <% code %> tag has no # after <% and is left
185
+ # intact. Multi-line ERB comments and HAML comments are out of scope: the
186
+ # dot does not cross newlines, so they are not matched.
187
+ #
188
+ # Replaced with same-length spaces (via the block form, so the length is
189
+ # the matched span's, not a global) to preserve positions and line
190
+ # structure.
191
+ #
192
+ def strip_erb_comments(text)
193
+ text.gsub(/<%\s*-?\s*#.*?-?\s*%>/) { |match| " " * match.length }
194
+ end
195
+
196
+ # The directory a bareword render resolves against: the view's own dir, or
197
+ # the controller-derived dir. Returns nil for files that render nothing
198
+ # resolvable this way.
199
+ #
200
+ def caller_directory(filepath)
201
+ return Regexp.last_match(1) if filepath =~ VIEW_DIR_REGEX
202
+ return Regexp.last_match(1) if filepath =~ CONTROLLER_REGEX
203
+
204
+ nil
205
+ end
206
+
207
+ # A positional string whose basename starts with _ names a partial file
208
+ # directly: render_to_string("shared/notes/_note") uses shared/notes/note.
209
+ # There is no partial:/layout: keyword here, so it is handled separately
210
+ # from #usage_regex and the bareword path. Only the leading _ of the
211
+ # BASENAME is stripped; intermediate path segments are kept verbatim.
212
+ #
213
+ # A method (not a constant) so configured render_helpers extend the
214
+ # recognized method token via #render_method_pattern.
215
+ #
216
+ def positional_underscore_regex
217
+ /(?<!\w)#{render_method_pattern}["']([^"']*\/_[^"'\/]+)["']/
218
+ end
219
+
220
+ # The recognized render-method token for EXPLICIT-PATH matching. Defaults
221
+ # to RENDER_METHOD; when the partials strategy is configured with
222
+ # render_helpers, each helper name is added to the alternation so an
223
+ # app-specific helper that renders a partial from a string path is treated
224
+ # like render for explicit-path detection.
225
+ #
226
+ # strategies:
227
+ # partials:
228
+ # render_helpers:
229
+ # - view_to_html_string
230
+ # - tabs_json
231
+ #
232
+ # Helper names are Regexp.quote'd so metacharacters match literally, and
233
+ # the caller keeps the (?<!\w) left-anchor so tabs_json does not match
234
+ # my_tabs_json. Returns RENDER_METHOD unchanged when none are configured,
235
+ # so default behavior is byte-identical to today.
236
+ #
237
+ def render_method_pattern
238
+ helpers = Keela.configuration.options_for(name)["render_helpers"]
239
+ return RENDER_METHOD unless helpers.is_a?(Array) && helpers.any?
240
+
241
+ quoted = helpers.map { |h| Regexp.quote(h.to_s) }
242
+ /(?:render(?:_to_string|_to_body)?|#{quoted.join('|')})\s*\(?\s*/
243
+ end
244
+ end
245
+ end
246
+ end
@@ -43,6 +43,26 @@ module Keela
43
43
  raise NotImplementedError, "#{self.class} must implement #usage_regex"
44
44
  end
45
45
 
46
+ # Whether +name+ is used anywhere in +source+ (a Keela::Source).
47
+ #
48
+ # Defaults to matching #usage_regex against the raw source. Override when an
49
+ # equivalent formulation is cheaper to match. An override must decide
50
+ # exactly what #usage_regex would decide.
51
+ #
52
+ def used?(name, source)
53
+ usage_regex(name).match?(source.text)
54
+ end
55
+
56
+ # Force any derived Keela::Source view this strategy needs in #used?.
57
+ #
58
+ # The scanner calls this once before it forks its workers, so the views are
59
+ # built in the parent and inherited copy-on-write rather than being rebuilt
60
+ # by every worker.
61
+ #
62
+ def prepare(_source)
63
+ nil
64
+ end
65
+
46
66
  # Whether to skip lines that start with # (comments)
47
67
  def skip_comments?
48
68
  false
data/lib/keela/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Keela
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/keela.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "keela/version"
4
4
  require_relative "keela/configuration"
5
5
  require_relative "keela/config_file"
6
+ require_relative "keela/source"
6
7
  require_relative "keela/strategy"
7
8
  require_relative "keela/strategies/methods"
8
9
  require_relative "keela/strategies/scopes"
@@ -10,6 +11,7 @@ require_relative "keela/strategies/constants"
10
11
  require_relative "keela/strategies/delegations"
11
12
  require_relative "keela/strategies/attributes"
12
13
  require_relative "keela/strategies/i18n_keys"
14
+ require_relative "keela/strategies/partials"
13
15
  require_relative "keela/reporter"
14
16
  require_relative "keela/baseline"
15
17
  require_relative "keela/scanner"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keela
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerri Miller
@@ -88,11 +88,13 @@ files:
88
88
  - lib/keela/formatters/toon.rb
89
89
  - lib/keela/reporter.rb
90
90
  - lib/keela/scanner.rb
91
+ - lib/keela/source.rb
91
92
  - lib/keela/strategies/attributes.rb
92
93
  - lib/keela/strategies/constants.rb
93
94
  - lib/keela/strategies/delegations.rb
94
95
  - lib/keela/strategies/i18n_keys.rb
95
96
  - lib/keela/strategies/methods.rb
97
+ - lib/keela/strategies/partials.rb
96
98
  - lib/keela/strategies/scopes.rb
97
99
  - lib/keela/strategy.rb
98
100
  - lib/keela/version.rb
@@ -111,7 +113,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
111
113
  requirements:
112
114
  - - ">="
113
115
  - !ruby/object:Gem::Version
114
- version: 3.1.0
116
+ version: 3.3.0
115
117
  required_rubygems_version: !ruby/object:Gem::Requirement
116
118
  requirements:
117
119
  - - ">="