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,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