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,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18nLint
4
+ # Parse YAML such that every value is a ValueWithLineNumbers.
5
+ class YamlWithLines
6
+ ValueWithLineNumbers = Struct.new(:value, :lines)
7
+
8
+ class << self
9
+ def dupe_segments_by_file = @dupe_segments_by_file ||= Hash.new { |h, k| h[k] = {} }
10
+
11
+ # I18n, up to 1.14 as of writing, always unsafe-loads YAML because it should be from devs, not user-supplied. This
12
+ # is why we don't bother defining `safe_load` methods here. If any NoMethodError exceptions are being raised for a
13
+ # `safe_load*` method being called, please look into the version of I18n to see if it's changed, or if our
14
+ # patching-in of this class has a bug and is effecting other YAML-loading classes.
15
+
16
+ def unsafe_load(yaml, filename: nil, fallback: false, symbolize_names: false, freeze: false, # rubocop:disable Metrics/ParameterLists
17
+ strict_integer: false, parse_symbols: true)
18
+ result = parse(yaml, filename:)
19
+ return fallback unless result
20
+
21
+ Psych::Visitors::ToRubyWithLineNumbers.create(symbolize_names:, freeze:, strict_integer:, parse_symbols:)
22
+ .accept(result)[0]
23
+ end
24
+
25
+ # copy-paste from Psych.
26
+ def unsafe_load_file(filename, **kwargs)
27
+ ::File.open(filename, "r:bom|utf-8") do |f|
28
+ unsafe_load f, filename: filename, **kwargs
29
+ end
30
+ end
31
+
32
+ alias load_file unsafe_load_file # I18n relies on unsafe-loading, and < 1.8 relies on Psych defaulting to unsafe.
33
+
34
+ def parse(yaml, filename: nil)
35
+ handler = Psych::TreeWithLineNumbersBuilder.new
36
+ handler.parser = Psych::Parser.new(handler)
37
+ handler.parser.parse(yaml, filename:)
38
+ dupe_segments_by_file[filename] = handler.duplicate_segments
39
+ handler.root
40
+ end
41
+
42
+ def walk(val, key_parts = [], yield_hash_when: nil, &block)
43
+ case val
44
+ when ValueWithLineNumbers then yield key_parts, val.value, *first_last_lines(val)
45
+ when Hash
46
+ if yield_hash_when&.call(val)
47
+ yield key_parts, val.transform_values(&:value), *first_last_lines(val)
48
+ else
49
+ val.each { |each_key, each_val| walk(each_val, key_parts + [each_key], yield_hash_when:, &block) }
50
+ end
51
+ else yield key_parts, val
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def first_last_lines(val)
58
+ case val
59
+ when ValueWithLineNumbers
60
+ [val.lines.first, val.lines.last]
61
+ when Hash
62
+ [val.values.first.lines.first, val.values.last.lines.last]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ # Copied from https://gist.github.com/johncarney/7332f7b2075b86ea52177a4a82453806
70
+ # I've edited it to apply line numbers to sequence values too, even though they're unlikely to be used in i18n.
71
+ =begin # rubocop:disable Style/BlockComments
72
+ Custom Psych parser that captures line number information from a YAML file.
73
+
74
+ For a project I'm working on I need to be able to determine which line(s) in a YAML
75
+ file a particular value comes from. There are a few bits of advice on the internet
76
+ about this, the best of them that I've found involves monkey-patching, which is a
77
+ fairly low bar for "best" in my opinion. I found it on Stack Overflow:
78
+
79
+ https://stackoverflow.com/questions/29462856/loading-yaml-with-line-number-for-each-key
80
+
81
+ Here's my take without monkey-patching. It deals with values spanning multiple lines
82
+ and handles YAML's << (insertion) operator, borrowing liberally from Psych's source code
83
+ to do so.
84
+ =end
85
+
86
+ require "psych"
87
+
88
+ class Psych::Nodes::ScalarWithLineNumber < Psych::Nodes::Scalar # rubocop:disable Style/Documentation,Style/ClassAndModuleChildren
89
+ attr_reader :line_number
90
+
91
+ def initialize(*args, line_number)
92
+ super(*args)
93
+ @line_number = line_number
94
+ end
95
+ end
96
+
97
+ class Psych::Nodes::SequenceWithLineNumber < Psych::Nodes::Sequence # rubocop:disable Style/Documentation,Style/ClassAndModuleChildren
98
+ attr_reader :line_number
99
+
100
+ def initialize(*args, line_number)
101
+ super(*args)
102
+ @line_number = line_number
103
+ end
104
+ end
105
+
106
+ class Psych::TreeWithLineNumbersBuilder < Psych::TreeBuilder # rubocop:disable Style/Documentation,Style/ClassAndModuleChildren
107
+ attr_accessor :parser
108
+
109
+ # Track duplicate segments across the same file.
110
+ class DuplicatesTracker
111
+ def initialize
112
+ @levels = []
113
+ @keys = []
114
+ @level = -1
115
+ end
116
+
117
+ def duplicate_segments
118
+ @keys.group_by(&:first)
119
+ .reject { |_key, key_positions| key_positions.size < 2 }
120
+ .transform_values { |key_positions| key_positions.map(&:last) }
121
+ end
122
+
123
+ def start_map!
124
+ @level += 1
125
+ @_last_was_scalar = false
126
+ end
127
+
128
+ def end_map!
129
+ @level -= 1
130
+ @_last_was_scalar = false
131
+ @levels.pop
132
+ end
133
+
134
+ def add_scalar!(val, lineno)
135
+ if @_last_was_scalar
136
+ @_last_was_scalar = false
137
+ @keys << [@levels.join("."), lineno]
138
+ return
139
+ end
140
+
141
+ @_last_was_scalar = true
142
+ @levels[@level] = val
143
+ end
144
+ end
145
+
146
+ def initialize(...)
147
+ super
148
+ @_dup_tracker = DuplicatesTracker.new
149
+ end
150
+
151
+ def duplicate_segments = @_dup_tracker.duplicate_segments
152
+
153
+ def scalar(*args)
154
+ @_dup_tracker.add_scalar!(args[0], parser.mark.line + 1)
155
+ node = Psych::Nodes::ScalarWithLineNumber.new(*args, parser.mark.line)
156
+ @last.children << node
157
+ node
158
+ end
159
+
160
+ def start_mapping(...)
161
+ @_dup_tracker.start_map!
162
+ super
163
+ end
164
+
165
+ def end_mapping(...)
166
+ @_dup_tracker.end_map!
167
+ super
168
+ end
169
+
170
+ def start_sequence(*args)
171
+ node = Psych::Nodes::SequenceWithLineNumber.new(*args, parser.mark.line)
172
+ @last.children << node
173
+ push node
174
+ end
175
+
176
+ def end_sequence
177
+ node = pop
178
+ set_end_location(node)
179
+ node
180
+ end
181
+ end
182
+
183
+ class Psych::Visitors::ToRubyWithLineNumbers < Psych::Visitors::ToRuby # rubocop:disable Style/Documentation,Style/ClassAndModuleChildren
184
+ def visit_Psych_Nodes_ScalarWithLineNumber(node) # rubocop:disable Naming/MethodName
185
+ visit_Psych_Nodes_Scalar(node)
186
+ end
187
+
188
+ def visit_Psych_Nodes_SequenceWithLineNumber(node) # rubocop:disable Naming/MethodName
189
+ visit_Psych_Nodes_Sequence(node)
190
+ end
191
+
192
+ if Gem.loaded_specs["psych"].version < Gem::Version.create("3.2.0")
193
+ def self.create(...)
194
+ super()
195
+ end
196
+ elsif Gem.loaded_specs["psych"].version < Gem::Version.create("5.0.0")
197
+ def self.create(*args, strict_integer:, parse_symbols:, **kwargs) # rubocop:disable Lint/UnusedMethodArgument
198
+ super(*args, **kwargs)
199
+ end
200
+ elsif Gem.loaded_specs["psych"].version < Gem::Version.create("5.3.0")
201
+ def self.create(*args, parse_symbols:, **kwargs) # rubocop:disable Lint/UnusedMethodArgument
202
+ super(*args, **kwargs)
203
+ end
204
+ end
205
+
206
+ private
207
+
208
+ def revive_hash(hash, node, tagged = false) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize,Metrics/MethodLength,Style/OptionalBooleanParameter
209
+ node.children.each_slice(2) do |k, v| # rubocop:disable Metrics/BlockLength
210
+ key = accept(k)
211
+ val = accept(v)
212
+
213
+ if v.is_a?(Psych::Nodes::ScalarWithLineNumber) || v.is_a?(Psych::Nodes::SequenceWithLineNumber)
214
+ start_line = end_line = v.line_number + 1
215
+
216
+ start_line = k.line_number + 1 if k.is_a?(v.class)
217
+ val = I18nLint::YamlWithLines::ValueWithLineNumbers.new(val, start_line..end_line)
218
+ end
219
+
220
+ if key == "<<" && k.tag != "tag:yaml.org,2002:str"
221
+ case v
222
+ when Psych::Nodes::Alias, Psych::Nodes::Mapping
223
+ begin
224
+ hash.merge! val
225
+ rescue TypeError
226
+ hash[key] = val
227
+ end
228
+ when Psych::Nodes::Sequence
229
+ begin
230
+ h = {}
231
+ val.reverse_each do |value|
232
+ h.merge! value
233
+ end
234
+ hash.merge! h
235
+ rescue TypeError
236
+ hash[key] = val
237
+ end
238
+ else
239
+ hash[key] = val
240
+ end
241
+ else
242
+ if !tagged && @symbolize_names && key.is_a?(String)
243
+ key = key.to_sym
244
+ elsif !@freeze && respond_to?(:deduplicate)
245
+ key = deduplicate(key)
246
+ end
247
+
248
+ hash[key] = val
249
+ end
250
+ end
251
+
252
+ hash
253
+ end
254
+ end
data/lib/i18nlint.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+
5
+ require_relative "i18nlint/version"
6
+ require_relative "i18nlint/error"
7
+ require_relative "i18nlint/configuration"
8
+ require_relative "i18nlint/highlighters"
9
+ require_relative "i18nlint/linter"
10
+ require_relative "i18nlint/registry"
11
+ require_relative "i18nlint/rule"
12
+ require_relative "i18nlint/enumerator"
13
+
14
+ require_relative "i18nlint/railtie" if defined?(Rails::Railtie)
15
+
16
+ # Detect various "offences" in your I18n files.
17
+ module I18nLint
18
+ def self.lint(filepaths, source_locale: nil)
19
+ linter = Linter.new(filepaths:, source_locale:)
20
+ linter.run
21
+ linter.run_comparison
22
+ linter.offences
23
+ end
24
+
25
+ def self.register_rule(rule)
26
+ Registry.register_rule(rule)
27
+ end
28
+
29
+ # Basic set of rules.
30
+ module Rules
31
+ # Built-in rules that should suit most use-cases.
32
+ module BuiltIn
33
+ # Match rules have no default configuration, so they must be defined in config to be registered.
34
+ require_relative "i18nlint/rules/built_in/match"
35
+ # Other built-ins aren't much configurable so they're always registered.
36
+ require_relative "i18nlint/rules/built_in/interpolations"
37
+ require_relative "i18nlint/rules/built_in/duplicates"
38
+ end
39
+ end
40
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: i18nlint
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Henry Blyth
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: i18n
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: appraisal
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: Linting for your I18n
42
+ email:
43
+ - blyth.henry@gmail.com
44
+ executables:
45
+ - i18nlint
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - CODE_OF_CONDUCT.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - exe/i18nlint
54
+ - lib/i18nlint.rb
55
+ - lib/i18nlint/cli.rb
56
+ - lib/i18nlint/cloneable_struct.rb
57
+ - lib/i18nlint/configuration.rb
58
+ - lib/i18nlint/enumerator.rb
59
+ - lib/i18nlint/error.rb
60
+ - lib/i18nlint/highlighters.rb
61
+ - lib/i18nlint/highlighters/below_line.rb
62
+ - lib/i18nlint/highlighters/colour.rb
63
+ - lib/i18nlint/linter.rb
64
+ - lib/i18nlint/railtie.rb
65
+ - lib/i18nlint/registry.rb
66
+ - lib/i18nlint/rspec/expect_offence.rb
67
+ - lib/i18nlint/rule.rb
68
+ - lib/i18nlint/rule_helper.rb
69
+ - lib/i18nlint/rules/built_in/duplicates.rb
70
+ - lib/i18nlint/rules/built_in/interpolations.rb
71
+ - lib/i18nlint/rules/built_in/match.rb
72
+ - lib/i18nlint/version.rb
73
+ - lib/i18nlint/yaml_with_lines.rb
74
+ homepage: https://github.com/henrahmagix/i18nlint
75
+ licenses:
76
+ - MIT
77
+ metadata:
78
+ allowed_push_host: https://rubygems.org
79
+ rubygems_mfa_required: 'true'
80
+ homepage_uri: https://github.com/henrahmagix/i18nlint
81
+ source_code_uri: https://github.com/henrahmagix/i18nlint
82
+ changelog_uri: https://github.com/henrahmagix/i18nlint/blob/v1.0.0/CHANGELOG.md
83
+ post_install_message:
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 3.1.0
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 3.3.3
99
+ signing_key:
100
+ specification_version: 4
101
+ summary: Linting for your I18n
102
+ test_files: []