keela 0.4.0 → 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 +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +105 -12
- data/exe/keela +5 -3
- data/lib/keela/scanner.rb +22 -4
- data/lib/keela/source.rb +107 -0
- data/lib/keela/strategies/attributes.rb +30 -10
- data/lib/keela/strategies/constants.rb +6 -2
- data/lib/keela/strategies/delegations.rb +27 -0
- data/lib/keela/strategies/i18n_keys.rb +90 -8
- data/lib/keela/strategies/methods.rb +8 -3
- data/lib/keela/strategies/partials.rb +246 -0
- data/lib/keela/strategy.rb +28 -0
- data/lib/keela/version.rb +1 -1
- data/lib/keela.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0755e73eaeeb61f9f97ffa109fd8c421316e7725b83ef2502a37c5ba0111858f
|
|
4
|
+
data.tar.gz: d66e1092dd578f28ae3d727b179b601fcd7068671550f9186e53c76a543d1763
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf117842ed4d8891671dac92ee34c48b5433f50795f9a90963211c1e650df11b31e72c2d142a6fc489f3dfb040989341764ab6eba4d2ea70432828bd6ed1c1dc
|
|
7
|
+
data.tar.gz: ac2d7352393d70240f7ad1541f2ff476e5ed7c8def8b13212721a527df4e7c285b2c31519333ebe712aab31e9a186a997420598e7e7662af181353cf9f8b96ca
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ 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
|
+
|
|
32
|
+
## [0.4.1] - 2026-08-21
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Constants used as hash keys (`CONST => value`) or in rescue splats (`rescue *ERRORS => e`) are now correctly detected as used ([#63](https://github.com/kerrizor/keela/pull/63))
|
|
37
|
+
- Methods referenced as literal symbols (`:method_name`) are now detected as used, including Rails callbacks, `send(:method)`, `validate :method`, etc. ([#64](https://github.com/kerrizor/keela/issues/64))
|
|
38
|
+
- I18n pluralization keys (`one`, `other`, `zero`, etc.) are now detected as used when the parent key is called with `t('key', count: n)` ([#69](https://github.com/kerrizor/keela/pull/69))
|
|
39
|
+
- I18n lazy lookup (`t('.title')` in views) now correctly resolves to the full key based on the view path ([#17](https://github.com/kerrizor/keela/issues/17))
|
|
40
|
+
|
|
10
41
|
## [0.4.0] - 2026-08-14
|
|
11
42
|
|
|
12
43
|
### Added
|
data/README.md
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
**Your Ruby codebase's forensic investigator.**
|
|
4
4
|
|
|
5
|
-
Like her namesake
|
|
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
|
-
🔍
|
|
9
|
-
🎯 Baseline mode
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,22 +194,90 @@ 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`
|
|
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
|
|
|
200
205
|
The `i18n_keys` strategy is **beta** and may produce false positives. It cannot detect:
|
|
201
206
|
|
|
202
|
-
- **Lazy lookup** - `t('.title')` in views resolves based on the view path
|
|
203
207
|
- **Dynamic keys** - `t("users.#{action}.title")` with interpolated segments
|
|
204
208
|
- **Model translations** - `User.human_attribute_name(:email)` and `User.model_name.human`
|
|
205
|
-
|
|
209
|
+
|
|
210
|
+
The following patterns ARE now supported:
|
|
211
|
+
- **Lazy lookup** - `t('.title')` in views resolves based on the view path
|
|
212
|
+
- **Pluralization siblings** - `t('key', count: n)` marks all plural forms as used
|
|
206
213
|
|
|
207
214
|
Review results carefully and use the exclusion file for known false positives.
|
|
208
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
|
+
|
|
229
|
+
## Limitations
|
|
230
|
+
|
|
231
|
+
Keela uses static analysis: it reads your code without executing it. This means some patterns are **fundamentally undetectable**.
|
|
232
|
+
|
|
233
|
+
### What Keela CAN Detect
|
|
234
|
+
|
|
235
|
+
Keela detects **literal references** to methods, constants, etc.:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
# ✅ Direct calls
|
|
239
|
+
user.save
|
|
240
|
+
User.find(1)
|
|
241
|
+
|
|
242
|
+
# ✅ Literal symbol references
|
|
243
|
+
before_save :ensure_token
|
|
244
|
+
validate :check_valid
|
|
245
|
+
send(:process_data)
|
|
246
|
+
respond_to?(:optional_method)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### What Keela CANNOT Detect
|
|
250
|
+
|
|
251
|
+
**Dynamic dispatch** with interpolation or variables cannot be analyzed statically:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
# ❌ Interpolated symbols: what method does this call?
|
|
255
|
+
public_send(:"add_#{role}", user)
|
|
256
|
+
|
|
257
|
+
# ❌ Variable method names: could be anything
|
|
258
|
+
send(method_name)
|
|
259
|
+
|
|
260
|
+
# ❌ Computed method names
|
|
261
|
+
define_method(compute_name) { }
|
|
262
|
+
```
|
|
263
|
+
|
|
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.
|
|
265
|
+
|
|
266
|
+
### Handling False Positives
|
|
267
|
+
|
|
268
|
+
When Keela reports a method as unused but it's actually called dynamically, add it to your exclusion file:
|
|
269
|
+
|
|
270
|
+
```yaml
|
|
271
|
+
# .keela/excluded.yml
|
|
272
|
+
methods:
|
|
273
|
+
app/models/project_team.rb:
|
|
274
|
+
- add_owner: "Called via public_send(:\"add_\#{role}\")"
|
|
275
|
+
- add_maintainer: "Called via public_send(:\"add_\#{role}\")"
|
|
276
|
+
- add_developer: "Called via public_send(:\"add_\#{role}\")"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**Tip:** If you see `send`, `public_send`, or `define_method` with interpolation in a file, expect some false positives for methods in that file.
|
|
280
|
+
|
|
209
281
|
## Configuration File
|
|
210
282
|
|
|
211
283
|
Create a `keela.yml` or `.keela.yml` in your project root:
|
|
@@ -259,6 +331,23 @@ strategies:
|
|
|
259
331
|
|
|
260
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.
|
|
261
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
|
+
|
|
262
351
|
### Customizing Which Files to Scan
|
|
263
352
|
|
|
264
353
|
There are two approaches:
|
|
@@ -488,6 +577,10 @@ Like her namesake, this gem finds the dead code that `grep` and your IDE missed.
|
|
|
488
577
|
|
|
489
578
|
*Good girl, Keela.* 🦴
|
|
490
579
|
|
|
580
|
+
## Changelog
|
|
581
|
+
|
|
582
|
+
See [CHANGELOG.md](CHANGELOG.md) for a list of notable changes to each release.
|
|
583
|
+
|
|
491
584
|
## License
|
|
492
585
|
|
|
493
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"
|
|
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,17 +208,35 @@ module Keela
|
|
|
208
208
|
end
|
|
209
209
|
|
|
210
210
|
def find_unused(definitions, show_progress: false)
|
|
211
|
-
|
|
211
|
+
source = Source.from_source_files(source_files)
|
|
212
|
+
|
|
213
|
+
# Get additional used names from strategy-specific detection
|
|
214
|
+
# (e.g., I18n lazy lookup)
|
|
215
|
+
additional_used = strategy.additional_used_names(source_files)
|
|
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)
|
|
212
221
|
|
|
213
222
|
progress_label = show_progress ? "Checking #{strategy.name}" : nil
|
|
214
223
|
|
|
215
224
|
unused = Parallel.flat_map(definitions, progress: progress_label) do |definition|
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
# Check if marked as used by additional detection
|
|
226
|
+
next [] if additional_used.include?(definition[:name])
|
|
227
|
+
|
|
228
|
+
strategy.used?(definition[:name], source) ? [] : definition
|
|
218
229
|
end
|
|
219
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
|
+
#
|
|
220
235
|
unused.each do |unused_def|
|
|
221
|
-
@unused_collection[unused_def[:file]]
|
|
236
|
+
names = @unused_collection[unused_def[:file]]
|
|
237
|
+
next if names.include?(unused_def[:name])
|
|
238
|
+
|
|
239
|
+
names << unused_def[:name]
|
|
222
240
|
if unused_def[:line]
|
|
223
241
|
@source_locations["#{unused_def[:file]}:#{unused_def[:name]}"] = unused_def[:line]
|
|
224
242
|
end
|
data/lib/keela/source.rb
ADDED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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?
|
|
@@ -41,8 +41,12 @@ module Keela
|
|
|
41
41
|
# uppercase letters/digits/underscores (partial match)
|
|
42
42
|
# Uses negative lookahead to avoid:
|
|
43
43
|
# - partial matches (followed by uppercase letters/digits/underscores)
|
|
44
|
-
# - definitions (followed by optional whitespace then =, but not ==)
|
|
45
|
-
|
|
44
|
+
# - definitions (followed by optional whitespace then =, but not == or =>)
|
|
45
|
+
#
|
|
46
|
+
# The pattern (?!\s*=(?![=>])) means:
|
|
47
|
+
# - Don't match if followed by optional whitespace, then =
|
|
48
|
+
# - UNLESS that = is followed by = (comparison) or > (hash rocket)
|
|
49
|
+
/(?<![A-Z0-9_])#{Regexp.quote(name)}(?![A-Z0-9_])(?!\s*=(?![=>]))/
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
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
|
|
@@ -15,9 +15,16 @@ module Keela
|
|
|
15
15
|
# - t(:key)
|
|
16
16
|
# - .human_attribute_name(:attr)
|
|
17
17
|
#
|
|
18
|
-
#
|
|
18
|
+
# Pluralization keys (zero, one, two, few, many, other) are grouped:
|
|
19
|
+
# if t("items.count", count: n) is called, all siblings are considered used.
|
|
20
|
+
#
|
|
21
|
+
# Lazy lookup is supported: t('.title') in app/views/users/show.html.erb
|
|
22
|
+
# resolves to 'users.show.title'.
|
|
19
23
|
#
|
|
20
24
|
class I18nKeys < Strategy
|
|
25
|
+
PLURAL_SUFFIXES = %w[zero one two few many other].freeze
|
|
26
|
+
VIEW_PATH_REGEX = %r{(?:ee/)?app/views/(.+)\.html\.(?:erb|haml|slim)$}.freeze
|
|
27
|
+
LAZY_LOOKUP_REGEX = /(?:I18n\.)?t\s*\(\s*['"](\.[^'"]+)['"]/
|
|
21
28
|
def name
|
|
22
29
|
"i18n_keys"
|
|
23
30
|
end
|
|
@@ -32,10 +39,19 @@ module Keela
|
|
|
32
39
|
return [] unless File.exist?(filepath)
|
|
33
40
|
|
|
34
41
|
content = YAML.load_file(filepath, permitted_classes: [Symbol]) || {}
|
|
35
|
-
flatten_keys(content).map do |key|
|
|
42
|
+
keys = flatten_keys(content).map do |key|
|
|
36
43
|
# Remove the locale prefix (e.g., "en.users.show" -> "users.show")
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
key.sub(/^[a-z]{2}(-[A-Z]{2})?\./, "")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# For pluralization keys, also add the parent key so that
|
|
48
|
+
# t("items.count", count: n) marks all siblings as used
|
|
49
|
+
parent_keys = keys.filter_map do |key|
|
|
50
|
+
parent_key_for_pluralization(key)
|
|
51
|
+
end.uniq
|
|
52
|
+
|
|
53
|
+
(keys + parent_keys).uniq.map do |key|
|
|
54
|
+
{ name: key, file: filepath }
|
|
39
55
|
end
|
|
40
56
|
rescue Psych::SyntaxError => e
|
|
41
57
|
warn "Warning: Could not parse #{filepath}: #{e.message}"
|
|
@@ -55,20 +71,86 @@ module Keela
|
|
|
55
71
|
# t('users.show.title')
|
|
56
72
|
# t(:users_show_title) - symbol form (underscored)
|
|
57
73
|
#
|
|
58
|
-
#
|
|
59
|
-
# t(".
|
|
74
|
+
# For pluralization keys, also match the parent key:
|
|
75
|
+
# t("items.count", count: n) should match items.count.one, items.count.other, etc.
|
|
60
76
|
quoted_name = Regexp.quote(name)
|
|
61
77
|
|
|
62
|
-
#
|
|
63
|
-
|
|
78
|
+
# Check if this is a pluralization key and build alternate pattern
|
|
79
|
+
parent_key = parent_key_for_pluralization(name)
|
|
80
|
+
if parent_key
|
|
81
|
+
quoted_parent = Regexp.quote(parent_key)
|
|
82
|
+
# Match either the exact key OR the parent key
|
|
83
|
+
/(?:I18n\.)?t\s*\(\s*["':]+(?:#{quoted_name}|#{quoted_parent})["']?\s*[,)]/
|
|
84
|
+
else
|
|
85
|
+
# Build pattern that matches the key in quotes or as a symbol
|
|
86
|
+
/(?:I18n\.)?t\s*\(\s*["':]+#{quoted_name}["']?\s*[,)]/
|
|
87
|
+
end
|
|
64
88
|
end
|
|
65
89
|
|
|
66
90
|
def skip_comments?
|
|
67
91
|
true
|
|
68
92
|
end
|
|
69
93
|
|
|
94
|
+
# Convert a view file path to its I18n prefix
|
|
95
|
+
# "app/views/users/show.html.erb" -> "users.show"
|
|
96
|
+
# "app/views/users/_form.html.erb" -> "users.form"
|
|
97
|
+
# "ee/app/views/users/show.html.erb" -> "users.show"
|
|
98
|
+
def view_path_to_prefix(path)
|
|
99
|
+
return nil unless path =~ VIEW_PATH_REGEX
|
|
100
|
+
|
|
101
|
+
view_path = Regexp.last_match(1)
|
|
102
|
+
|
|
103
|
+
# Split into parts and process
|
|
104
|
+
parts = view_path.split("/")
|
|
105
|
+
|
|
106
|
+
# Handle partials: _form -> form
|
|
107
|
+
parts[-1] = parts[-1].sub(/^_/, "")
|
|
108
|
+
|
|
109
|
+
parts.join(".")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Extract lazy lookup keys from view content
|
|
113
|
+
# Returns array of keys like [".title", ".description"]
|
|
114
|
+
def extract_lazy_keys(content)
|
|
115
|
+
content.scan(LAZY_LOOKUP_REGEX).flatten
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Build a set of expanded lazy lookup keys from view files
|
|
119
|
+
# Called by the scanner to detect usage via lazy lookup
|
|
120
|
+
def additional_used_names(source_files)
|
|
121
|
+
expanded = Set.new
|
|
122
|
+
|
|
123
|
+
source_files.each do |filepath, lines|
|
|
124
|
+
prefix = view_path_to_prefix(filepath)
|
|
125
|
+
next unless prefix
|
|
126
|
+
|
|
127
|
+
content = lines.join("\n")
|
|
128
|
+
lazy_keys = extract_lazy_keys(content)
|
|
129
|
+
|
|
130
|
+
lazy_keys.each do |lazy_key|
|
|
131
|
+
# .title -> users.show.title
|
|
132
|
+
full_key = "#{prefix}#{lazy_key}"
|
|
133
|
+
expanded << full_key
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
expanded
|
|
138
|
+
end
|
|
139
|
+
|
|
70
140
|
private
|
|
71
141
|
|
|
142
|
+
# Returns the parent key if this is a pluralization key, nil otherwise
|
|
143
|
+
# "items.count.one" -> "items.count"
|
|
144
|
+
# "users.show.title" -> nil
|
|
145
|
+
def parent_key_for_pluralization(key)
|
|
146
|
+
PLURAL_SUFFIXES.each do |suffix|
|
|
147
|
+
if key.end_with?(".#{suffix}")
|
|
148
|
+
return key.sub(/\.#{suffix}$/, "")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
72
154
|
# Flatten nested hash to dot-notation keys
|
|
73
155
|
# { "en" => { "users" => { "title" => "..." } } }
|
|
74
156
|
# becomes ["en.users.title"]
|
|
@@ -24,9 +24,14 @@ module Keela
|
|
|
24
24
|
# Setter method: match assignment usage
|
|
25
25
|
/(?<!def |def self\.)#{method_name.chomp("=")}\W=*/
|
|
26
26
|
else
|
|
27
|
-
# Regular method: match calls
|
|
28
|
-
#
|
|
29
|
-
|
|
27
|
+
# Regular method: match calls and symbol references
|
|
28
|
+
# Matches:
|
|
29
|
+
# - Direct calls: foo(arg), obj.foo
|
|
30
|
+
# - Symbol references: :foo, :foo! (callbacks, send, etc.)
|
|
31
|
+
# Excludes:
|
|
32
|
+
# - Definitions: def foo, def self.foo
|
|
33
|
+
# - Partial matches: :foobar, before_foo (via word boundary lookbehind)
|
|
34
|
+
/(?<!def |def self\.)(?<![A-Za-z0-9_]):?#{method_name}(?:\W|$)/
|
|
30
35
|
end
|
|
31
36
|
end
|
|
32
37
|
|
|
@@ -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
|
data/lib/keela/strategy.rb
CHANGED
|
@@ -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
|
|
@@ -55,6 +75,14 @@ module Keela
|
|
|
55
75
|
nil
|
|
56
76
|
end
|
|
57
77
|
|
|
78
|
+
# Override this method for strategies that need to detect usage through
|
|
79
|
+
# patterns that can't be expressed as a simple regex (e.g., I18n lazy lookup).
|
|
80
|
+
# Returns a Set of definition names that are considered "used".
|
|
81
|
+
# Called with the source_files hash { filepath => [lines] }.
|
|
82
|
+
def additional_used_names(_source_files)
|
|
83
|
+
Set.new
|
|
84
|
+
end
|
|
85
|
+
|
|
58
86
|
private
|
|
59
87
|
|
|
60
88
|
def configured_pattern
|
data/lib/keela/version.rb
CHANGED
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
|
+
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.
|
|
116
|
+
version: 3.3.0
|
|
115
117
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
116
118
|
requirements:
|
|
117
119
|
- - ">="
|