rubocop-minify 0.1.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 +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +89 -0
- data/config/default.yml +14 -0
- data/exe/minify-erb +48 -0
- data/lib/rubocop/cop/minify/attribute_methods.rb +45 -0
- data/lib/rubocop/minify/plugin.rb +22 -0
- data/lib/rubocop-minify.rb +6 -0
- data/lib/rubocop_minify/definitions.rb +36 -0
- data/lib/rubocop_minify/erb.rb +39 -0
- data/lib/rubocop_minify/rewrite.rb +255 -0
- data/lib/rubocop_minify/version.rb +5 -0
- data/lib/rubocop_minify.rb +27 -0
- metadata +130 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '049d556f390def41fa201dedd271537d50e8cc52089a88390265099294b98d06'
|
|
4
|
+
data.tar.gz: d5456061700c24de435649ae2a13887c30b8390f50de47667a560e44c56c4873
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7587a7837c4f11f0edc58d071367b039cd0c51ec9f54e4f1e4dbc84d23dcf7881228bc43714757663f08a7e0e87fec85e77d2c4ac64e6182a683957dd1344da7
|
|
7
|
+
data.tar.gz: c20b028278b3b49797bbaeaee9b460a5df1105dbfaad073dbda4fc4298bb3746d42da6847fce9ba1b46d0da82aa0b2da0d69e5ac3d827fe906a22ae65b50b0c6
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sampo Kuokkanen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# rubocop-minify
|
|
2
|
+
|
|
3
|
+
RuboCop cops, and an ERB fixer, that rewrite code relying on what
|
|
4
|
+
[rails-minify](../rails-minify) removes from Rails. Part of rails-minify.
|
|
5
|
+
|
|
6
|
+
activerecord-minimizer's strict mode stops generating 19 of the 21 attribute
|
|
7
|
+
methods per column (`name?`, `name_changed?`, `saved_change_to_name?`, ...).
|
|
8
|
+
`Minify/AttributeMethods` rewrites the calls into what they stood for:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
venue.featured? # => venue.featured.present?
|
|
12
|
+
return unless venue.featured? # => return unless venue.featured
|
|
13
|
+
venue.name? # => venue.name.present?
|
|
14
|
+
venue.rank? # => venue.query_attribute(:rank)
|
|
15
|
+
venue.name_changed?(to: "x") # => venue.attribute_changed?(:name, to: "x")
|
|
16
|
+
before_save :geocode, if: :address_changed?
|
|
17
|
+
# => if: -> { attribute_changed?(:address) }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`name?` follows the column's type: a boolean or date column reads as itself
|
|
21
|
+
where only truthiness matters (conditions, `!`, `&&`/`||` inside them) and as
|
|
22
|
+
`.present?` elsewhere; strings use `.present?`; numbers, enums and decorated
|
|
23
|
+
columns use `query_attribute`. Arguments, `&.` and nesting are kept.
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# Gemfile
|
|
29
|
+
group :development do
|
|
30
|
+
gem "rubocop-minify", require: false
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
# .rubocop.yml
|
|
36
|
+
plugins:
|
|
37
|
+
- rubocop-minify
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The cop reads activerecord-minimizer's manifest, `config/ar_minimizer.json`
|
|
41
|
+
(`bin/rails ar_minimizer:manifest`): which names strict mode removes, from
|
|
42
|
+
which models, for which attribute and column type. Without a manifest it does
|
|
43
|
+
nothing.
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
bin/rubocop --only Minify/AttributeMethods -a # Ruby: app, lib, test, db, ...
|
|
47
|
+
bundle exec minify-erb --fix app # templates
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
RuboCop doesn't read ERB, so `minify-erb` does the same rewrite on the Ruby
|
|
51
|
+
inside each tag (parsed with Herb). Without `--fix` it lists the calls; it
|
|
52
|
+
exits non-zero while any are left, for CI.
|
|
53
|
+
|
|
54
|
+
## What it won't rewrite
|
|
55
|
+
|
|
56
|
+
Receivers aren't typed, so the cop only rewrites what can't mean something
|
|
57
|
+
else, and reports the rest for you to fix by hand or add to the keep list
|
|
58
|
+
(`config/ar_minimizer_keep.txt`):
|
|
59
|
+
|
|
60
|
+
* Names the app also defines (`def featured?` in a presenter, `delegate`,
|
|
61
|
+
`attr_reader`, `alias_method`, ...), scanned from `app/` and `lib/`
|
|
62
|
+
(`DefinitionPaths`).
|
|
63
|
+
* Names another model defines some other way, such as an enum predicate
|
|
64
|
+
called like another model's column (the manifest's `shadows`).
|
|
65
|
+
* Names Ruby or Active Record define everywhere (`key?`, `empty?`,
|
|
66
|
+
`readonly?`), and calls with arguments to methods that take none.
|
|
67
|
+
* Calls without a receiver outside a model, or in a module included by
|
|
68
|
+
models of which only some have the column. In templates, `self` is a view,
|
|
69
|
+
so receiver-less calls are never records.
|
|
70
|
+
* Private proxies (`name_will_change!` → `attribute_will_change!(:name)`) on
|
|
71
|
+
another object; on `self` they're rewritten.
|
|
72
|
+
* Method names passed to `send`, `try` and `delegate`.
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
Minify/AttributeMethods:
|
|
78
|
+
Manifest: config/ar_minimizer.json
|
|
79
|
+
DefinitionPaths:
|
|
80
|
+
- "app/**/*.rb"
|
|
81
|
+
- "lib/**/*.rb"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## On golf-buddy
|
|
85
|
+
|
|
86
|
+
191 models: 130 calls in Ruby (app, lib, test, db) and 21 templates rewritten
|
|
87
|
+
automatically; 11 names left for the keep list, all of them names some other
|
|
88
|
+
class defines too. The test suite gives the same results in strict mode
|
|
89
|
+
afterwards.
|
data/config/default.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Minify/AttributeMethods:
|
|
2
|
+
Description: >-
|
|
3
|
+
Rewrites calls to the attribute methods activerecord-minimizer's strict
|
|
4
|
+
mode removes (name?, name_changed?, saved_change_to_name?, ...) into the
|
|
5
|
+
calls they stood for.
|
|
6
|
+
Enabled: true
|
|
7
|
+
VersionAdded: "0.1"
|
|
8
|
+
# Written by `bin/rails ar_minimizer:manifest`: which names are removed, and for which attribute.
|
|
9
|
+
Manifest: config/ar_minimizer.json
|
|
10
|
+
# Where to look for hand-written methods sharing a removed name (a PORO's
|
|
11
|
+
# featured?): calls of those names are reported but never rewritten.
|
|
12
|
+
DefinitionPaths:
|
|
13
|
+
- "app/**/*.rb"
|
|
14
|
+
- "lib/**/*.rb"
|
data/exe/minify-erb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Rewrites (with --fix) or lists calls to removed attribute methods in ERB
|
|
5
|
+
# templates, the part of the app RuboCop doesn't read.
|
|
6
|
+
#
|
|
7
|
+
# minify-erb [--fix] [--manifest config/ar_minimizer.json] [paths...]
|
|
8
|
+
require "optparse"
|
|
9
|
+
require "rubocop_minify"
|
|
10
|
+
|
|
11
|
+
fix = false
|
|
12
|
+
manifest = "config/ar_minimizer.json"
|
|
13
|
+
OptionParser.new do |o|
|
|
14
|
+
o.banner = "usage: minify-erb [--fix] [--manifest PATH] [paths...]"
|
|
15
|
+
o.on("--fix", "rewrite the templates in place") { fix = true }
|
|
16
|
+
o.on("--manifest PATH") { manifest = it }
|
|
17
|
+
end.parse!
|
|
18
|
+
|
|
19
|
+
root = Dir.pwd
|
|
20
|
+
rewrite = RubocopMinify.rewrite_for(root, manifest: manifest, definition_paths: ["app/**/*.rb", "lib/**/*.rb"], views: true)
|
|
21
|
+
abort "minify-erb: no #{manifest}; run bin/rails ar_minimizer:manifest" unless rewrite
|
|
22
|
+
|
|
23
|
+
paths = (ARGV.empty? ? ["app"] : ARGV).flat_map { File.directory?(it) ? Dir.glob("#{it}/**/*.erb") : [it] }.sort
|
|
24
|
+
rewritten = 0
|
|
25
|
+
left = 0
|
|
26
|
+
paths.each do |path|
|
|
27
|
+
template = File.read(path)
|
|
28
|
+
line_of = ->(offset) { template.byteslice(0, offset).count("\n") + 1 }
|
|
29
|
+
if fix
|
|
30
|
+
fixed, manual = RubocopMinify::ERB.fix(template, rewrite)
|
|
31
|
+
if fixed != template
|
|
32
|
+
File.write(path, fixed)
|
|
33
|
+
rewritten += 1
|
|
34
|
+
puts "rewrote #{path}"
|
|
35
|
+
end
|
|
36
|
+
template = fixed
|
|
37
|
+
line_of = ->(offset) { template.byteslice(0, offset).count("\n") + 1 }
|
|
38
|
+
manual.each { puts "#{path}:#{line_of.(it.from)}: #{it.message}" }
|
|
39
|
+
left += manual.size
|
|
40
|
+
else
|
|
41
|
+
RubocopMinify::ERB.edits(template, rewrite).each do |edit|
|
|
42
|
+
puts "#{path}:#{line_of.(edit.from)}: #{edit.message}"
|
|
43
|
+
left += 1
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
puts(fix ? "#{rewritten} templates rewritten, #{left} calls to fix by hand" : "#{left} calls")
|
|
48
|
+
exit(left.zero? ? 0 : 1)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Minify
|
|
6
|
+
# Calls to the attribute methods activerecord-minimizer's strict mode
|
|
7
|
+
# removes, rewritten into what they stood for. See RubocopMinify::Rewrite.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # bad
|
|
11
|
+
# venue.featured?
|
|
12
|
+
# venue.name_changed?
|
|
13
|
+
# before_save :geocode, if: :address_changed?
|
|
14
|
+
#
|
|
15
|
+
# # good
|
|
16
|
+
# venue.featured.present?
|
|
17
|
+
# venue.attribute_changed?(:name)
|
|
18
|
+
# before_save :geocode, if: -> { attribute_changed?(:address) }
|
|
19
|
+
class AttributeMethods < Base
|
|
20
|
+
extend AutoCorrector
|
|
21
|
+
|
|
22
|
+
def on_new_investigation
|
|
23
|
+
rewrite = RubocopMinify.rewrite_for(config.base_dir_for_path_parameters,
|
|
24
|
+
manifest: cop_config.fetch("Manifest", "config/ar_minimizer.json"),
|
|
25
|
+
definition_paths: Array(cop_config.fetch("DefinitionPaths", ["app/**/*.rb", "lib/**/*.rb"])))
|
|
26
|
+
return unless rewrite
|
|
27
|
+
|
|
28
|
+
source = processed_source.raw_source
|
|
29
|
+
rewrite.edits(source).each do |edit|
|
|
30
|
+
range = range_between(source.byteslice(0, edit.from).length, source.byteslice(0, edit.to).length)
|
|
31
|
+
if edit.replacement
|
|
32
|
+
add_offense(range, message: edit.message) { it.replace(range, edit.replacement) }
|
|
33
|
+
else
|
|
34
|
+
add_offense(range, message: edit.message)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def range_between(from, to) = Parser::Source::Range.new(processed_source.buffer, from, to)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lint_roller"
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Minify
|
|
7
|
+
# `plugins: [rubocop-minify]` in .rubocop.yml.
|
|
8
|
+
class Plugin < LintRoller::Plugin
|
|
9
|
+
def about
|
|
10
|
+
LintRoller::About.new(name: "rubocop-minify", version: RubocopMinify::VERSION,
|
|
11
|
+
homepage: "https://github.com/sampokuokkanen/rails-minify",
|
|
12
|
+
description: "Rewrites code relying on what rails-minify removes from Rails")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def supported?(context) = context.engine == :rubocop
|
|
16
|
+
|
|
17
|
+
def rules(_context)
|
|
18
|
+
LintRoller::Rules.new(type: :path, config_format: :rubocop, value: Pathname.new(__dir__).join("../../../config/default.yml"))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module RubocopMinify
|
|
6
|
+
# Hand-written methods in the app, by name: { "featured?" => "app/models/poro.rb:12" }.
|
|
7
|
+
# def, define_method, alias, alias_method, attr_*, delegate and def_delegator(s).
|
|
8
|
+
module Definitions
|
|
9
|
+
DEFINERS = %i[define_method alias_method attr_reader attr_writer attr_accessor delegate def_delegator def_delegators].freeze
|
|
10
|
+
|
|
11
|
+
def self.scan(root, patterns)
|
|
12
|
+
found = {}
|
|
13
|
+
patterns.flat_map { Dir.glob(it, base: root) }.sort.each do |path|
|
|
14
|
+
walk(Prism.parse_file(File.join(root, path)).value, path, found)
|
|
15
|
+
end
|
|
16
|
+
found
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.walk(node, path, found)
|
|
20
|
+
case node
|
|
21
|
+
when Prism::DefNode
|
|
22
|
+
found[node.name.to_s] ||= "#{path}:#{node.location.start_line}"
|
|
23
|
+
when Prism::AliasMethodNode
|
|
24
|
+
found[node.new_name.slice.delete_prefix(":")] ||= "#{path}:#{node.location.start_line}" if node.new_name.is_a?(Prism::SymbolNode)
|
|
25
|
+
when Prism::CallNode
|
|
26
|
+
if DEFINERS.include?(node.name)
|
|
27
|
+
symbols = node.arguments&.arguments&.grep(Prism::SymbolNode) || []
|
|
28
|
+
symbols = symbols.first(1) if %i[define_method alias_method].include?(node.name)
|
|
29
|
+
symbols = symbols.drop(1) if node.name == :def_delegators # the accessor comes first
|
|
30
|
+
symbols.each { found[it.unescaped] ||= "#{path}:#{node.location.start_line}" }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
node&.compact_child_nodes&.each { walk(it, path, found) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "herb"
|
|
4
|
+
|
|
5
|
+
module RubocopMinify
|
|
6
|
+
# The same rewrite in ERB templates (RuboCop doesn't read them): Herb finds
|
|
7
|
+
# the Ruby in each tag, Rewrite works on it as code that runs in a view.
|
|
8
|
+
module ERB
|
|
9
|
+
# Rewrite::Edits with offsets into the template.
|
|
10
|
+
def self.edits(template, rewrite)
|
|
11
|
+
found = []
|
|
12
|
+
collect = lambda do |node|
|
|
13
|
+
if node.class.name.start_with?("Herb::AST::ERB") && node.respond_to?(:content) && node.content &&
|
|
14
|
+
!node.class.name.end_with?("CommentNode") && !node.tag_opening&.value.to_s.start_with?("<%#")
|
|
15
|
+
base = node.content.range.from
|
|
16
|
+
rewrite.edits(node.content.value).each do |e|
|
|
17
|
+
found << Rewrite::Edit.new(e.from + base, e.to + base, e.replacement, e.message)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
node.compact_child_nodes.each(&collect)
|
|
21
|
+
end
|
|
22
|
+
collect.(Herb.parse(template).value)
|
|
23
|
+
found
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The template with every rewritable call rewritten, and what's left to
|
|
27
|
+
# do by hand.
|
|
28
|
+
def self.fix(template, rewrite)
|
|
29
|
+
5.times do
|
|
30
|
+
edits = edits(template, rewrite)
|
|
31
|
+
fixed = Rewrite.apply(template, edits)
|
|
32
|
+
return [fixed, edits.reject(&:replacement)] if fixed == template
|
|
33
|
+
|
|
34
|
+
template = fixed
|
|
35
|
+
end
|
|
36
|
+
[template, edits(template, rewrite).reject(&:replacement)]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module RubocopMinify
|
|
6
|
+
# Finds calls to the attribute methods activerecord-minimizer's strict mode
|
|
7
|
+
# removes and what each stood for:
|
|
8
|
+
#
|
|
9
|
+
# venue.featured? -> venue.featured.present? (venue.featured in a condition)
|
|
10
|
+
# venue.name? -> venue.name.present?
|
|
11
|
+
# venue.rank? -> venue.query_attribute(:rank)
|
|
12
|
+
# venue.name_changed?(to: x) -> venue.attribute_changed?(:name, to: x)
|
|
13
|
+
# before_save :x, if: :name_changed? -> if: -> { attribute_changed?(:name) }
|
|
14
|
+
#
|
|
15
|
+
# Which names are removed, for which attribute and with which column type
|
|
16
|
+
# comes from the ar_minimizer manifest (an ArMinimizer::Index). Receivers
|
|
17
|
+
# aren't typed: a name is rewritten wherever it's called on an explicit
|
|
18
|
+
# receiver, unless the app also defines a method of that name somewhere
|
|
19
|
+
# (+defined+), and then it's only reported. Calls without a receiver are
|
|
20
|
+
# only rewritten inside models and the app modules they include.
|
|
21
|
+
class Rewrite
|
|
22
|
+
# Byte offsets into the source. replacement nil: reported, not rewritten.
|
|
23
|
+
Edit = Data.define(:from, :to, :replacement, :message)
|
|
24
|
+
|
|
25
|
+
SEND = %i[send public_send __send__ try try!].freeze
|
|
26
|
+
# A removed name that core classes also define (key?, empty?, ...) is
|
|
27
|
+
# far more often one of those.
|
|
28
|
+
CORE = [Object, Kernel, Comparable, Enumerable, Hash, Array, String, Symbol, Integer, Float, NilClass, Range, Set].freeze
|
|
29
|
+
CALLBACK_OPTIONS = %i[if unless].freeze
|
|
30
|
+
|
|
31
|
+
# defined: { method name => "path:line" } of hand-written methods.
|
|
32
|
+
# views: the source is template code (self is a view, never a record).
|
|
33
|
+
def initialize(index, defined: {}, views: false)
|
|
34
|
+
@index = index
|
|
35
|
+
@defined = defined
|
|
36
|
+
@views = views
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def edits(source)
|
|
40
|
+
root = Prism.parse(source).value
|
|
41
|
+
@edits = []
|
|
42
|
+
@nesting = []
|
|
43
|
+
@class_level = 0
|
|
44
|
+
@locals = locals(root)
|
|
45
|
+
visit(root, false)
|
|
46
|
+
@edits.sort_by(&:from)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Applies the rewritable edits that don't overlap (nested ones are left
|
|
50
|
+
# for the next pass), from the end so offsets stay valid.
|
|
51
|
+
def self.apply(source, edits)
|
|
52
|
+
out = source.b
|
|
53
|
+
last = nil
|
|
54
|
+
edits.select(&:replacement).sort_by { -it.from }.each do |edit|
|
|
55
|
+
next if last && edit.to > last
|
|
56
|
+
|
|
57
|
+
out.bytesplice(edit.from, edit.to - edit.from, edit.replacement.b)
|
|
58
|
+
last = edit.from
|
|
59
|
+
end
|
|
60
|
+
out.force_encoding(source.encoding)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# cond: only the value's truthiness matters here (if/unless/while
|
|
66
|
+
# conditions, the operand of !, and/or inside those).
|
|
67
|
+
def visit(node, cond)
|
|
68
|
+
case node
|
|
69
|
+
when Prism::IfNode, Prism::UnlessNode, Prism::WhileNode, Prism::UntilNode
|
|
70
|
+
visit(node.predicate, true)
|
|
71
|
+
node.compact_child_nodes.each { visit(it, false) unless it.equal?(node.predicate) }
|
|
72
|
+
when Prism::AndNode, Prism::OrNode
|
|
73
|
+
visit(node.left, cond)
|
|
74
|
+
visit(node.right, cond)
|
|
75
|
+
when Prism::ParenthesesNode
|
|
76
|
+
body = node.body
|
|
77
|
+
if body.is_a?(Prism::StatementsNode) && body.body.size == 1
|
|
78
|
+
visit(body.body[0], cond)
|
|
79
|
+
else
|
|
80
|
+
visit_children(node)
|
|
81
|
+
end
|
|
82
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
83
|
+
@nesting.push(node.constant_path.slice.delete_prefix("::"))
|
|
84
|
+
outer, @class_level = @class_level, 0
|
|
85
|
+
visit_children(node)
|
|
86
|
+
@class_level = outer
|
|
87
|
+
@nesting.pop
|
|
88
|
+
when Prism::SingletonClassNode, Prism::DefNode
|
|
89
|
+
# class << self and def self.x: self is the class, not a record
|
|
90
|
+
class_level = node.is_a?(Prism::SingletonClassNode) || node.receiver
|
|
91
|
+
@class_level += 1 if class_level
|
|
92
|
+
visit_children(node)
|
|
93
|
+
@class_level -= 1 if class_level
|
|
94
|
+
when Prism::CallNode
|
|
95
|
+
if node.name == :! && node.receiver && !node.arguments
|
|
96
|
+
visit(node.receiver, true)
|
|
97
|
+
elsif (edit = call_edit(node, cond))
|
|
98
|
+
@edits << edit
|
|
99
|
+
visit_children(node) unless edit.replacement # its parts are copied into the replacement
|
|
100
|
+
else
|
|
101
|
+
symbol_edits(node)
|
|
102
|
+
visit_children(node)
|
|
103
|
+
end
|
|
104
|
+
when nil
|
|
105
|
+
else
|
|
106
|
+
visit_children(node)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def visit_children(node) = node.compact_child_nodes.each { visit(it, false) }
|
|
111
|
+
|
|
112
|
+
def call_edit(node, cond)
|
|
113
|
+
name = node.name.to_s
|
|
114
|
+
entry = @index[name] or return
|
|
115
|
+
return if node.block || core?(name)
|
|
116
|
+
return if node.arguments && !entry.args # the generated method takes none: something else
|
|
117
|
+
|
|
118
|
+
receiver = node.receiver
|
|
119
|
+
implicit = receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
120
|
+
if implicit
|
|
121
|
+
return if @views
|
|
122
|
+
|
|
123
|
+
case self_coverage(entry)
|
|
124
|
+
when :all then nil # self is always a record with the attribute
|
|
125
|
+
when :some then return edit(node, nil, "#{name} is removed by strict mode from #{models(entry)}, but not every class this code runs in has it: rewrite by hand")
|
|
126
|
+
else return # not a record with the attribute: an enum predicate, a helper, ...
|
|
127
|
+
end
|
|
128
|
+
else
|
|
129
|
+
if (where = @defined[name])
|
|
130
|
+
return edit(node, nil, "#{name} is removed by strict mode from #{models(entry)}, but #{where} defines a method of that name: rewrite calls on those records by hand")
|
|
131
|
+
end
|
|
132
|
+
if (others = @index.shadowed(name)).any?
|
|
133
|
+
return edit(node, nil, "#{name} is removed by strict mode from #{models(entry)}, but #{others.first(4).join(", ")} define#{"s" if others.size == 1} it otherwise (an enum, a delegate, ...): rewrite calls on those records by hand")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
replacement = call_replacement(node, entry, cond)
|
|
138
|
+
message = replacement ? "#{name} is removed by strict mode; use #{replacement}" : "#{name} is removed by strict mode; call #{entry.proxy}(#{symbol(entry.attr)}, ...) on the record itself (it's private)"
|
|
139
|
+
edit(node, replacement, message)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def call_replacement(node, entry, cond)
|
|
143
|
+
receiver = node.receiver
|
|
144
|
+
prefix = receiver ? "#{receiver.slice}#{node.call_operator}" : ""
|
|
145
|
+
|
|
146
|
+
if entry.proxy == "attribute?"
|
|
147
|
+
reader = receiver || !@locals.include?(entry.attr) ? "#{prefix}#{entry.attr}" : "self.#{entry.attr}"
|
|
148
|
+
case entry.query
|
|
149
|
+
when :truthy then return cond ? reader : "#{reader}.present?" # true, false, nil, or a date/time
|
|
150
|
+
when :string then return "#{reader}.present?"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
args = [symbol(entry.attr), node.arguments&.slice].compact.join(", ")
|
|
155
|
+
if (proxy = entry.public_proxy)
|
|
156
|
+
"#{prefix}#{proxy}(#{args})"
|
|
157
|
+
elsif receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
158
|
+
"#{prefix}#{entry.proxy}(#{args})" # private, callable on self
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Method names passed as symbols: callback conditions become lambdas;
|
|
163
|
+
# send/try/delegate are reported.
|
|
164
|
+
def symbol_edits(node)
|
|
165
|
+
if SEND.include?(node.name) && (first = node.arguments&.arguments&.first).is_a?(Prism::SymbolNode)
|
|
166
|
+
report_symbol(first, "#{first.unescaped} is removed by strict mode; call what it stood for instead of #{node.name}")
|
|
167
|
+
elsif node.name == :delegate && node.receiver.nil?
|
|
168
|
+
node.arguments&.arguments&.grep(Prism::SymbolNode)&.each do
|
|
169
|
+
report_symbol(it, "#{it.unescaped} is removed by strict mode, so delegating it raises; delegate the reader and define the method")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
keywords = node.arguments&.arguments&.grep(Prism::KeywordHashNode)&.flat_map(&:elements) || []
|
|
174
|
+
keywords.each do |pair|
|
|
175
|
+
next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) && CALLBACK_OPTIONS.include?(pair.key.unescaped.to_sym)
|
|
176
|
+
|
|
177
|
+
values = pair.value.is_a?(Prism::ArrayNode) ? pair.value.elements : [pair.value]
|
|
178
|
+
values.grep(Prism::SymbolNode).each { callback_edit(it) }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def callback_edit(sym)
|
|
183
|
+
name = sym.unescaped
|
|
184
|
+
entry = @index[name] or return
|
|
185
|
+
case self_coverage(entry)
|
|
186
|
+
when :all then nil
|
|
187
|
+
when :some then return report_symbol(sym, "#{name} is removed by strict mode from #{models(entry)}, but not every class this code runs in has it")
|
|
188
|
+
else return
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
body =
|
|
192
|
+
if entry.proxy == "attribute?" && entry.query == :truthy
|
|
193
|
+
entry.attr # callback conditions only test truthiness
|
|
194
|
+
elsif entry.proxy == "attribute?" && entry.query == :string
|
|
195
|
+
"#{entry.attr}.present?"
|
|
196
|
+
else
|
|
197
|
+
"#{entry.public_proxy || entry.proxy}(#{symbol(entry.attr)})"
|
|
198
|
+
end
|
|
199
|
+
@edits << edit(sym, "-> { #{body} }", "#{name} is removed by strict mode; use -> { #{body} }")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def report_symbol(sym, message)
|
|
203
|
+
@edits << edit(sym, nil, message) if @index[sym.unescaped] && !core?(sym.unescaped)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# On every record too (readonly?, frozen?, ...) when ActiveRecord is loaded.
|
|
207
|
+
def core?(name)
|
|
208
|
+
CORE.any? { it.method_defined?(name) || it.private_method_defined?(name) } ||
|
|
209
|
+
(defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.method_defined?(name))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def models(entry)
|
|
213
|
+
names = entry.classes
|
|
214
|
+
names.size > 4 ? "#{names.first(4).join(", ")} and #{names.size - 4} more" : names.join(", ")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def edit(node, replacement, message)
|
|
218
|
+
loc = node.location
|
|
219
|
+
Edit.new(loc.start_offset, loc.end_offset, replacement, message)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# :all when self is a record of a model with the entry's attribute
|
|
223
|
+
# wherever this code runs (in the model, its subclasses, or every model
|
|
224
|
+
# including this module), :some when only in some of them, nil otherwise.
|
|
225
|
+
def self_coverage(entry)
|
|
226
|
+
return if @nesting.empty? || @class_level.positive?
|
|
227
|
+
|
|
228
|
+
name = @nesting.join("::")
|
|
229
|
+
classes =
|
|
230
|
+
if @index.model?(name) then @index.with_descendants(name)
|
|
231
|
+
elsif @index.app_module?(name) then @index.includers_of(name)
|
|
232
|
+
else return
|
|
233
|
+
end
|
|
234
|
+
covered = classes.count { entry.classes.include?(it) }
|
|
235
|
+
if covered == classes.size then :all
|
|
236
|
+
elsif covered.positive? then :some
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def symbol(attr) = attr.to_sym.inspect
|
|
241
|
+
|
|
242
|
+
# Names of local variables and parameters anywhere in the file: a bare
|
|
243
|
+
# reader call with one of these names would read the local instead.
|
|
244
|
+
def locals(root)
|
|
245
|
+
names = Set.new
|
|
246
|
+
walk = lambda do |node|
|
|
247
|
+
type = node.class.name
|
|
248
|
+
names << node.name.to_s if (type.include?("LocalVariable") || type.end_with?("ParameterNode")) && node.respond_to?(:name) && node.name
|
|
249
|
+
node.compact_child_nodes.each(&walk)
|
|
250
|
+
end
|
|
251
|
+
walk.(root)
|
|
252
|
+
names
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop_minify/version"
|
|
4
|
+
require "rubocop_minify/rewrite"
|
|
5
|
+
require "rubocop_minify/definitions"
|
|
6
|
+
|
|
7
|
+
module RubocopMinify
|
|
8
|
+
autoload :ERB, "rubocop_minify/erb"
|
|
9
|
+
|
|
10
|
+
# One Rewrite per app root and settings: reading the manifest and scanning
|
|
11
|
+
# the app for definitions happens once per process.
|
|
12
|
+
def self.rewrite_for(root, manifest:, definition_paths:, views: false)
|
|
13
|
+
@rewrites ||= {}
|
|
14
|
+
@rewrites[[root, manifest, definition_paths, views]] ||= begin
|
|
15
|
+
path = File.expand_path(manifest, root)
|
|
16
|
+
if File.exist?(path)
|
|
17
|
+
require "ar_minimizer"
|
|
18
|
+
Rewrite.new(ArMinimizer::Manifest.load(path), defined: definitions(root, definition_paths), views: views)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.definitions(root, paths)
|
|
24
|
+
@definitions ||= {}
|
|
25
|
+
@definitions[[root, paths]] ||= Definitions.scan(root, paths)
|
|
26
|
+
end
|
|
27
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rubocop-minify
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sampo Kuokkanen
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord-minimizer
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: herb
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.11'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.11'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: lint_roller
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: prism
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.4'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.4'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.72'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.72'
|
|
82
|
+
description: RuboCop cop and ERB fixer that rewrite calls to the attribute methods
|
|
83
|
+
activerecord-minimizer's strict mode removes (name?, name_changed?, ...) into the
|
|
84
|
+
calls they stood for, from the app's manifest.
|
|
85
|
+
executables:
|
|
86
|
+
- minify-erb
|
|
87
|
+
extensions: []
|
|
88
|
+
extra_rdoc_files: []
|
|
89
|
+
files:
|
|
90
|
+
- CHANGELOG.md
|
|
91
|
+
- LICENSE.txt
|
|
92
|
+
- README.md
|
|
93
|
+
- config/default.yml
|
|
94
|
+
- exe/minify-erb
|
|
95
|
+
- lib/rubocop-minify.rb
|
|
96
|
+
- lib/rubocop/cop/minify/attribute_methods.rb
|
|
97
|
+
- lib/rubocop/minify/plugin.rb
|
|
98
|
+
- lib/rubocop_minify.rb
|
|
99
|
+
- lib/rubocop_minify/definitions.rb
|
|
100
|
+
- lib/rubocop_minify/erb.rb
|
|
101
|
+
- lib/rubocop_minify/rewrite.rb
|
|
102
|
+
- lib/rubocop_minify/version.rb
|
|
103
|
+
homepage: https://github.com/sampokuokkanen/rails-minify/tree/main/rubocop-minify
|
|
104
|
+
licenses:
|
|
105
|
+
- MIT
|
|
106
|
+
metadata:
|
|
107
|
+
source_code_uri: https://github.com/sampokuokkanen/rails-minify/tree/main/rubocop-minify
|
|
108
|
+
changelog_uri: https://github.com/sampokuokkanen/rails-minify/blob/main/rubocop-minify/CHANGELOG.md
|
|
109
|
+
bug_tracker_uri: https://github.com/sampokuokkanen/rails-minify/issues
|
|
110
|
+
rubygems_mfa_required: 'true'
|
|
111
|
+
default_lint_roller_plugin: RuboCop::Minify::Plugin
|
|
112
|
+
rdoc_options: []
|
|
113
|
+
require_paths:
|
|
114
|
+
- lib
|
|
115
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
116
|
+
requirements:
|
|
117
|
+
- - ">="
|
|
118
|
+
- !ruby/object:Gem::Version
|
|
119
|
+
version: '3.4'
|
|
120
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - ">="
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '0'
|
|
125
|
+
requirements: []
|
|
126
|
+
rubygems_version: 4.0.20
|
|
127
|
+
specification_version: 4
|
|
128
|
+
summary: RuboCop cops (and an ERB fixer) that rewrite code relying on what rails-minify
|
|
129
|
+
removes from Rails
|
|
130
|
+
test_files: []
|