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,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: []
|