i18n_flow 0.1.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.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +7 -0
  3. data/.rspec +3 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +13 -0
  6. data/Gemfile +6 -0
  7. data/Gemfile.lock +45 -0
  8. data/LICENSE +22 -0
  9. data/README.md +103 -0
  10. data/Rakefile +2 -0
  11. data/bin/console +14 -0
  12. data/bin/setup +8 -0
  13. data/doc/rules.md +316 -0
  14. data/doc/tags.md +488 -0
  15. data/example/example.en.yml +14 -0
  16. data/example/example.ja.yml +9 -0
  17. data/exe/i18n_flow +11 -0
  18. data/i18n_flow.gemspec +28 -0
  19. data/i18n_flow.yml +8 -0
  20. data/lib/i18n_flow/cli/color.rb +18 -0
  21. data/lib/i18n_flow/cli/command_base.rb +33 -0
  22. data/lib/i18n_flow/cli/copy_command.rb +69 -0
  23. data/lib/i18n_flow/cli/help_command.rb +29 -0
  24. data/lib/i18n_flow/cli/lint_command/ascii.erb +45 -0
  25. data/lib/i18n_flow/cli/lint_command/ascii_renderer.rb +58 -0
  26. data/lib/i18n_flow/cli/lint_command/markdown.erb +49 -0
  27. data/lib/i18n_flow/cli/lint_command/markdown_renderer.rb +55 -0
  28. data/lib/i18n_flow/cli/lint_command.rb +55 -0
  29. data/lib/i18n_flow/cli/read_config_command.rb +20 -0
  30. data/lib/i18n_flow/cli/search_command/default.erb +11 -0
  31. data/lib/i18n_flow/cli/search_command/default_renderer.rb +67 -0
  32. data/lib/i18n_flow/cli/search_command/oneline.erb +5 -0
  33. data/lib/i18n_flow/cli/search_command/oneline_renderer.rb +39 -0
  34. data/lib/i18n_flow/cli/search_command.rb +59 -0
  35. data/lib/i18n_flow/cli/split_command.rb +20 -0
  36. data/lib/i18n_flow/cli/version_command.rb +9 -0
  37. data/lib/i18n_flow/cli.rb +42 -0
  38. data/lib/i18n_flow/configuration.rb +205 -0
  39. data/lib/i18n_flow/parser.rb +34 -0
  40. data/lib/i18n_flow/repository.rb +39 -0
  41. data/lib/i18n_flow/search.rb +176 -0
  42. data/lib/i18n_flow/splitter/merger.rb +60 -0
  43. data/lib/i18n_flow/splitter/strategy.rb +66 -0
  44. data/lib/i18n_flow/splitter.rb +5 -0
  45. data/lib/i18n_flow/util.rb +57 -0
  46. data/lib/i18n_flow/validator/errors.rb +99 -0
  47. data/lib/i18n_flow/validator/file_scope.rb +58 -0
  48. data/lib/i18n_flow/validator/multiplexer.rb +58 -0
  49. data/lib/i18n_flow/validator/symmetry.rb +154 -0
  50. data/lib/i18n_flow/validator.rb +4 -0
  51. data/lib/i18n_flow/version.rb +7 -0
  52. data/lib/i18n_flow/yaml_ast_proxy/mapping.rb +72 -0
  53. data/lib/i18n_flow/yaml_ast_proxy/node.rb +128 -0
  54. data/lib/i18n_flow/yaml_ast_proxy/node_meta_data.rb +86 -0
  55. data/lib/i18n_flow/yaml_ast_proxy/sequence.rb +29 -0
  56. data/lib/i18n_flow/yaml_ast_proxy.rb +57 -0
  57. data/lib/i18n_flow.rb +15 -0
  58. data/spec/lib/i18n_flow/cli/command_base_spec.rb +46 -0
  59. data/spec/lib/i18n_flow/cli/help_command_spec.rb +13 -0
  60. data/spec/lib/i18n_flow/cli/version_command_spec.rb +13 -0
  61. data/spec/lib/i18n_flow/configuration_spec.rb +334 -0
  62. data/spec/lib/i18n_flow/repository_spec.rb +40 -0
  63. data/spec/lib/i18n_flow/splitter/merger_spec.rb +149 -0
  64. data/spec/lib/i18n_flow/util_spec.rb +194 -0
  65. data/spec/lib/i18n_flow/validator/file_scope_spec.rb +74 -0
  66. data/spec/lib/i18n_flow/validator/multiplexer_spec.rb +68 -0
  67. data/spec/lib/i18n_flow/validator/symmetry_spec.rb +511 -0
  68. data/spec/lib/i18n_flow/yaml_ast_proxy/node_spec.rb +151 -0
  69. data/spec/lib/i18n_flow_spec.rb +21 -0
  70. data/spec/spec_helper.rb +16 -0
  71. data/spec/support/repository_examples.rb +60 -0
  72. data/spec/support/util_macro.rb +14 -0
  73. metadata +214 -0
@@ -0,0 +1,205 @@
1
+ require 'yaml'
2
+ require 'pathname'
3
+ require_relative 'util'
4
+
5
+ class I18nFlow::Configuration
6
+ module Validation
7
+ def self.included(base)
8
+ base.include(InstanceMethods)
9
+ base.extend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ def _validators
14
+ @_validators ||= []
15
+ end
16
+
17
+ def validate(attr, message, &block)
18
+ _validators << [attr, message, block]
19
+ end
20
+ end
21
+
22
+ module InstanceMethods
23
+ def validate!
24
+ self.class._validators.each do |attr, message, block|
25
+ next if instance_eval(&block)
26
+ raise ValueError.new(attr, message)
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ class NoConfigurationFileFoundError < ::IOError
33
+ def message
34
+ 'no configuration file found'
35
+ end
36
+ end
37
+
38
+ class ValueError < ::StandardError
39
+ def initialize(attr, message)
40
+ @attr = attr
41
+ @message = message
42
+ end
43
+
44
+ def message
45
+ '%s: %s' % [@attr, @message]
46
+ end
47
+ end
48
+ end
49
+
50
+ class I18nFlow::Configuration
51
+ include Validation
52
+
53
+ CONFIG_FILES = [
54
+ 'i18n_flow.yml',
55
+ 'i18n_flow.yaml',
56
+ ].freeze
57
+
58
+ LINTERS = %i[
59
+ file_scope
60
+ symmetry
61
+ ].freeze
62
+
63
+ attr_reader(*%i[
64
+ base_path
65
+ glob_patterns
66
+ valid_locales
67
+ locale_pairs
68
+ linters
69
+ split_max_level
70
+ split_line_threshold
71
+ ])
72
+
73
+ validate :base_path, 'need to be an absolute path' do
74
+ !!base_path&.absolute?
75
+ end
76
+
77
+ validate :glob_patterns, 'should be an array' do
78
+ !glob_patterns.nil?
79
+ end
80
+
81
+ validate :glob_patterns, 'should contain at least one pattern' do
82
+ glob_patterns.any?
83
+ end
84
+
85
+ validate :valid_locales, 'should be an array' do
86
+ !valid_locales.nil?
87
+ end
88
+
89
+ validate :valid_locales, 'should contain at least one pattern' do
90
+ valid_locales.any?
91
+ end
92
+
93
+ validate :locale_pairs, 'should be an array' do
94
+ !locale_pairs.nil?
95
+ end
96
+
97
+ validate :linters, "should be an array" do
98
+ !linters.nil?
99
+ end
100
+
101
+ validate :linters, "should contain any of [#{LINTERS.join(', ')}]" do
102
+ (linters - LINTERS).empty?
103
+ end
104
+
105
+ validate :split_max_level, 'must be set' do
106
+ !split_max_level.nil?
107
+ end
108
+
109
+ validate :split_line_threshold, 'must be set' do
110
+ !split_line_threshold.nil?
111
+ end
112
+
113
+ def initialize
114
+ update(validate: false) do |c|
115
+ c.base_path = File.expand_path('.')
116
+ c.glob_patterns = ['*.en.yml']
117
+ c.valid_locales = %w[en]
118
+ c.locale_pairs = []
119
+ c.linters = LINTERS
120
+ c.split_max_level = 3
121
+ c.split_line_threshold = 50
122
+ end
123
+ end
124
+
125
+ def base_path=(path)
126
+ @base_path = path&.tap do |v|
127
+ break Pathname.new(v)
128
+ end
129
+ end
130
+
131
+ def glob_patterns=(patterns)
132
+ @glob_patterns = patterns&.tap do |v|
133
+ break unless v.is_a?(Array)
134
+ break v.map(&:to_s)
135
+ end
136
+ end
137
+
138
+ def locale_pairs=(pairs)
139
+ @locale_pairs = pairs&.tap do |v|
140
+ break unless v.is_a?(Array)
141
+ break unless v.all? { |e| e.size == 2 }
142
+ break v.map { |e| e.map(&:to_s) }
143
+ end
144
+ end
145
+
146
+ def valid_locales=(locales)
147
+ @valid_locales = locales&.tap do |v|
148
+ break unless v.is_a?(Array)
149
+ break v.map(&:to_s)
150
+ end
151
+ end
152
+
153
+ def linters=(linters)
154
+ @linters = linters&.tap do |v|
155
+ break unless v.is_a?(Array)
156
+ break v.map(&:to_sym)
157
+ end
158
+ end
159
+
160
+ def split_max_level=(level)
161
+ @split_max_level = level&.tap do |v|
162
+ break unless v.is_a?(Integer)
163
+ end
164
+ end
165
+
166
+ def split_line_threshold=(threshold)
167
+ @split_line_threshold = threshold&.tap do |v|
168
+ break unless v.is_a?(Integer)
169
+ end
170
+ end
171
+
172
+ def update(validate: true)
173
+ yield self if block_given?
174
+ validate! if validate
175
+ end
176
+
177
+ def auto_configure!
178
+ load_from_file!
179
+ update
180
+ end
181
+
182
+ private
183
+
184
+ def load_from_file!
185
+ config_file = I18nFlow::Util.find_file_upward(*CONFIG_FILES)
186
+
187
+ unless config_file
188
+ raise NoConfigurationFileFoundError
189
+ end
190
+
191
+ yaml = YAML.load_file(config_file)
192
+ yaml_dir = File.dirname(config_file)
193
+
194
+ _base_path = yaml.delete('base_path')
195
+ self.base_path = _base_path ? File.absolute_path(_base_path, yaml_dir) : yaml_dir
196
+
197
+ yaml.each do |k, v|
198
+ if respond_to?("#{k}=")
199
+ send("#{k}=", v)
200
+ else
201
+ raise KeyError.new('invalid option: %s' % [k])
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,34 @@
1
+ require 'psych'
2
+ require_relative 'yaml_ast_proxy'
3
+
4
+ class I18nFlow::Parser
5
+ attr_reader :buffer
6
+ attr_reader :file_path
7
+
8
+ def initialize(buffer, file_path: nil)
9
+ @buffer = buffer
10
+ @file_path = file_path
11
+ end
12
+
13
+ def parse!
14
+ parser.parse(buffer)
15
+ end
16
+
17
+ def root
18
+ builder.root
19
+ end
20
+
21
+ def root_proxy
22
+ @root_proxy ||= I18nFlow::YamlAstProxy.create(root, file_path: file_path)
23
+ end
24
+
25
+ private
26
+
27
+ def builder
28
+ @builder ||= Psych::TreeBuilder.new
29
+ end
30
+
31
+ def parser
32
+ @parser ||= Psych::Parser.new(builder)
33
+ end
34
+ end
@@ -0,0 +1,39 @@
1
+ require 'pathname'
2
+ require_relative 'parser'
3
+ require_relative 'util'
4
+
5
+ class I18nFlow::Repository
6
+ def initialize(
7
+ base_path:,
8
+ glob_patterns:
9
+ )
10
+ @base_path = Pathname.new(base_path)
11
+ @glob_patterns = glob_patterns.to_a
12
+ end
13
+
14
+ def file_paths
15
+ @file_paths ||= @glob_patterns
16
+ .flat_map { |pattern| Dir.glob(@base_path.join(pattern)) }
17
+ end
18
+
19
+ def asts_by_path
20
+ @asts ||= file_paths
21
+ .map { |path|
22
+ rel_path = Pathname.new(path).relative_path_from(@base_path).to_s
23
+ parser = I18nFlow::Parser.new(File.read(path), file_path: rel_path)
24
+ parser.parse!
25
+ [rel_path, parser.root_proxy]
26
+ }
27
+ .to_h
28
+ end
29
+
30
+ def asts_by_scope
31
+ @asts_by_scope ||= Hash.new { |h, k| h[k] = {} }
32
+ .tap { |h|
33
+ asts_by_path.each { |path, tree|
34
+ locale, *scopes = I18nFlow::Util.filepath_to_scope(path)
35
+ h[scopes.join('.')][locale] = tree
36
+ }
37
+ }
38
+ end
39
+ end
@@ -0,0 +1,176 @@
1
+ class I18nFlow::Search
2
+ class Item
3
+ attr_reader :locale
4
+ attr_reader :file
5
+ attr_reader :line
6
+ attr_reader :column
7
+ attr_reader :value
8
+ attr_reader :score
9
+
10
+ def initialize(
11
+ locale:,
12
+ file:,
13
+ line:,
14
+ column:,
15
+ value:,
16
+ score:
17
+ )
18
+ @locale = locale
19
+ @file = file
20
+ @line = line
21
+ @column = column
22
+ @value = value
23
+ @score = score
24
+ end
25
+ end
26
+ end
27
+
28
+
29
+ class I18nFlow::Search
30
+ attr_reader :repository
31
+ attr_reader :pattern
32
+
33
+ SCORE_KEY_CS_MATCH = 10
34
+ SCORE_KEY_CI_MATCH = 9
35
+ SCORE_CONTENT_CS_MATCH = 8
36
+ SCORE_CONTENT_CI_MATCH = 6
37
+
38
+ def initialize(
39
+ repository:,
40
+ pattern:,
41
+ include_all: false
42
+ )
43
+ @repository = repository
44
+ @pattern = pattern
45
+ @include_all = include_all
46
+ end
47
+
48
+ def search!
49
+ repository.asts_by_scope.each do |scope, locale_trees|
50
+ asts = locale_trees
51
+ .map { |locale, tree| tree[locale] }
52
+ .compact
53
+
54
+ search_on(asts)
55
+ end
56
+ end
57
+
58
+ def results
59
+ @results ||= indexed_results
60
+ .sort_by { |k, rs| -rs.map(&:score).max }
61
+ .to_h
62
+ end
63
+
64
+ def include_all?
65
+ !!@include_all
66
+ end
67
+
68
+ def indexed_results
69
+ @indexed_results ||= Hash.new { |h, k| h[k] = [] }
70
+ end
71
+
72
+ def pattern_downcase
73
+ @pattern_downcase ||= pattern.downcase
74
+ end
75
+
76
+ private
77
+
78
+ def search_on(asts)
79
+ if include_all?
80
+ score = asts.map { |a| score_for(a) }.max
81
+ if score > 0
82
+ asts.each do |ast|
83
+ add_result(ast, score: score)
84
+ end
85
+ end
86
+ else
87
+ asts.each do |ast|
88
+ score = score_for(ast)
89
+ next unless score > 0
90
+ add_result(ast, score: score)
91
+ end
92
+ end
93
+
94
+ recursive_asts = asts.reject { |a| a.scalar? || a.alias? }
95
+ keys = recursive_asts.flat_map(&:keys).uniq
96
+
97
+ keys.each do |k|
98
+ search_on(recursive_asts.map { |a| a[k] }.compact)
99
+ end
100
+ end
101
+
102
+ def add_result(node, score:)
103
+ locale, *scopes = node.scopes
104
+ key = scopes.join('.')
105
+
106
+ indexed_results[key] << Item.new(
107
+ locale: locale,
108
+ file: node.file_path,
109
+ line: node.start_line,
110
+ column: node.start_column,
111
+ value: node.value,
112
+ score: score,
113
+ )
114
+ end
115
+
116
+ def score_for(node)
117
+ key = node.scopes[1..-1].join('.')
118
+
119
+ key_match_score(key).tap do |score|
120
+ return score if score > 0
121
+ end
122
+
123
+ if node.scalar?
124
+ return content_match_score(node.value)
125
+ end
126
+
127
+ 0
128
+ end
129
+
130
+ def key_match_score(key)
131
+ if key == pattern
132
+ return SCORE_KEY_CS_MATCH
133
+ end
134
+ if key.downcase == pattern_downcase
135
+ return SCORE_KEY_CI_MATCH
136
+ end
137
+
138
+ 0
139
+ end
140
+
141
+ def content_match_score(str)
142
+ if str.include?(pattern)
143
+ return SCORE_CONTENT_CS_MATCH * lcs_score(str, pattern)
144
+ end
145
+
146
+ str = str.downcase
147
+
148
+ if str.include?(pattern_downcase)
149
+ return SCORE_CONTENT_CI_MATCH * lcs_score(str, pattern_downcase)
150
+ end
151
+
152
+ 0
153
+ end
154
+
155
+ def lcs_score(a, b)
156
+ a_size = a.size
157
+ b_size = b.size
158
+
159
+ m = Array.new(a_size) { 0 }
160
+
161
+ result = 0
162
+
163
+ b_size.times do |i|
164
+ result = d = 0
165
+
166
+ a_size.times do |j|
167
+ t = (b[i] == a[j]) ? d + 1 : d
168
+ d = m[j]
169
+
170
+ m[j] = result = [d, t, result].max
171
+ end
172
+ end
173
+
174
+ result * 2.0 / (a_size + b_size)
175
+ end
176
+ end
@@ -0,0 +1,60 @@
1
+ module I18nFlow::Splitter
2
+ class Merger
3
+ attr_reader :chunks
4
+
5
+ def initialize(chunks)
6
+ @chunks = chunks
7
+ end
8
+
9
+ def root
10
+ @root ||= I18nFlow::YamlAstProxy.new_root
11
+ end
12
+
13
+ def perform_merge!
14
+ chunks.each do |chunk|
15
+ append_chunk(chunk)
16
+ end
17
+ end
18
+
19
+ def to_yaml
20
+ root.parent.to_yaml
21
+ end
22
+
23
+ private
24
+
25
+ def append_chunk(chunk)
26
+ parent = root
27
+
28
+ chunk.scopes[0..(chunk.scalar? ? -2 : -1)]
29
+ .each
30
+ .with_index do |scope, i|
31
+ next_scope = chunk.scopes[i + 1]
32
+ is_seq = next_scope ? next_scope&.is_a?(Integer) : chunk.sequence?
33
+
34
+ node = parent[scope]
35
+
36
+ if node && (!is_seq && !node.mapping? || is_seq && !node.sequence?)
37
+ # TODO: should raise?
38
+ return
39
+ end
40
+
41
+ unless node
42
+ parent[scope] = if is_seq
43
+ Psych::Nodes::Sequence.new
44
+ else
45
+ Psych::Nodes::Mapping.new
46
+ end
47
+ node = parent[scope]
48
+ end
49
+
50
+ parent = node
51
+ end
52
+
53
+ if chunk.scalar?
54
+ parent[chunk.scopes[-1]] = chunk.node
55
+ else
56
+ parent.merge!(chunk)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,66 @@
1
+ module I18nFlow::Splitter
2
+ class Strategy
3
+ DEFAULT_MAX_LEVEL = 3
4
+ DEFAULT_LINE_THRESHOLD = 50
5
+
6
+ def initialize(
7
+ ast,
8
+ max_level: DEFAULT_MAX_LEVEL,
9
+ line_threshold: DEFAULT_LINE_THRESHOLD
10
+ )
11
+ @ast = ast
12
+ @max_level = max_level
13
+ @line_threshold = line_threshold
14
+ end
15
+
16
+ def split!
17
+ @chunks = nil
18
+ traverse(@ast, level: 0)
19
+ end
20
+
21
+ def chunks
22
+ @chunks ||= Hash.new { |h, k| h[k] = [] }
23
+ end
24
+
25
+ private
26
+
27
+ def traverse(node, level:)
28
+ return if node.scalar?
29
+
30
+ if level > 0 && node.mapping?
31
+ others, mappings = node.values.partition(&:scalar?)
32
+
33
+ others.each do |n|
34
+ add_chunk(n, delta_level: -2)
35
+ end
36
+
37
+ if mappings.sum(&:num_lines) < @line_threshold
38
+ mappings.each do |n|
39
+ add_chunk(n, delta_level: -2)
40
+ end
41
+ return
42
+ end
43
+
44
+ if level >= @max_level
45
+ add_chunk(node)
46
+ return
47
+ end
48
+ end
49
+
50
+ if node.sequence?
51
+ add_chunk(node, delta_level: -2)
52
+ return
53
+ end
54
+
55
+ node.each do |_, v|
56
+ traverse(v, level: level + 1)
57
+ end
58
+ end
59
+
60
+ def add_chunk(node, delta_level: 0)
61
+ level = [node.scopes.size + delta_level, 1].max
62
+ file_scopes = node.scopes[0...level]
63
+ chunks[file_scopes] << node
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ module I18nFlow::Splitter
2
+ end
3
+
4
+ require_relative 'splitter/strategy'
5
+ require_relative 'splitter/merger'
@@ -0,0 +1,57 @@
1
+ module I18nFlow::Util
2
+ extend self
3
+
4
+ def extract_args(text)
5
+ text.to_s
6
+ .gsub(/%%/, '')
7
+ .scan(/%\{([^\}]+)\}/)
8
+ .flatten
9
+ .sort
10
+ end
11
+
12
+ def filepath_to_scope(filepath)
13
+ *scopes, filename = filepath.split('/')
14
+ *basename, locale, _ = filename.split('.')
15
+
16
+ ([locale] + scopes + basename).compact.reject(&:empty?)
17
+ end
18
+
19
+ def scope_to_filepath(scopes)
20
+ locale, *components = scopes
21
+ [components.join('/'), locale, 'yml'].compact.reject(&:empty?).join('.')
22
+ end
23
+
24
+ def find_file_upward(*file_names)
25
+ pwd = Dir.pwd
26
+ base = Hash.new { |h, k| h[k] = pwd }
27
+ file = {}
28
+
29
+ while base.values.all? { |b| '.' != b && '/' != b }
30
+ file_names.each do |name|
31
+ file[name] = File.join(base[name], name)
32
+ base[name] = File.dirname(base[name])
33
+
34
+ return file[name] if File.exists?(file[name])
35
+ end
36
+ end
37
+
38
+ nil
39
+ end
40
+
41
+ def parse_options(args)
42
+ options = {}
43
+
44
+ args.reject! do |arg|
45
+ case arg
46
+ when /^-([a-z0-9])$/i, /^--([a-z0-9][a-z0-9-]*)$/i
47
+ options[$1] = true
48
+ when /^--([a-z0-9][a-z0-9-]*)=(.+)$/i
49
+ options[$1] = $2
50
+ else
51
+ break
52
+ end
53
+ end
54
+
55
+ options
56
+ end
57
+ end