i18n_flow 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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