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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nLint
4
+ VERSION = "1.0.0"
5
+ end