rubocop-metz 0.5.3
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/LICENSE +21 -0
- data/config/default.yml +76 -0
- data/lib/metz/cop_metadata.rb +50 -0
- data/lib/metz/erb_ruby_extractor.rb +107 -0
- data/lib/metz/file_classifier.rb +46 -0
- data/lib/metz/haml_ruby_extractor.rb +33 -0
- data/lib/metz/slim_ruby_extractor.rb +34 -0
- data/lib/metz/template_ruby_extractor.rb +153 -0
- data/lib/rubocop/cop/metz/classes_too_long.rb +59 -0
- data/lib/rubocop/cop/metz/controllers_too_many_direct_collaborators.rb +235 -0
- data/lib/rubocop/cop/metz/demeter_train_wreck/type_inference.rb +234 -0
- data/lib/rubocop/cop/metz/demeter_train_wreck.rb +144 -0
- data/lib/rubocop/cop/metz/methods_too_long.rb +62 -0
- data/lib/rubocop/cop/metz/methods_too_many_parameters.rb +62 -0
- data/lib/rubocop/cop/metz/on_send_csend_bridge.rb +23 -0
- data/lib/rubocop/cop/metz/views_deep_navigation.rb +57 -0
- data/lib/rubocop/cop/metz_cops.rb +12 -0
- data/lib/rubocop/formatter/metz_json_formatter.rb +50 -0
- data/lib/rubocop/metz/plugin.rb +36 -0
- data/lib/rubocop/metz/version.rb +7 -0
- data/lib/rubocop-metz.rb +19 -0
- data/rubocop-metz.gemspec +42 -0
- metadata +111 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 38eb7e859b0bf45c9f80e8838678713d2aaedb5f18409eb802f53c0489fec544
|
|
4
|
+
data.tar.gz: 2972b51623d4c6027735b8a3cd9a1b85bb575a1778dd444f661e7f1bc7c37bf1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 68746f49ee4469e0f23e22eb8020ac3a6cdfbc5e117d6f524fcb6f1c88c95e6f6362bed07d6bf2e5f8896c4e3ea6f6b0ad8f02461366bd045b83348aadb54140
|
|
7
|
+
data.tar.gz: 7a242eacde2feca2b19fb116391f42923b59e64b0273432cfc7022eb33e94c5164cb8e1a3e0a122faca561c086d94aabc10cd3f2e12bd7848fa9e47252d2e302
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Salvador Fuentes Jr
|
|
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/config/default.yml
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Default configuration for the rubocop-metz plugin.
|
|
2
|
+
|
|
3
|
+
Metrics/ClassLength:
|
|
4
|
+
Enabled: false
|
|
5
|
+
|
|
6
|
+
Metrics/MethodLength:
|
|
7
|
+
Enabled: false
|
|
8
|
+
|
|
9
|
+
Metrics/ParameterLists:
|
|
10
|
+
Enabled: false
|
|
11
|
+
|
|
12
|
+
Metz/ClassesTooLong:
|
|
13
|
+
Description: 'Flags classes longer than the configured Max line count; the Metz default is stricter than core RuboCop.'
|
|
14
|
+
Enabled: true
|
|
15
|
+
Severity: refactor
|
|
16
|
+
Max: 100
|
|
17
|
+
CountComments: false
|
|
18
|
+
CountAsOne: []
|
|
19
|
+
|
|
20
|
+
Metz/MethodsTooLong:
|
|
21
|
+
Description: 'Flags methods longer than the configured Max line count; the Metz default is stricter than core RuboCop.'
|
|
22
|
+
Enabled: true
|
|
23
|
+
Severity: refactor
|
|
24
|
+
Max: 5
|
|
25
|
+
CountComments: false
|
|
26
|
+
CountAsOne: []
|
|
27
|
+
AllowedMethods: []
|
|
28
|
+
AllowedPatterns: []
|
|
29
|
+
|
|
30
|
+
Metz/MethodsTooManyParameters:
|
|
31
|
+
Description: 'Flags methods whose parameter list exceeds Max; the Metz default is stricter than core RuboCop.'
|
|
32
|
+
Enabled: true
|
|
33
|
+
Severity: refactor
|
|
34
|
+
Max: 4
|
|
35
|
+
|
|
36
|
+
Metz/DemeterTrainWreck:
|
|
37
|
+
Description: 'Detects Law-of-Demeter-violating object-graph traversal chains.'
|
|
38
|
+
Enabled: true
|
|
39
|
+
Severity: refactor
|
|
40
|
+
Max: 4
|
|
41
|
+
AllowedReceivers:
|
|
42
|
+
- Rails
|
|
43
|
+
- Arel
|
|
44
|
+
- Time
|
|
45
|
+
- Date
|
|
46
|
+
- DateTime
|
|
47
|
+
AllowedValueObjects:
|
|
48
|
+
- String
|
|
49
|
+
- Integer
|
|
50
|
+
- Float
|
|
51
|
+
- Symbol
|
|
52
|
+
- Array
|
|
53
|
+
- Hash
|
|
54
|
+
- Set
|
|
55
|
+
Exclude:
|
|
56
|
+
- 'spec/**/*'
|
|
57
|
+
- 'db/migrate/**/*'
|
|
58
|
+
VersionAdded: '0.1'
|
|
59
|
+
|
|
60
|
+
Metz/ControllersTooManyDirectCollaborators:
|
|
61
|
+
Description: 'Flags Rails controller actions that reach into more than MaxCollaborators distinct collaborator constants.'
|
|
62
|
+
Enabled: true
|
|
63
|
+
Severity: refactor
|
|
64
|
+
MaxCollaborators: 1
|
|
65
|
+
Include:
|
|
66
|
+
- 'app/controllers/**/*.rb'
|
|
67
|
+
VersionAdded: '0.1'
|
|
68
|
+
|
|
69
|
+
Metz/ViewsDeepNavigation:
|
|
70
|
+
Description: 'Flags Rails view templates whose ERB/HAML/SLIM expressions traverse the object graph deeper than MaxChainLength.'
|
|
71
|
+
Enabled: true
|
|
72
|
+
Severity: refactor
|
|
73
|
+
MaxChainLength: 3
|
|
74
|
+
Include:
|
|
75
|
+
- 'app/views/**/*.{erb,haml,slim}'
|
|
76
|
+
VersionAdded: '0.1'
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metz
|
|
4
|
+
# Metadata DSL for Metz cops. Provides class-level setter/reader macros for
|
|
5
|
+
# `why_it_matters`, `fix_safety`, and `suggested_next_moves`. Works whether
|
|
6
|
+
# `include`d (instance-style sub-modules) or `extend`ed onto the class.
|
|
7
|
+
#
|
|
8
|
+
# Public contract for a cop class that never invokes any setter:
|
|
9
|
+
# - why_it_matters -> "" (empty string)
|
|
10
|
+
# - fix_safety -> :manual (the safest, no-autocorrect tier)
|
|
11
|
+
# - suggested_next_moves -> [] (empty, frozen array)
|
|
12
|
+
module CopMetadata
|
|
13
|
+
UNSET = Object.new.freeze
|
|
14
|
+
private_constant :UNSET
|
|
15
|
+
|
|
16
|
+
DEFAULT_WHY_IT_MATTERS = ""
|
|
17
|
+
DEFAULT_FIX_SAFETY = :manual
|
|
18
|
+
DEFAULT_SUGGESTED_NEXT_MOVES = [].freeze
|
|
19
|
+
|
|
20
|
+
def self.included(base)
|
|
21
|
+
base.extend(self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def why_it_matters(value = UNSET)
|
|
25
|
+
return @metz_why_it_matters = value unless UNSET.equal?(value)
|
|
26
|
+
|
|
27
|
+
defined?(@metz_why_it_matters) ? @metz_why_it_matters : DEFAULT_WHY_IT_MATTERS
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fix_safety(value = UNSET)
|
|
31
|
+
return @metz_fix_safety = value unless UNSET.equal?(value)
|
|
32
|
+
|
|
33
|
+
defined?(@metz_fix_safety) ? @metz_fix_safety : DEFAULT_FIX_SAFETY
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def suggested_next_moves(value = UNSET)
|
|
37
|
+
return @metz_suggested_next_moves = value unless UNSET.equal?(value)
|
|
38
|
+
|
|
39
|
+
defined?(@metz_suggested_next_moves) ? @metz_suggested_next_moves : DEFAULT_SUGGESTED_NEXT_MOVES
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def metz_metadata
|
|
43
|
+
{
|
|
44
|
+
why_it_matters: why_it_matters,
|
|
45
|
+
fix_safety: fix_safety,
|
|
46
|
+
suggested_next_moves: suggested_next_moves
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "file_classifier"
|
|
4
|
+
|
|
5
|
+
module Metz
|
|
6
|
+
# Ruby extractor for ERB / HAML / SLIM view templates. Registered with
|
|
7
|
+
# `RuboCop::Runner.ruby_extractors` so that view-aware Metz cops (currently
|
|
8
|
+
# `Metz/ViewsDeepNavigation`) can run against ERB content. RuboCop's parser
|
|
9
|
+
# would otherwise emit `Lint/Syntax` for any `.erb` file because the raw
|
|
10
|
+
# template is not a valid Ruby program.
|
|
11
|
+
#
|
|
12
|
+
# The extractor scans for ERB tags, hands each `<% ... %>` / `<%= ... %>`
|
|
13
|
+
# body to RuboCop as its own `ProcessedSource`, and records the byte offset
|
|
14
|
+
# of the body inside the original template. RuboCop adds that offset back
|
|
15
|
+
# to every offense location, so reported line / column numbers point at
|
|
16
|
+
# the original `.erb` file -- not the synthetic snippet.
|
|
17
|
+
#
|
|
18
|
+
# Control-flow openers such as `<% if ... %>` are reduced to the condition
|
|
19
|
+
# expression. Other invalid fragments are skipped to avoid `Lint/Syntax`
|
|
20
|
+
# noise. ERB comments (`<%# ... %>`) are skipped as well.
|
|
21
|
+
module ErbRubyExtractor
|
|
22
|
+
ERB_TAG = /<%(?<kind>[-=#]?)(?<body>.*?)-?%>/m
|
|
23
|
+
CONTROL_CONDITION = /\A(?<prefix>\s*(?:if|unless|while|until|elsif|case)\b\s*)(?<expr>.*?)(?:\s+then)?\s*\z/m
|
|
24
|
+
TRAILING_DO_BLOCK = /\A(?<expr>.*?)(?<suffix>\s+do\b(?:\s*\|[^|]*\|)?\s*)\z/m
|
|
25
|
+
MAGIC_COMMENT = "# frozen_string_literal: true\n\n"
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def call(processed_source)
|
|
30
|
+
path = processed_source.path
|
|
31
|
+
return nil unless path && handles?(path)
|
|
32
|
+
|
|
33
|
+
snippets = extract_snippets(processed_source)
|
|
34
|
+
snippets << empty_snippet(processed_source) if snippets.empty?
|
|
35
|
+
snippets
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handles?(path)
|
|
39
|
+
File.extname(path.to_s) == ".erb" && ::Metz::FileClassifier.view?(path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def install!
|
|
43
|
+
extractors = RuboCop::Runner.ruby_extractors
|
|
44
|
+
extractor = method(:call)
|
|
45
|
+
extractors.unshift(extractor) unless extractors.include?(extractor)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_snippets(processed_source)
|
|
49
|
+
enumerate_matches(processed_source.raw_source).filter_map do |match|
|
|
50
|
+
snippet_for(match, processed_source)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def enumerate_matches(raw, pos: 0, matches: [])
|
|
55
|
+
while (match = raw.match(ERB_TAG, pos))
|
|
56
|
+
matches << match
|
|
57
|
+
pos = match.end(0)
|
|
58
|
+
end
|
|
59
|
+
matches
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def snippet_for(match, processed_source)
|
|
63
|
+
return if match[:kind] == "#"
|
|
64
|
+
|
|
65
|
+
ruby_version = processed_source.ruby_version || RUBY_VERSION.to_f
|
|
66
|
+
ps, offset = source_and_offset(match, ruby_version, processed_source.path)
|
|
67
|
+
return unless ps
|
|
68
|
+
|
|
69
|
+
{ offset: offset, processed_source: ps }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def source_and_offset(match, ruby_version, path)
|
|
73
|
+
body = match[:body]
|
|
74
|
+
ps = RuboCop::ProcessedSource.new(body, ruby_version, path)
|
|
75
|
+
return [ps, match.begin(:body)] if ps.valid_syntax?
|
|
76
|
+
return unless code_tag?(match[:kind])
|
|
77
|
+
|
|
78
|
+
expression_source(body, ruby_version, path, match.begin(:body))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def code_tag?(kind)
|
|
82
|
+
kind.empty? || kind == "-"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def expression_source(body, ruby_version, path, body_offset)
|
|
86
|
+
match = body.match(CONTROL_CONDITION) || body.match(TRAILING_DO_BLOCK)
|
|
87
|
+
return unless match
|
|
88
|
+
|
|
89
|
+
ps = RuboCop::ProcessedSource.new(control_expression(match[:expr]), ruby_version, path)
|
|
90
|
+
[ps, expression_offset(body_offset, match)] if ps.valid_syntax?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def control_expression(expr)
|
|
94
|
+
"#{MAGIC_COMMENT}#{expr}\n"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def expression_offset(body_offset, match)
|
|
98
|
+
body_offset + match.begin(:expr) - MAGIC_COMMENT.bytesize
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def empty_snippet(processed_source)
|
|
102
|
+
ruby_version = processed_source.ruby_version || RUBY_VERSION.to_f
|
|
103
|
+
ps = RuboCop::ProcessedSource.new("", ruby_version, processed_source.path)
|
|
104
|
+
{ offset: 0, processed_source: ps }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metz
|
|
4
|
+
# Path-based classifier for Rails-shaped source files. Used by the
|
|
5
|
+
# Rails-aware cops (`Metz/ControllersTooManyDirectCollaborators`,
|
|
6
|
+
# `Metz/ViewsDeepNavigation`) to decide whether a file is in scope for
|
|
7
|
+
# those cops. Predicates accept either a relative or an absolute path and
|
|
8
|
+
# always return a strict boolean.
|
|
9
|
+
#
|
|
10
|
+
# The matching rules are deliberately syntactic: a path is a controller
|
|
11
|
+
# iff some path segment chain ends in `app/controllers/<...>.rb`, a view
|
|
12
|
+
# iff `app/views/<...>.{erb,haml,slim}`, a model iff `app/models/<...>.rb`.
|
|
13
|
+
# We never touch the filesystem from here -- the inputs are already paths
|
|
14
|
+
# that RuboCop or a caller has handed us.
|
|
15
|
+
#
|
|
16
|
+
# Phase 3 reuse: future cross-file analyzers (project index, service-soup
|
|
17
|
+
# detector, repeated-branching detector) need the same path-shape question
|
|
18
|
+
# answered. Keep predicates pure, allocation-light, and dependency-free
|
|
19
|
+
# so they can be reused as-is from the wrapper or future analyzers.
|
|
20
|
+
module FileClassifier
|
|
21
|
+
CONTROLLER_PATTERN = %r{(?:\A|/)app/controllers/[^\0]+\.rb\z}
|
|
22
|
+
VIEW_PATTERN = %r{(?:\A|/)app/views/[^\0]+\.(?:erb|haml|slim)\z}
|
|
23
|
+
MODEL_PATTERN = %r{(?:\A|/)app/models/[^\0]+\.rb\z}
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def controller?(path)
|
|
28
|
+
normalize(path).match?(CONTROLLER_PATTERN)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def view?(path)
|
|
32
|
+
normalize(path).match?(VIEW_PATTERN)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def model?(path)
|
|
36
|
+
normalize(path).match?(MODEL_PATTERN)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Windows-style paths arrive with backslash separators when the caller is
|
|
40
|
+
# an editor running on Windows or a CI step that shells out via `cmd.exe`.
|
|
41
|
+
# Normalize to forward slashes so the regexes below stay POSIX-shaped.
|
|
42
|
+
def normalize(path)
|
|
43
|
+
path.to_s.tr("\\", "/")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "template_ruby_extractor"
|
|
4
|
+
|
|
5
|
+
module Metz
|
|
6
|
+
# Ruby extractor for HAML view templates. Mirrors `Metz::ErbRubyExtractor`:
|
|
7
|
+
# registered with `RuboCop::Runner.ruby_extractors` so view-aware Metz cops
|
|
8
|
+
# (currently `Metz/ViewsDeepNavigation`) can run against HAML content.
|
|
9
|
+
#
|
|
10
|
+
# Recognises three HAML constructs that carry Ruby:
|
|
11
|
+
#
|
|
12
|
+
# * `= ruby_expr` - output script (escaped)
|
|
13
|
+
# * `- ruby_stmt` - silent script
|
|
14
|
+
# * `:ruby` filter - indented block of plain Ruby
|
|
15
|
+
#
|
|
16
|
+
# Each tag's body is handed to RuboCop as its own `ProcessedSource` whose
|
|
17
|
+
# offset points at the body's column in the original template. Tags whose
|
|
18
|
+
# body is not valid Ruby on its own are silently skipped (we never want to
|
|
19
|
+
# surface `Lint/Syntax` from a partial HAML fragment).
|
|
20
|
+
module HamlRubyExtractor
|
|
21
|
+
extend TemplateRubyExtractor
|
|
22
|
+
|
|
23
|
+
EXPR_LINE = /\A(?<lead>[ \t]*(?:[%.#][^\s=]*)?)=\s(?<body>.*)$/
|
|
24
|
+
STMT_LINE = /\A(?<lead>[ \t]*)-(?!#)\s(?<body>.*)$/
|
|
25
|
+
FILTER_LINE = /\A(?<indent>[ \t]*):ruby[ \t]*$/
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def handles?(path)
|
|
30
|
+
File.extname(path.to_s) == ".haml" && ::Metz::FileClassifier.view?(path)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "template_ruby_extractor"
|
|
4
|
+
|
|
5
|
+
module Metz
|
|
6
|
+
# Ruby extractor for Slim view templates. Mirrors `Metz::ErbRubyExtractor`:
|
|
7
|
+
# registered with `RuboCop::Runner.ruby_extractors` so view-aware Metz cops
|
|
8
|
+
# (currently `Metz/ViewsDeepNavigation`) can run against Slim content.
|
|
9
|
+
#
|
|
10
|
+
# Recognises four Slim constructs that carry Ruby:
|
|
11
|
+
#
|
|
12
|
+
# * `= ruby_expr` - output script (escaped)
|
|
13
|
+
# * `== ruby_expr` - output script (raw / unescaped)
|
|
14
|
+
# * `- ruby_stmt` - silent script
|
|
15
|
+
# * `ruby:` filter - indented block of plain Ruby
|
|
16
|
+
#
|
|
17
|
+
# Each tag's body is handed to RuboCop as its own `ProcessedSource` whose
|
|
18
|
+
# offset points at the body's column in the original template. Tags whose
|
|
19
|
+
# body is not valid Ruby on its own are silently skipped (we never want to
|
|
20
|
+
# surface `Lint/Syntax` from a partial Slim fragment).
|
|
21
|
+
module SlimRubyExtractor
|
|
22
|
+
extend TemplateRubyExtractor
|
|
23
|
+
|
|
24
|
+
EXPR_LINE = /\A(?<lead>[ \t]*(?:[a-zA-Z][\w-]*|[.#][^\s=]*)?)==?\s(?<body>.*)$/
|
|
25
|
+
STMT_LINE = /\A(?<lead>[ \t]*)-\s(?<body>.*)$/
|
|
26
|
+
FILTER_LINE = /\A(?<indent>[ \t]*)ruby:[ \t]*$/
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def handles?(path)
|
|
31
|
+
File.extname(path.to_s) == ".slim" && ::Metz::FileClassifier.view?(path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "file_classifier"
|
|
4
|
+
|
|
5
|
+
module Metz
|
|
6
|
+
# Shared iteration backbone for line-based Ruby extractors that target
|
|
7
|
+
# indentation-driven template languages (HAML, Slim). Concrete extractors
|
|
8
|
+
# define three patterns -- an expression line, a statement line, and a
|
|
9
|
+
# filter-block start -- and this module walks the raw source line by
|
|
10
|
+
# line, hands every matching body to RuboCop as its own ProcessedSource,
|
|
11
|
+
# and tracks the byte offset of each body inside the original template
|
|
12
|
+
# so offense locations refer back to the source file rather than the
|
|
13
|
+
# synthetic snippet.
|
|
14
|
+
module TemplateRubyExtractor
|
|
15
|
+
def call(processed_source)
|
|
16
|
+
path = processed_source.path
|
|
17
|
+
return nil unless path && handles?(path)
|
|
18
|
+
|
|
19
|
+
snippets = Walker.new(self, processed_source).snippets
|
|
20
|
+
snippets << empty_snippet(processed_source) if snippets.empty?
|
|
21
|
+
snippets
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def install!
|
|
25
|
+
extractors = RuboCop::Runner.ruby_extractors
|
|
26
|
+
entrypoint = method(:call)
|
|
27
|
+
extractors.unshift(entrypoint) unless extractors.include?(entrypoint)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def empty_snippet(processed_source)
|
|
31
|
+
ps = build_processed_source("", processed_source)
|
|
32
|
+
{ offset: 0, processed_source: ps }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_snippet(body, body_offset, processed_source)
|
|
36
|
+
return nil if body.strip.empty?
|
|
37
|
+
|
|
38
|
+
ps = build_processed_source(body, processed_source)
|
|
39
|
+
return nil unless ps.valid_syntax?
|
|
40
|
+
|
|
41
|
+
{ offset: body_offset, processed_source: ps }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_processed_source(body, processed_source)
|
|
45
|
+
ruby_version = processed_source.ruby_version || RUBY_VERSION.to_f
|
|
46
|
+
RuboCop::ProcessedSource.new(body, ruby_version, processed_source.path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Walks lines of a template, dispatching each line to the owner module's
|
|
50
|
+
# match patterns. Holds the running byte offset, the line index, and the
|
|
51
|
+
# accumulating snippet list so the owner module can stay stateless.
|
|
52
|
+
class Walker
|
|
53
|
+
def initialize(owner, processed_source)
|
|
54
|
+
@owner = owner
|
|
55
|
+
@processed_source = processed_source
|
|
56
|
+
@cursor = Cursor.new(processed_source.raw_source.lines)
|
|
57
|
+
walk
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
attr_reader :snippets
|
|
61
|
+
|
|
62
|
+
def walk
|
|
63
|
+
@snippets = []
|
|
64
|
+
@snippets << next_snippet until @cursor.done?
|
|
65
|
+
@snippets.compact!
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def next_snippet
|
|
69
|
+
line = @cursor.line
|
|
70
|
+
filter = line.match(@owner::FILTER_LINE)
|
|
71
|
+
return consume_filter(filter) if filter
|
|
72
|
+
|
|
73
|
+
consume_inline(line.match(@owner::EXPR_LINE) || line.match(@owner::STMT_LINE))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def consume_inline(match)
|
|
77
|
+
snippet = inline_snippet(match)
|
|
78
|
+
@cursor.advance(1)
|
|
79
|
+
snippet
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def inline_snippet(match)
|
|
83
|
+
return nil if match.nil?
|
|
84
|
+
|
|
85
|
+
body_offset = @cursor.offset + match.begin(:body)
|
|
86
|
+
@owner.build_snippet(match[:body], body_offset, @processed_source)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def consume_filter(match)
|
|
90
|
+
body_offset = @cursor.offset + @cursor.line.length
|
|
91
|
+
body_lines = collect_filter_body(match[:indent].length)
|
|
92
|
+
snippet = build_filter_snippet(body_lines, body_offset)
|
|
93
|
+
@cursor.advance(1 + body_lines.size)
|
|
94
|
+
snippet
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def collect_filter_body(base_indent)
|
|
98
|
+
body = []
|
|
99
|
+
body << @cursor.peek(body.size + 1) while filter_member?(body.size + 1, base_indent)
|
|
100
|
+
body
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def filter_member?(lookahead, base_indent)
|
|
104
|
+
line = @cursor.peek(lookahead)
|
|
105
|
+
return false if line.nil?
|
|
106
|
+
|
|
107
|
+
line.match?(/\A\s*\z/) || leading_indent(line) > base_indent
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def leading_indent(line)
|
|
111
|
+
line[/\A[ \t]*/].length
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_filter_snippet(body_lines, body_offset)
|
|
115
|
+
return nil if body_lines.empty?
|
|
116
|
+
|
|
117
|
+
@owner.build_snippet(body_lines.join, body_offset, @processed_source)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Tracks the running byte offset and line index while walking a template.
|
|
122
|
+
class Cursor
|
|
123
|
+
def initialize(lines)
|
|
124
|
+
@lines = lines
|
|
125
|
+
@index = 0
|
|
126
|
+
@offset = 0
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
attr_reader :offset
|
|
130
|
+
|
|
131
|
+
def done?
|
|
132
|
+
@index >= @lines.size
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def line
|
|
136
|
+
@lines[@index]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def peek(lookahead)
|
|
140
|
+
@lines[@index + lookahead]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def advance(count)
|
|
144
|
+
count.times do
|
|
145
|
+
break if done?
|
|
146
|
+
|
|
147
|
+
@offset += @lines[@index].length
|
|
148
|
+
@index += 1
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
require_relative "../../../metz/cop_metadata"
|
|
5
|
+
|
|
6
|
+
module RuboCop
|
|
7
|
+
module Cop
|
|
8
|
+
module Metz
|
|
9
|
+
# Flags classes whose body exceeds the configured `Max` line count.
|
|
10
|
+
# Wraps the semantics of core's `Metrics/ClassLength` with a stricter
|
|
11
|
+
# default of 100 lines.
|
|
12
|
+
class ClassesTooLong < RuboCop::Cop::Base
|
|
13
|
+
include RuboCop::Cop::CodeLength
|
|
14
|
+
extend ::Metz::CopMetadata
|
|
15
|
+
|
|
16
|
+
why_it_matters "Long classes accumulate responsibilities and become hard to change safely."
|
|
17
|
+
fix_safety :manual
|
|
18
|
+
suggested_next_moves [
|
|
19
|
+
"Extract collaborating objects that own a coherent slice of the behavior.",
|
|
20
|
+
"Move data-shaping helpers onto value objects.",
|
|
21
|
+
"Split persistence, presentation, and orchestration concerns into separate classes."
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
MSG = "Class has too many lines. [%<length>d/%<max>d]"
|
|
25
|
+
|
|
26
|
+
def on_class(node)
|
|
27
|
+
check_code_length(node)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def on_sclass(node)
|
|
31
|
+
return if node.each_ancestor(:class).any?
|
|
32
|
+
|
|
33
|
+
on_class(node)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def on_casgn(node)
|
|
37
|
+
block_node = node.expression || find_expression_within_parent(node.parent)
|
|
38
|
+
return unless block_node.respond_to?(:class_definition?) && block_node.class_definition?
|
|
39
|
+
|
|
40
|
+
check_code_length(block_node)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def message(length, max_length)
|
|
46
|
+
format(MSG, length: length, max: max_length)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def find_expression_within_parent(parent)
|
|
50
|
+
if parent&.assignment?
|
|
51
|
+
parent.expression
|
|
52
|
+
elsif parent&.parent&.masgn_type?
|
|
53
|
+
parent.parent.expression
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|