i18nlint 1.0.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 +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +206 -0
- data/exe/i18nlint +5 -0
- data/lib/i18nlint/cli.rb +131 -0
- data/lib/i18nlint/cloneable_struct.rb +14 -0
- data/lib/i18nlint/configuration.rb +120 -0
- data/lib/i18nlint/enumerator.rb +106 -0
- data/lib/i18nlint/error.rb +25 -0
- data/lib/i18nlint/highlighters/below_line.rb +71 -0
- data/lib/i18nlint/highlighters/colour.rb +38 -0
- data/lib/i18nlint/highlighters.rb +17 -0
- data/lib/i18nlint/linter.rb +158 -0
- data/lib/i18nlint/railtie.rb +19 -0
- data/lib/i18nlint/registry.rb +53 -0
- data/lib/i18nlint/rspec/expect_offence.rb +141 -0
- data/lib/i18nlint/rule.rb +128 -0
- data/lib/i18nlint/rule_helper.rb +97 -0
- data/lib/i18nlint/rules/built_in/duplicates.rb +49 -0
- data/lib/i18nlint/rules/built_in/interpolations.rb +59 -0
- data/lib/i18nlint/rules/built_in/match.rb +134 -0
- data/lib/i18nlint/version.rb +5 -0
- data/lib/i18nlint/yaml_with_lines.rb +254 -0
- data/lib/i18nlint.rb +40 -0
- metadata +102 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
require "i18nlint/rule_helper"
|
|
5
|
+
|
|
6
|
+
module I18nLint
|
|
7
|
+
# A registered offence as reported by a rule.
|
|
8
|
+
FileOffence = Struct.new(:rule, :filepath, :lineno, :text, :message, :highlight, keyword_init: true) do
|
|
9
|
+
def key = nil
|
|
10
|
+
def locale = nil
|
|
11
|
+
end
|
|
12
|
+
SegmentOffence = Struct.new(*FileOffence.members, :value, :locale, :key, keyword_init: true)
|
|
13
|
+
CompareSegmentOffence = Struct.new(*SegmentOffence.members, :source_offence, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# Base class for rules to extend.
|
|
16
|
+
class Rule
|
|
17
|
+
include RuleHelper
|
|
18
|
+
|
|
19
|
+
attr_reader :config, :message
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
class RuleClasses < Array; include Singleton; end
|
|
23
|
+
private_constant :RuleClasses
|
|
24
|
+
|
|
25
|
+
def inherited(rule_class)
|
|
26
|
+
RuleClasses.instance << rule_class
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def rule_classes = RuleClasses.instance
|
|
31
|
+
|
|
32
|
+
def enabled_by_default? = @enable_by_default.nil? || @enable_by_default
|
|
33
|
+
def on_init_blocks = @on_init_blocks ||= []
|
|
34
|
+
|
|
35
|
+
def enable_by_default(bool)
|
|
36
|
+
@enable_by_default = bool
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_init(&block)
|
|
40
|
+
on_init_blocks << block
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def rule_key
|
|
44
|
+
name.to_s.gsub(/^(::)?I18nLint::Rules?::/, "").gsub("::", "/")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(config = {})
|
|
49
|
+
@config = config
|
|
50
|
+
@message = config["Message"] if config.is_a?(Hash)
|
|
51
|
+
@offences = []
|
|
52
|
+
|
|
53
|
+
@exclude = Array((config["Exclude"] if config.is_a?(Hash)))
|
|
54
|
+
@always_include = @exclude.empty?
|
|
55
|
+
|
|
56
|
+
self.class.on_init_blocks.each { |b| instance_exec(&b) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def describe
|
|
60
|
+
desc = " #{description}" if respond_to?(:description) && description
|
|
61
|
+
desc ||= " #{self.class.description}" if self.class.respond_to?(:description) && self.class.description
|
|
62
|
+
"#{self.class.rule_key}#{desc}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def excluded?(filepath)
|
|
66
|
+
return false if @always_include
|
|
67
|
+
|
|
68
|
+
path = Pathname.new(filepath)
|
|
69
|
+
@exclude.any? { |dir_pattern| path.fnmatch(dir_pattern) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def add_offence(item, message = nil, highlight: nil)
|
|
73
|
+
case item
|
|
74
|
+
when File
|
|
75
|
+
add_file_offence(item, message, highlight:)
|
|
76
|
+
when Segment
|
|
77
|
+
add_segment_offence(item, message, highlight:)
|
|
78
|
+
else
|
|
79
|
+
raise ArgumentError, "inapplicable offence type #{item.class}: #{item}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def add_file_offence(file, msg = nil, lineno: nil, source: nil, highlight: nil)
|
|
84
|
+
@offences << FileOffence.new(
|
|
85
|
+
rule: describe,
|
|
86
|
+
filepath: file.filepath,
|
|
87
|
+
lineno:,
|
|
88
|
+
text: source, # don't print the whole file contents in the offence
|
|
89
|
+
message: msg || message,
|
|
90
|
+
highlight:
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def add_segment_offence(segment, msg = nil, highlight: nil)
|
|
95
|
+
@offences << make_segment_offence(SegmentOffence, segment, describe, msg || message, highlight:)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def add_segment_compare_offence(segment, source_segment, msg = nil, src_msg = nil, highlight: nil, # rubocop:disable Metrics/ParameterLists
|
|
99
|
+
source_highlight: nil)
|
|
100
|
+
desc = describe
|
|
101
|
+
o = make_segment_offence(CompareSegmentOffence, segment, desc, msg || message, highlight:)
|
|
102
|
+
o.source_offence = make_segment_offence(SegmentOffence, source_segment, desc, src_msg,
|
|
103
|
+
highlight: source_highlight)
|
|
104
|
+
@offences << o
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def take_offences
|
|
108
|
+
@offences
|
|
109
|
+
ensure
|
|
110
|
+
@offences = []
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def make_segment_offence(offence_class, segment, description, message, highlight:)
|
|
116
|
+
offence_class.new(
|
|
117
|
+
rule: description,
|
|
118
|
+
filepath: segment.filepath,
|
|
119
|
+
lineno: segment.lineno,
|
|
120
|
+
locale: segment.locale,
|
|
121
|
+
key: segment.key,
|
|
122
|
+
text: segment.text,
|
|
123
|
+
message:,
|
|
124
|
+
highlight:
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nLint
|
|
4
|
+
# Various methods to help construct rules.
|
|
5
|
+
module RuleHelper
|
|
6
|
+
# For each item in arr_a, find the first comparable item in arr_b and remove them both. Returns the removed items.
|
|
7
|
+
def xor!(arr_a, arr_b, &compare)
|
|
8
|
+
compare = xor_comparison_proc(&compare)
|
|
9
|
+
|
|
10
|
+
deleted = []
|
|
11
|
+
|
|
12
|
+
arr_a.delete_if do |a|
|
|
13
|
+
if (i = arr_b.find_index { |b| compare[a, b] })
|
|
14
|
+
deleted << [a, arr_b[i]]
|
|
15
|
+
arr_b.delete_at(i)
|
|
16
|
+
next true
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
[deleted.map(&:first), deleted.map(&:last)]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def xor_comparison_proc(&block)
|
|
24
|
+
if !block_given?
|
|
25
|
+
proc { |a, b| a == b }
|
|
26
|
+
elsif block.arity != 2
|
|
27
|
+
proc { |a, b| block[a] == block[b] }
|
|
28
|
+
else
|
|
29
|
+
block
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
private :xor_comparison_proc
|
|
33
|
+
|
|
34
|
+
def match_with_highlights(string, regex)
|
|
35
|
+
string.enum_for(:scan, regex).map do |matches|
|
|
36
|
+
[matches[0], Regexp.last_match.offset(0)]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Use this to XOR results found using `match_with_highlights`.
|
|
41
|
+
def xor_highlights!(arr_a, arr_b) = xor!(arr_a, arr_b, &:first)
|
|
42
|
+
|
|
43
|
+
def self.included(base) = base.extend ClassMethods
|
|
44
|
+
|
|
45
|
+
module ClassMethods # rubocop:disable Style/Documentation
|
|
46
|
+
def def_segment_comparison(includes_highlights: false, message: nil, source_message: nil, &process_segment)
|
|
47
|
+
include EasySegmentComparison
|
|
48
|
+
include includes_highlights ? CompareWithHighlights : CompareNoHighlights
|
|
49
|
+
|
|
50
|
+
define_method(:process_message) { message }
|
|
51
|
+
define_method(:process_source_message) { source_message }
|
|
52
|
+
define_method(:process_segment, &process_segment)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
module CompareNoHighlights # rubocop:disable Style/Documentation
|
|
56
|
+
def compare!(these, source) = xor!(these, source)
|
|
57
|
+
def process_values(values) = values
|
|
58
|
+
def process_highlights(_values) = nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
module CompareWithHighlights # rubocop:disable Style/Documentation
|
|
62
|
+
def compare!(these, source) = xor!(these, source, &:first)
|
|
63
|
+
def process_values(values) = values.map(&:first)
|
|
64
|
+
|
|
65
|
+
def process_highlights(values)
|
|
66
|
+
highlights = values.map(&:last)
|
|
67
|
+
highlights.empty? ? nil : highlights
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
module EasySegmentComparison # rubocop:disable Style/Documentation
|
|
72
|
+
def on_segment_comparison(segment, source_segment)
|
|
73
|
+
these = process_segment(segment)
|
|
74
|
+
source = process_segment(source_segment)
|
|
75
|
+
|
|
76
|
+
compare!(these, source)
|
|
77
|
+
|
|
78
|
+
return if these.empty? && source.empty?
|
|
79
|
+
|
|
80
|
+
add_segment_compare_offence(
|
|
81
|
+
segment, source_segment,
|
|
82
|
+
string_or_callable(process_message, process_values(these), segment, source_segment),
|
|
83
|
+
string_or_callable(process_source_message, process_values(source), segment, source_segment),
|
|
84
|
+
highlight: process_highlights(these), source_highlight: process_highlights(source)
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def string_or_callable(val, *args)
|
|
91
|
+
val = val.call(*args) if val.is_a? Proc
|
|
92
|
+
val
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nLint
|
|
4
|
+
module Rules
|
|
5
|
+
module BuiltIn
|
|
6
|
+
# Check for duplicate segment keys across all given files. Differing load order determines which duplicate segment
|
|
7
|
+
# is the last added to the I18n store, so don't bother reporting on that; just highlight all the duplicates.
|
|
8
|
+
class Duplicates < Rule
|
|
9
|
+
enable_by_default true
|
|
10
|
+
|
|
11
|
+
# Track every segment key. Add offence when any key is encountered twice or more. Ensure the first occurrence is
|
|
12
|
+
# offended, and only once.
|
|
13
|
+
on_init do
|
|
14
|
+
@keys = Set.new
|
|
15
|
+
@offended_first_occurrence = Hash.new { |h, locale| h[locale] = {} }
|
|
16
|
+
end
|
|
17
|
+
attr_reader :keys, :offended_first_occurrence
|
|
18
|
+
|
|
19
|
+
def on_segment(segment)
|
|
20
|
+
check_file_duplicates(segment)
|
|
21
|
+
|
|
22
|
+
first_occurrence = recorded_first_occurrence(segment)
|
|
23
|
+
return if first_occurrence == true
|
|
24
|
+
|
|
25
|
+
add_segment_offence(first_occurrence) if first_occurrence
|
|
26
|
+
add_segment_offence(segment)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def recorded_first_occurrence(segment)
|
|
32
|
+
if keys.add?([segment.locale, segment.key])
|
|
33
|
+
offended_first_occurrence[segment.locale][segment.key] = segment
|
|
34
|
+
return true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
offended_first_occurrence[segment.locale].delete(segment.key)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def check_file_duplicates(segment)
|
|
41
|
+
dupes = YamlWithLines.dupe_segments_by_file.dig(segment.file.filepath, "#{segment.locale}.#{segment.key}")
|
|
42
|
+
return unless dupes
|
|
43
|
+
|
|
44
|
+
add_segment_offence(segment, "duplicate of line #{(dupes - [segment.lineno]).join(", ")}")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nLint
|
|
4
|
+
module Rules
|
|
5
|
+
module BuiltIn
|
|
6
|
+
# Check I18n interpolations like %{key}.
|
|
7
|
+
class Interpolations < Rule
|
|
8
|
+
enable_by_default true
|
|
9
|
+
|
|
10
|
+
on_init do
|
|
11
|
+
interpolation_patterns =
|
|
12
|
+
if ::I18n.config.respond_to?(:interpolation_patterns)
|
|
13
|
+
::I18n.config.interpolation_patterns
|
|
14
|
+
else
|
|
15
|
+
# This is one union-ed regexp, so we have to split it so we can edit each part individually below.
|
|
16
|
+
::I18n::INTERPOLATION_PATTERN.source.split(/\|(?=\(\?[-mix]{1,4}:?)/).map do |source|
|
|
17
|
+
source.match(/\(\?[-mix]{1,4}:?(.*)\)/)[1]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@i18n_interpolation_pattern_allowing_whitespace = Regexp.union(interpolation_patterns.map do |regex|
|
|
22
|
+
regex = regex.source if regex.is_a?(Regexp)
|
|
23
|
+
Regexp.new regex.gsub(/^\\?./, '\0\\s*')
|
|
24
|
+
.gsub(/\\?.$/, '\\s*\0')
|
|
25
|
+
.gsub(/\\?[<{]/, '\0\\s*')
|
|
26
|
+
end)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :i18n_interpolation_pattern_allowing_whitespace
|
|
30
|
+
|
|
31
|
+
# Look for the configured I18n interpolation pattern with possible spaces inside it; any found that aren't in
|
|
32
|
+
# the known interpolations are an offence.
|
|
33
|
+
def on_segment(segment)
|
|
34
|
+
expected = match_with_highlights(segment.text, ::I18n::INTERPOLATION_PATTERN)
|
|
35
|
+
with_possible_whitespace = match_with_highlights(segment.text, i18n_interpolation_pattern_allowing_whitespace)
|
|
36
|
+
|
|
37
|
+
xor_highlights!(with_possible_whitespace, expected)
|
|
38
|
+
|
|
39
|
+
return if with_possible_whitespace.empty?
|
|
40
|
+
|
|
41
|
+
add_segment_offence(segment, "broken", highlight: with_possible_whitespace.map(&:last))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Compare interpolations against source; anything extra or missing is an offence.
|
|
45
|
+
def_segment_comparison(
|
|
46
|
+
includes_highlights: true,
|
|
47
|
+
message: lambda do |matches, segment, _source|
|
|
48
|
+
"extra in #{segment.locale}: #{matches.join("; ")}" unless matches.empty?
|
|
49
|
+
end,
|
|
50
|
+
source_message: lambda do |matches, segment, _source|
|
|
51
|
+
"missing in #{segment.locale}: #{matches.join("; ")}" unless matches.empty?
|
|
52
|
+
end
|
|
53
|
+
) do |segment|
|
|
54
|
+
match_with_highlights(segment.text, ::I18n::INTERPOLATION_PATTERN)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nLint
|
|
4
|
+
module Rules
|
|
5
|
+
module BuiltIn
|
|
6
|
+
# Helper methods for reporting offences for a pattern configuration.
|
|
7
|
+
module MatchPattern
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.on_init do
|
|
10
|
+
next unless config.is_a?(Hash) && config["Pattern"]
|
|
11
|
+
|
|
12
|
+
options = Regexp::IGNORECASE if config["CaseSensitive"] == false
|
|
13
|
+
@pattern = Regexp.new(config["Pattern"], options)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :pattern
|
|
18
|
+
|
|
19
|
+
def description = pattern.inspect
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Report when a Regexp pattern matches against an individual segment.
|
|
23
|
+
class MatchSegment < Rule
|
|
24
|
+
def self.rule_key = "match-segment"
|
|
25
|
+
enable_by_default true
|
|
26
|
+
|
|
27
|
+
include MatchPattern
|
|
28
|
+
|
|
29
|
+
def on_segment(segment)
|
|
30
|
+
return unless pattern
|
|
31
|
+
|
|
32
|
+
segment.text.scan(pattern) do |_match|
|
|
33
|
+
add_segment_offence(segment, nil, highlight: [Regexp.last_match.offset(0)])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Report when a Regexp pattern matches against an individual segment as compared to itself in the source locale.
|
|
39
|
+
class MismatchToSource < Rule
|
|
40
|
+
def self.rule_key = "mismatch-to-source"
|
|
41
|
+
enable_by_default true
|
|
42
|
+
|
|
43
|
+
include MatchPattern
|
|
44
|
+
|
|
45
|
+
def on_segment_comparison(segment, source_segment)
|
|
46
|
+
return unless pattern
|
|
47
|
+
|
|
48
|
+
mismatches = enum_for(:each_mismatch, segment.text, source_segment.text).to_a
|
|
49
|
+
return if mismatches.none?
|
|
50
|
+
|
|
51
|
+
add_segment_compare_offence(
|
|
52
|
+
segment, source_segment,
|
|
53
|
+
"Found #{mismatches.size == 1 ? "mismatch" : "mismatches"} to the source #{source_segment.locale.upcase}",
|
|
54
|
+
highlight: mismatches.filter_map(&:highlight),
|
|
55
|
+
source_highlight: mismatches.filter_map(&:source_highlight)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def scan(text)
|
|
62
|
+
text.enum_for(:scan, pattern).map { Scan.new(_1, Regexp.last_match.offset(0)) }
|
|
63
|
+
end
|
|
64
|
+
Scan = Struct.new(:match, :highlight)
|
|
65
|
+
private_constant :Scan
|
|
66
|
+
|
|
67
|
+
def reduce(trans, source)
|
|
68
|
+
trans.delete_if do |scan|
|
|
69
|
+
if (i = source.find_index { _1.match == scan.match })
|
|
70
|
+
source.delete_at(i)
|
|
71
|
+
next true
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def scan_and_reduce(trans, source)
|
|
77
|
+
trans = scan(trans) # 🏳️⚧️🏳️🌈🫶
|
|
78
|
+
source = scan(source)
|
|
79
|
+
reduce(trans, source)
|
|
80
|
+
|
|
81
|
+
[trans, source]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def each_mismatch(trans, source)
|
|
85
|
+
trans, source = scan_and_reduce(trans, source)
|
|
86
|
+
|
|
87
|
+
trans_tally = trans.map(&:match).tally
|
|
88
|
+
source_tally = source.map(&:match).tally
|
|
89
|
+
|
|
90
|
+
trans.each { |scan| yield mismatch_from_tallies(scan.match, trans_tally, source_tally, scan.highlight, nil) }
|
|
91
|
+
source.each { |scan| yield mismatch_from_tallies(scan.match, trans_tally, source_tally, nil, scan.highlight) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mismatch_from_tallies(match, trans_tally, source_tally, highlight, source_highlight)
|
|
95
|
+
Mismatch.new(match, trans_tally[match] || 0, source_tally[match] || 0, highlight, source_highlight)
|
|
96
|
+
end
|
|
97
|
+
Mismatch = Struct.new(:match, :actual_count, :expected_count, :highlight, :source_highlight)
|
|
98
|
+
private_constant :Mismatch
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Report when a Regexp pattern matches against a whole I18n file.
|
|
102
|
+
class MatchFile < Rule
|
|
103
|
+
def self.rule_key = "match-file"
|
|
104
|
+
enable_by_default true
|
|
105
|
+
|
|
106
|
+
include MatchPattern
|
|
107
|
+
|
|
108
|
+
def on_file(file)
|
|
109
|
+
return unless pattern
|
|
110
|
+
|
|
111
|
+
file.raw.scan(pattern) do
|
|
112
|
+
source, lineno, offset_adjust = source_for_match(Regexp.last_match, file.raw)
|
|
113
|
+
highlight = Regexp.last_match.offset(0).map { _1 - offset_adjust }
|
|
114
|
+
add_file_offence(file, nil, lineno:, source:, highlight:)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def source_for_match(match, raw)
|
|
119
|
+
end_of_match = match.offset(0)[1]
|
|
120
|
+
n = 0
|
|
121
|
+
lines = []
|
|
122
|
+
raw.lines.each do |line|
|
|
123
|
+
break if n >= end_of_match
|
|
124
|
+
|
|
125
|
+
lines << line
|
|
126
|
+
n += line.length
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
[lines.last, lines.size, n - lines.last.size]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|