Module: Metz::ErbRubyExtractor

Defined in:
lib/metz/erb_ruby_extractor.rb

Overview

Ruby extractor for ERB / HAML / SLIM view templates. Registered with RuboCop::Runner.ruby_extractors so that view-aware Metz cops (currently Metz/ViewsDeepNavigation) can run against ERB content. RuboCop's parser would otherwise emit Lint/Syntax for any .erb file because the raw template is not a valid Ruby program.

The extractor scans for ERB tags, hands each <% ... %> / <%= ... %> body to RuboCop as its own ProcessedSource, and records the byte offset of the body inside the original template. RuboCop adds that offset back to every offense location, so reported line / column numbers point at the original .erb file -- not the synthetic snippet.

Control-flow openers such as <% if ... %> are reduced to the condition expression. Other invalid fragments are skipped to avoid Lint/Syntax noise. ERB comments (<%# ... %>) are skipped as well.

Constant Summary collapse

ERB_TAG =
/<%(?<kind>[-=#]?)(?<body>.*?)-?%>/m
CONTROL_CONDITION =
/\A(?<prefix>\s*(?:if|unless|while|until|elsif|case)\b\s*)(?<expr>.*?)(?:\s+then)?\s*\z/m
TRAILING_DO_BLOCK =
/\A(?<expr>.*?)(?<suffix>\s+do\b(?:\s*\|[^|]*\|)?\s*)\z/m
MAGIC_COMMENT =
"# frozen_string_literal: true\n\n"

Class Method Summary collapse

Class Method Details

.call(processed_source) ⇒ Object



29
30
31
32
33
34
35
36
# File 'lib/metz/erb_ruby_extractor.rb', line 29

def call(processed_source)
  path = processed_source.path
  return nil unless path && handles?(path)

  snippets = extract_snippets(processed_source)
  snippets << empty_snippet(processed_source) if snippets.empty?
  snippets
end

.code_tag?(kind) ⇒ Boolean

Returns:

  • (Boolean)


81
82
83
# File 'lib/metz/erb_ruby_extractor.rb', line 81

def code_tag?(kind)
  kind.empty? || kind == "-"
end

.control_expression(expr) ⇒ Object



93
94
95
# File 'lib/metz/erb_ruby_extractor.rb', line 93

def control_expression(expr)
  "#{MAGIC_COMMENT}#{expr}\n"
end

.empty_snippet(processed_source) ⇒ Object



101
102
103
104
105
# File 'lib/metz/erb_ruby_extractor.rb', line 101

def empty_snippet(processed_source)
  ruby_version = processed_source.ruby_version || RUBY_VERSION.to_f
  ps = RuboCop::ProcessedSource.new("", ruby_version, processed_source.path)
  { offset: 0, processed_source: ps }
end

.enumerate_matches(raw, pos: 0, matches: []) ⇒ Object



54
55
56
57
58
59
60
# File 'lib/metz/erb_ruby_extractor.rb', line 54

def enumerate_matches(raw, pos: 0, matches: [])
  while (match = raw.match(ERB_TAG, pos))
    matches << match
    pos = match.end(0)
  end
  matches
end

.expression_offset(body_offset, match) ⇒ Object



97
98
99
# File 'lib/metz/erb_ruby_extractor.rb', line 97

def expression_offset(body_offset, match)
  body_offset + match.begin(:expr) - MAGIC_COMMENT.bytesize
end

.expression_source(body, ruby_version, path, body_offset) ⇒ Object



85
86
87
88
89
90
91
# File 'lib/metz/erb_ruby_extractor.rb', line 85

def expression_source(body, ruby_version, path, body_offset)
  match = body.match(CONTROL_CONDITION) || body.match(TRAILING_DO_BLOCK)
  return unless match

  ps = RuboCop::ProcessedSource.new(control_expression(match[:expr]), ruby_version, path)
  [ps, expression_offset(body_offset, match)] if ps.valid_syntax?
end

.extract_snippets(processed_source) ⇒ Object



48
49
50
51
52
# File 'lib/metz/erb_ruby_extractor.rb', line 48

def extract_snippets(processed_source)
  enumerate_matches(processed_source.raw_source).filter_map do |match|
    snippet_for(match, processed_source)
  end
end

.handles?(path) ⇒ Boolean

Returns:

  • (Boolean)


38
39
40
# File 'lib/metz/erb_ruby_extractor.rb', line 38

def handles?(path)
  File.extname(path.to_s) == ".erb" && ::Metz::FileClassifier.view?(path)
end

.install!Object



42
43
44
45
46
# File 'lib/metz/erb_ruby_extractor.rb', line 42

def install!
  extractors = RuboCop::Runner.ruby_extractors
  extractor = method(:call)
  extractors.unshift(extractor) unless extractors.include?(extractor)
end

.snippet_for(match, processed_source) ⇒ Object



62
63
64
65
66
67
68
69
70
# File 'lib/metz/erb_ruby_extractor.rb', line 62

def snippet_for(match, processed_source)
  return if match[:kind] == "#"

  ruby_version = processed_source.ruby_version || RUBY_VERSION.to_f
  ps, offset = source_and_offset(match, ruby_version, processed_source.path)
  return unless ps

  { offset: offset, processed_source: ps }
end

.source_and_offset(match, ruby_version, path) ⇒ Object



72
73
74
75
76
77
78
79
# File 'lib/metz/erb_ruby_extractor.rb', line 72

def source_and_offset(match, ruby_version, path)
  body = match[:body]
  ps = RuboCop::ProcessedSource.new(body, ruby_version, path)
  return [ps, match.begin(:body)] if ps.valid_syntax?
  return unless code_tag?(match[:kind])

  expression_source(body, ruby_version, path, match.begin(:body))
end