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,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cloneable_struct"
|
|
4
|
+
require_relative "yaml_with_lines"
|
|
5
|
+
|
|
6
|
+
module I18nLint
|
|
7
|
+
File = CloneableStruct.new(:filepath, :parsed, :raw, keyword_init: true) do
|
|
8
|
+
def initialize(...)
|
|
9
|
+
super
|
|
10
|
+
@ext = ::File.extname(filepath)
|
|
11
|
+
@is_ruby = @ext == ".rb"
|
|
12
|
+
@is_yaml = @ext == ".yml" || @ext == ".yaml"
|
|
13
|
+
@is_json = @ext == ".json"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def ruby? = @is_ruby
|
|
17
|
+
def yaml? = @is_yaml
|
|
18
|
+
def json? = @is_json
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Segment = CloneableStruct.new(:file, :lineno, :key, :text, :value, :locale, :source_locale, keyword_init: true) do
|
|
22
|
+
def filepath = file.filepath
|
|
23
|
+
|
|
24
|
+
def source?
|
|
25
|
+
locale.to_s == source_locale.to_s
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Using an I18n backend to load and parse the files ensures consistency in syntactical restrictions. We're overloading
|
|
30
|
+
# so we can capture line numbers where possible.
|
|
31
|
+
class Loader < ::I18n::Backend::Simple
|
|
32
|
+
# We don't need to store the translations.
|
|
33
|
+
def store_translations(...); end
|
|
34
|
+
|
|
35
|
+
def load_yml(...)
|
|
36
|
+
::I18n::Backend.const_set(:YAML, YamlWithLines)
|
|
37
|
+
super
|
|
38
|
+
ensure
|
|
39
|
+
::I18n::Backend.send(:remove_const, :YAML)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Yields each parsed file and segment by `:each_file` and `:each_segment` respectively. `:each` is not supported.
|
|
44
|
+
class Enumerator
|
|
45
|
+
attr_reader :source_locale
|
|
46
|
+
|
|
47
|
+
def initialize(filepaths, source_locale:)
|
|
48
|
+
@filepaths = Dir[*Array(filepaths).map(&:to_s)]
|
|
49
|
+
@source_locale = source_locale
|
|
50
|
+
|
|
51
|
+
@i18n_loader = Loader.new
|
|
52
|
+
|
|
53
|
+
@files = {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def num_files
|
|
57
|
+
@filepaths.size
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def each_file
|
|
61
|
+
return to_enum(__method__) { @filepaths.size } unless block_given?
|
|
62
|
+
|
|
63
|
+
@filepaths.each do |filepath|
|
|
64
|
+
yield @files[filepath] ||= File.new(
|
|
65
|
+
filepath:,
|
|
66
|
+
parsed: @i18n_loader.send(:load_file, filepath),
|
|
67
|
+
raw: ::File.read(filepath)
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def each_segment(file: nil)
|
|
73
|
+
return to_enum(__method__, file:) unless block_given?
|
|
74
|
+
|
|
75
|
+
each_file do |i18n_file|
|
|
76
|
+
next if file && i18n_file != file
|
|
77
|
+
|
|
78
|
+
YamlWithLines.walk(i18n_file.parsed, yield_hash_when:) do |(locale, *key_parts), text, line_start, _line_end|
|
|
79
|
+
text, value = determine_text_and_value(text)
|
|
80
|
+
yield Segment.new(file: i18n_file, lineno: line_start, key: key_parts.join("."), text:, value:,
|
|
81
|
+
locale:, source_locale:)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def yield_hash_when
|
|
89
|
+
proc do |hash|
|
|
90
|
+
hash_key?(hash, :one) && (hash_key?(hash, :few) || hash_key?(hash, :many) || hash_key?(hash, :other))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def hash_key?(hash, key)
|
|
95
|
+
hash.key?(key.to_s) || hash.key?(key.to_sym)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def determine_text_and_value(text)
|
|
99
|
+
if text.is_a?(String)
|
|
100
|
+
[text, nil]
|
|
101
|
+
else
|
|
102
|
+
[nil, text]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nLint
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ErrorOnFile < Error # rubocop:disable Style/Documentation
|
|
7
|
+
def initialize(file, cause)
|
|
8
|
+
super("on file #{file.filepath}:\n #{cause.class}: #{cause.message}")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class ErrorOnSegment < Error # rubocop:disable Style/Documentation
|
|
13
|
+
def initialize(segment, cause)
|
|
14
|
+
super("on segment #{segment.key} at #{segment.filepath}:#{segment.lineno}:\n #{cause.class}: #{cause.message}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class ErrorOnSegmentComparison < Error # rubocop:disable Style/Documentation
|
|
19
|
+
def initialize(segment, source_segment, cause)
|
|
20
|
+
super("on segment #{segment.key} at #{segment.filepath}:#{segment.lineno} " \
|
|
21
|
+
"compared to #{source_segment.key} at #{source_segment.filepath}:#{source_segment.lineno}:" \
|
|
22
|
+
"\n #{cause.class}: #{cause.message}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nLint
|
|
4
|
+
module Highlighters
|
|
5
|
+
# Indicate slices in string. A 'slice' here is a tuple of character positions, e.g. '01234' and [1, 3] gives '123'.
|
|
6
|
+
module BelowLine
|
|
7
|
+
class << self
|
|
8
|
+
def indicate(str, *slices, messages: [])
|
|
9
|
+
slices = slices.sort
|
|
10
|
+
messages = messages.dup
|
|
11
|
+
|
|
12
|
+
has_messages = !messages.empty?
|
|
13
|
+
|
|
14
|
+
ensure_tuples_of(Integer, *slices)
|
|
15
|
+
|
|
16
|
+
# Go line-by-line to add indicators if needed, then join at the end.
|
|
17
|
+
to_enum(:highlight_per_line, str, *slices).map do |line, slices_in_this_line|
|
|
18
|
+
next line if slices_in_this_line.empty?
|
|
19
|
+
|
|
20
|
+
indicators_line = make_indicators_line("^", slices_in_this_line,
|
|
21
|
+
(messages.shift(slices_in_this_line.size) if has_messages))
|
|
22
|
+
|
|
23
|
+
"#{line.chomp}\n#{indicators_line}#{"\n" if line.end_with?("\n")}"
|
|
24
|
+
end.join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def ensure_tuples_of(type, *objects)
|
|
30
|
+
return if objects.all? { _1.is_a?(Array) && _1.map(&:class) == [type, type] }
|
|
31
|
+
|
|
32
|
+
raise ArgumentError,
|
|
33
|
+
"must be given 1 or more tuples of #{type}, but was called with #{objects.map(&:inspect).join(", ")}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def highlight_per_line(str, *slices)
|
|
37
|
+
str.lines.reduce([0, []]) do |(checked, line_slices), line|
|
|
38
|
+
line_length = line.chomp.length # ignore newlines so it doesn't look like we're indicating empty space
|
|
39
|
+
|
|
40
|
+
slices.delete_if do |a, b|
|
|
41
|
+
next if a > (limit = checked + line_length)
|
|
42
|
+
|
|
43
|
+
# Adjust the slice so it's local to this line rather than the str as a whole.
|
|
44
|
+
line_slices << [(a - checked).clamp(0, line_length), (b - checked).clamp(0, line_length)]
|
|
45
|
+
true if b <= limit
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
yield [line, line_slices]
|
|
49
|
+
|
|
50
|
+
[checked + line.length, []]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def make_indicators_line(char, slices, messages)
|
|
55
|
+
line = make_line_of(char, *slices)
|
|
56
|
+
return line if messages.nil?
|
|
57
|
+
|
|
58
|
+
"#{line} #{messages.map { |m| m.nil? ? "<nil>" : m }.join("; ")}".rstrip
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def make_line_of(char, *slices)
|
|
62
|
+
(" " * slices.last[1]).tap do |line|
|
|
63
|
+
slices.each do |slice|
|
|
64
|
+
line[slice[0]...slice[1]] = char * (slice[1] - slice[0])
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nLint
|
|
4
|
+
module Highlighters
|
|
5
|
+
# Colour slices in string. A 'slice' here is a tuple of character positions, e.g. '01234' and [1, 3] gives '123'.
|
|
6
|
+
module Colour
|
|
7
|
+
class << self
|
|
8
|
+
def bold(str)
|
|
9
|
+
"\e[1m#{str}\e[0m"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def highlight(str)
|
|
13
|
+
"\e[30;43m#{str}\e[0m"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def indicate(str, *slices)
|
|
17
|
+
ensure_tuples_of(Integer, *slices)
|
|
18
|
+
|
|
19
|
+
str = str.dup
|
|
20
|
+
|
|
21
|
+
slices.sort.reverse.each do |slice|
|
|
22
|
+
str[slice[0]...slice[1]] = highlight(str[slice[0]...slice[1]])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
str
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def ensure_tuples_of(type, *objects)
|
|
31
|
+
return if objects.all? { _1.is_a?(Array) && _1.map(&:class) == [type, type] }
|
|
32
|
+
|
|
33
|
+
raise ArgumentError, "must be given 1 or more tuples of #{type}, but was called with #{objects.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "i18nlint/highlighters/below_line"
|
|
4
|
+
require "i18nlint/highlighters/colour"
|
|
5
|
+
|
|
6
|
+
module I18nLint
|
|
7
|
+
# Decide how to highlight a string.
|
|
8
|
+
module Highlighters
|
|
9
|
+
def self.indicate(text, *range)
|
|
10
|
+
if $stdout.isatty
|
|
11
|
+
Colour.indicate(text, *range)
|
|
12
|
+
else
|
|
13
|
+
BelowLine.indicate(text, *range)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module I18nLint
|
|
6
|
+
# Run rules over the given files.
|
|
7
|
+
class Linter
|
|
8
|
+
attr_reader :offences, :source_locale
|
|
9
|
+
|
|
10
|
+
def initialize(filepaths:, source_locale:)
|
|
11
|
+
@offences = []
|
|
12
|
+
@enum = Enumerator.new(filepaths, source_locale:)
|
|
13
|
+
@source_locale = source_locale
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def num_files
|
|
17
|
+
enum.num_files
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tick_each_file(&block)
|
|
21
|
+
@tick_each_file = make_tick_proc(block)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tick_each_comparison(&block)
|
|
25
|
+
@tick_each_comparison = make_tick_proc(block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run
|
|
29
|
+
each_file do |i18n_file|
|
|
30
|
+
tick(@tick_each_file, Registry.rules.sum do |rule|
|
|
31
|
+
next 0 if rule.excluded?(i18n_file.filepath)
|
|
32
|
+
|
|
33
|
+
rule.on_file(i18n_file.clone)
|
|
34
|
+
each_segment(file: i18n_file) do |segment|
|
|
35
|
+
rule.on_segment(segment.clone)
|
|
36
|
+
end
|
|
37
|
+
rule.take_offences.tap { offences.concat(_1) }.size
|
|
38
|
+
end)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run_comparison
|
|
43
|
+
each_segment_comparison do |segment, source_segment|
|
|
44
|
+
tick(@tick_each_comparison, Registry.rules.sum do |rule|
|
|
45
|
+
next 0 if rule.excluded?(segment.file.filepath)
|
|
46
|
+
|
|
47
|
+
rule.on_segment_comparison(segment.clone, source_segment.clone)
|
|
48
|
+
rule.take_offences.tap { offences.concat(_1) }.size
|
|
49
|
+
end)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def each_file(&)
|
|
54
|
+
enum.each_file do |file|
|
|
55
|
+
yield file
|
|
56
|
+
rescue Error # coming from a segment?
|
|
57
|
+
raise
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
raise ErrorOnFile.new file, e
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def each_segment(file: nil, &)
|
|
64
|
+
enum.each_segment(file:) do |segment|
|
|
65
|
+
yield segment
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
raise ErrorOnSegment.new segment, e
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def each_segment_comparison(&)
|
|
72
|
+
comparisons = ComparisonMap.new(source_locale)
|
|
73
|
+
# Slurp all the files first, so we can separate the source segments from translation segments.
|
|
74
|
+
each_segment { comparisons.add(_1) }
|
|
75
|
+
comparisons.freeze
|
|
76
|
+
comparisons.each do |segment, source_segment|
|
|
77
|
+
yield [segment, source_segment]
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
raise ErrorOnSegmentComparison.new segment, source_segment, e
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
attr_reader :enum
|
|
86
|
+
|
|
87
|
+
def tick(block, num_offences)
|
|
88
|
+
return if block.nil?
|
|
89
|
+
|
|
90
|
+
block.call(num_offences)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def make_tick_proc(block)
|
|
94
|
+
if block.arity.zero?
|
|
95
|
+
->(_num_offences) { block.call }
|
|
96
|
+
else
|
|
97
|
+
block
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Store segments then enumerate them compared to the source_locale.
|
|
102
|
+
class ComparisonMap
|
|
103
|
+
def initialize(source_locale)
|
|
104
|
+
@source_locale = source_locale.downcase
|
|
105
|
+
@hash = Hash.new do |h, k|
|
|
106
|
+
next if h.frozen?
|
|
107
|
+
|
|
108
|
+
h[k] = Hash.new do |h, k|
|
|
109
|
+
next if h.frozen?
|
|
110
|
+
|
|
111
|
+
h[k] = []
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
attr_reader :source_locale
|
|
117
|
+
|
|
118
|
+
include Enumerable
|
|
119
|
+
|
|
120
|
+
def each(&)
|
|
121
|
+
::Enumerator.new do |yielder|
|
|
122
|
+
each_segment do |segment|
|
|
123
|
+
each_source(segment.key) do |source_segment|
|
|
124
|
+
yielder << [segment, source_segment]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end.each(&)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def add(segment)
|
|
131
|
+
@hash[segment.locale.downcase.to_sym][segment.key] << segment
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def freeze
|
|
135
|
+
super
|
|
136
|
+
@hash.freeze
|
|
137
|
+
@hash.each_value do |level2|
|
|
138
|
+
level2.freeze
|
|
139
|
+
level2.each_value(&:freeze)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def each_segment(&block)
|
|
146
|
+
@hash.except(source_locale.to_sym).each_value do |per_key|
|
|
147
|
+
per_key.each_value do |segments|
|
|
148
|
+
segments.each(&block)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def each_source(key, &)
|
|
154
|
+
@hash.dig(source_locale.to_sym, key)&.each(&)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nLint
|
|
4
|
+
# Allow this library to gain I18n configuration from a Rails app.
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
rake_tasks do
|
|
7
|
+
desc "Lint your I18n"
|
|
8
|
+
task :i18nlint, [:filepaths] => [:environment] do |_t, args|
|
|
9
|
+
source_locale = Rails.application.config.i18n.default_locale
|
|
10
|
+
|
|
11
|
+
filepaths = args[:filepaths]
|
|
12
|
+
filepaths ||= I18n.load_path.select { _1.start_with?(Rails.root.to_s) }
|
|
13
|
+
|
|
14
|
+
require "i18nlint/cli"
|
|
15
|
+
I18nLint::CLI.run(["--source=#{source_locale}", *filepaths])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nLint
|
|
4
|
+
# Collect rules.
|
|
5
|
+
class Registry
|
|
6
|
+
@rules = []
|
|
7
|
+
|
|
8
|
+
LINT_METHODS = %i[
|
|
9
|
+
on_file
|
|
10
|
+
on_segment
|
|
11
|
+
on_segment_comparison
|
|
12
|
+
].freeze
|
|
13
|
+
private_constant :LINT_METHODS
|
|
14
|
+
|
|
15
|
+
# Avoid custom rules that will never get used.
|
|
16
|
+
class WillNeverRun < NotImplementedError
|
|
17
|
+
attr_reader :rule_class
|
|
18
|
+
|
|
19
|
+
def initialize(instance)
|
|
20
|
+
@rule_class = instance.class
|
|
21
|
+
super("Rule #{rule_class} will not be used: it must respond to at least one of " \
|
|
22
|
+
"#{LINT_METHODS.map(&:inspect).join(", ")}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
attr_reader :rules
|
|
28
|
+
|
|
29
|
+
def register_rule(rule_class, config = {})
|
|
30
|
+
rule = rule_class.new(config)
|
|
31
|
+
enforce_rule_shape(rule)
|
|
32
|
+
rules << rule
|
|
33
|
+
rule
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def enforce_rule_shape(rule)
|
|
39
|
+
has_methods = false
|
|
40
|
+
LINT_METHODS.each do |m|
|
|
41
|
+
if rule.respond_to?(m)
|
|
42
|
+
has_methods = true
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
rule.singleton_class.define_method(m) { |*| nil }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
raise WillNeverRun, rule unless has_methods
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module I18nLint
|
|
6
|
+
module RSpec
|
|
7
|
+
# Include in RSpec example groups to get rule assertions.
|
|
8
|
+
module ExpectOffence
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.let(:rule) { ::I18nLint::Registry.register_rule(*[described_class, (config if defined?(config))].compact) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
extend ::RSpec::Matchers::DSL
|
|
14
|
+
|
|
15
|
+
matcher :cause_offence do |expected|
|
|
16
|
+
diffable
|
|
17
|
+
supports_block_expectations
|
|
18
|
+
|
|
19
|
+
chain(:type) { @type = _1 }
|
|
20
|
+
chain(:did_you_mean) { @opposite_assertion = _1 }
|
|
21
|
+
|
|
22
|
+
define_method(:source) { @source ||= expected.lines.grep_v(/^\s*\^+ ?/).join }
|
|
23
|
+
|
|
24
|
+
def matches_offences?
|
|
25
|
+
@actual, @offences = @actual.call(source)
|
|
26
|
+
actual == expected
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
match { matches_offences? && !@offences.empty? }
|
|
30
|
+
match_when_negated { matches_offences? && @offences.empty? }
|
|
31
|
+
|
|
32
|
+
failure_message do
|
|
33
|
+
if source == expected
|
|
34
|
+
return "No offence highlights have been given#{", and none were found" if @offences.empty?}. " \
|
|
35
|
+
"Did you mean `#{@opposite_assertion}`?"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
"expected offences to match#{", but there were none" if @offences.empty?}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
failure_message_when_negated do
|
|
42
|
+
if source != expected
|
|
43
|
+
return "Offence highlights have been given#{", but no offences found" if @offences.empty?}. " \
|
|
44
|
+
"Did you mean `#{@opposite_assertion}`?"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
"expected no #{@type} offences"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def expect_file_offence(expected_highlights, filepath)
|
|
52
|
+
expect { |source| on_file(filepath, source) }.to cause_offence(expected_highlights)
|
|
53
|
+
.type(:file).did_you_mean(:expect_no_file_offences)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def expect_no_file_offences(expected_highlights, filepath)
|
|
57
|
+
expect { |source| on_file(filepath, source) }.not_to cause_offence(expected_highlights)
|
|
58
|
+
.type(:file).did_you_mean(:expect_file_offence)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def expect_segment_offence(expected_highlights, lineno = nil, locale: nil)
|
|
62
|
+
expect { |source| on_segment(source, lineno:, locale:) }.to cause_offence(expected_highlights)
|
|
63
|
+
.type(:segment).did_you_mean(:expect_no_segment_offences)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def expect_no_segment_offences(expected_highlights, lineno = nil, locale: nil)
|
|
67
|
+
expect { |source| on_segment(source, lineno:, locale:) }.not_to cause_offence(expected_highlights)
|
|
68
|
+
.type(:segment).did_you_mean(:expect_segment_offence)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def expect_comparison_offence(expected_translation, expected_source, locale:, source_locale:)
|
|
72
|
+
expect { |combined| on_comparison(*compare_split(combined), locale:, source_locale:) }
|
|
73
|
+
.to cause_offence(compare_combine(expected_translation, expected_source))
|
|
74
|
+
.type(:segment).did_you_mean(:expect_no_comparison_offences)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def expect_no_comparison_offences(expected_translation, expected_source, locale:, source_locale:)
|
|
78
|
+
expect { |combined| on_comparison(*compare_split(combined), locale:, source_locale:) }
|
|
79
|
+
.not_to cause_offence(compare_combine(expected_translation, expected_source))
|
|
80
|
+
.type(:segment).did_you_mean(:expect_comparison_offence)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def on_file(filepath, contents)
|
|
84
|
+
rule.on_file(make_file(filepath, contents))
|
|
85
|
+
offences = rule.take_offences
|
|
86
|
+
[highlight_offences(contents, offences), offences]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def on_segment(source, lineno: nil, locale: nil, key: nil, filepath: nil)
|
|
90
|
+
rule.on_segment(make_segment(source, lineno:, locale:, key:, filepath:))
|
|
91
|
+
offences = rule.take_offences
|
|
92
|
+
[highlight_offences(source, offences), offences]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def on_comparison(translation, source, locale: nil, source_locale: nil)
|
|
96
|
+
rule.on_segment_comparison make_segment(translation, locale:), make_segment(source, locale: source_locale)
|
|
97
|
+
offences = rule.take_offences
|
|
98
|
+
[
|
|
99
|
+
compare_combine(
|
|
100
|
+
highlight_offences(translation, offences),
|
|
101
|
+
highlight_offences(source, offences.map(&:source_offence))
|
|
102
|
+
), offences
|
|
103
|
+
]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
DUMMY_FILE = ::I18nLint::File.new(filepath: "<none>")
|
|
107
|
+
|
|
108
|
+
JOINER = "\n---\n"
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def compare_combine(translation, source) = [translation.chomp, source].join(JOINER)
|
|
113
|
+
def compare_split(combined) = combined.split(JOINER)
|
|
114
|
+
|
|
115
|
+
def highlight_offences(content, offences)
|
|
116
|
+
highlighted, unhighlighted = offences.partition(&:highlight)
|
|
117
|
+
|
|
118
|
+
highlight_messages = highlighted.each_with_object({}) { |o, h| o.highlight.each { h[_1] = o.message } }
|
|
119
|
+
|
|
120
|
+
actual = ::I18nLint::Highlighters::BelowLine.indicate(content, *highlight_messages.keys,
|
|
121
|
+
messages: highlight_messages.values)
|
|
122
|
+
|
|
123
|
+
other = unhighlighted.filter_map(&:message).map(&:inspect).join("; ")
|
|
124
|
+
actual.prepend "^ Offences without highlights: #{other}\n" unless other.empty?
|
|
125
|
+
|
|
126
|
+
actual
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def make_file(filepath, contents)
|
|
130
|
+
file = Tempfile.create([filepath, ::File.extname(filepath)])
|
|
131
|
+
file.tap { _1.write(contents) }.tap(&:rewind)
|
|
132
|
+
::I18nLint::Enumerator.new([file.path], source_locale: nil).each_file.first.tap { _1.filepath = filepath }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def make_segment(text, lineno: nil, locale: nil, key: nil, filepath: nil)
|
|
136
|
+
file = filepath ? ::I18nLint::File.new(filepath:) : DUMMY_FILE
|
|
137
|
+
::I18nLint::Segment.new(text:, file:, lineno:, locale:, key:)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|