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,99 @@
1
+ module I18nFlow::Validator
2
+ class Error
3
+ attr_reader :key
4
+ attr_reader :file
5
+ attr_reader :line
6
+
7
+ def initialize(key)
8
+ @key = key
9
+ end
10
+
11
+ def ==(other)
12
+ return false unless other.is_a?(self.class)
13
+ data == other.data
14
+ end
15
+
16
+ def data
17
+ [key]
18
+ end
19
+
20
+ def single?
21
+ !!@single
22
+ end
23
+
24
+ def set_location(node)
25
+ @file = node.file_path
26
+ @line = node.start_line
27
+ self
28
+ end
29
+ end
30
+
31
+ class InvalidTypeError < Error
32
+ def initialize(key, single: false)
33
+ super(key)
34
+ @single = single
35
+ end
36
+ end
37
+
38
+ class MissingKeyError < Error
39
+ def initialize(key, single: false)
40
+ super(key)
41
+ @single = single
42
+ end
43
+ end
44
+
45
+ class ExtraKeyError < Error
46
+ def initialize(key, single: false)
47
+ super(key)
48
+ @single = single
49
+ end
50
+ end
51
+
52
+ class InvalidTodoError < Error
53
+ end
54
+
55
+ class TodoContentError < Error
56
+ attr_reader :expect
57
+ attr_reader :actual
58
+
59
+ def initialize(key, expect:, actual:)
60
+ super(key)
61
+ @expect = expect
62
+ @actual = actual
63
+ end
64
+
65
+ def data
66
+ super + [expect, actual]
67
+ end
68
+ end
69
+
70
+ class InvalidLocaleError < Error
71
+ attr_reader :expect
72
+ attr_reader :actual
73
+
74
+ def initialize(key, expect:, actual:)
75
+ super(key)
76
+ @expect = expect
77
+ @actual = actual
78
+ end
79
+
80
+ def data
81
+ super + [expect, actual]
82
+ end
83
+ end
84
+
85
+ class AsymmetricArgsError < Error
86
+ attr_reader :expect
87
+ attr_reader :actual
88
+
89
+ def initialize(key, expect:, actual:)
90
+ super(key)
91
+ @expect = expect
92
+ @actual = actual
93
+ end
94
+
95
+ def data
96
+ super + [expect, actual]
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,58 @@
1
+ require_relative 'errors'
2
+ require_relative '../util'
3
+
4
+ module I18nFlow::Validator
5
+ class FileScope
6
+ attr_reader :ast
7
+ attr_reader :filepath
8
+
9
+ def initialize(ast, filepath:)
10
+ @ast = ast
11
+ @filepath = filepath
12
+ end
13
+
14
+ def filepath_scopes
15
+ @filepath_scopes ||= I18nFlow::Util.filepath_to_scope(filepath)
16
+ end
17
+
18
+ def validate!
19
+ @errors = nil
20
+ validate_scope(ast, scopes: filepath_scopes)
21
+ end
22
+
23
+ def errors
24
+ @errors ||= []
25
+ end
26
+
27
+ private
28
+
29
+ def validate_scope(tree, scopes:)
30
+ scopes.each_with_index do |scope, i|
31
+ node = tree[scope]
32
+
33
+ if node.nil?
34
+ full_key = scopes[0..i].join('.')
35
+ errors << MissingKeyError.new(full_key, single: true).set_location(tree)
36
+ break
37
+ end
38
+
39
+ if tree.mapping? && tree.size > 1
40
+ parent_scopes = scopes[0...i]
41
+ (tree.keys - [scope]).each do |key|
42
+ full_key = [*parent_scopes, key].join('.')
43
+ errors << ExtraKeyError.new(full_key, single: true).set_location(node)
44
+ end
45
+ break
46
+ end
47
+
48
+ if node.scalar?
49
+ full_key = scopes[0..i].join('.')
50
+ errors << InvalidTypeError.new(full_key, single: true).set_location(node)
51
+ break
52
+ end
53
+
54
+ tree = node
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,58 @@
1
+ require_relative 'file_scope'
2
+ require_relative 'symmetry'
3
+ require_relative '../parser'
4
+
5
+ module I18nFlow::Validator
6
+ class Multiplexer
7
+ attr_reader :repository
8
+ attr_reader :valid_locales
9
+ attr_reader :locale_pairs
10
+ attr_reader :linters
11
+
12
+ def initialize(
13
+ repository:,
14
+ valid_locales:,
15
+ locale_pairs:,
16
+ linters: %i[file_scope symmetry]
17
+ )
18
+ @repository = repository
19
+ @valid_locales = valid_locales
20
+ @locale_pairs = locale_pairs
21
+ @linters = linters
22
+ end
23
+
24
+ def validate!
25
+ @errors = nil
26
+
27
+ if linters.include?(:file_scope)
28
+ repository.asts_by_path.each do |path, tree|
29
+ validator = FileScope.new(tree, filepath: path)
30
+ validator.validate!
31
+ validator.errors.each do |err|
32
+ errors[err.file][err.key] = err
33
+ end
34
+ end
35
+ end
36
+
37
+ if linters.include?(:symmetry)
38
+ repository.asts_by_scope.each do |scope, locale_trees|
39
+ locale_pairs.each do |(master, slave)|
40
+ master_tree = locale_trees[master]
41
+ slave_tree = locale_trees[slave]
42
+ next unless master_tree && slave_tree
43
+
44
+ validator = Symmetry.new(master_tree[master], slave_tree[slave])
45
+ validator.validate!
46
+ validator.errors.each do |err|
47
+ errors[err.file][err.key] = err
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def errors
55
+ @errors ||= Hash.new { |h, k| h[k] = {} }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,154 @@
1
+ require_relative 'errors'
2
+ require_relative '../util'
3
+
4
+ module I18nFlow::Validator
5
+ class Symmetry
6
+ attr_reader :ast_1
7
+ attr_reader :ast_2
8
+
9
+ def initialize(ast_1, ast_2)
10
+ @ast_1 = ast_1
11
+ @ast_2 = ast_2
12
+ end
13
+
14
+ def validate!
15
+ @errors = nil
16
+ validate_content(ast_1, ast_2)
17
+ end
18
+
19
+ def errors
20
+ @errors ||= []
21
+ end
22
+
23
+ private
24
+
25
+ def validate_content(t1, t2)
26
+ keys = t1.keys | t2.keys
27
+
28
+ keys.each do |k|
29
+ validate_node(t1, t2, k)
30
+ end
31
+ end
32
+
33
+ def validate_node(t1, t2, key)
34
+ n1 = t1[key]
35
+ n2 = t2[key]
36
+
37
+ check_only_tag(n1, n2)&.tap do |err|
38
+ errors << err if err
39
+ return
40
+ end
41
+
42
+ check_asymmetric_key(n1, n2, t2)&.tap do |err|
43
+ errors << err if err
44
+ return
45
+ end
46
+
47
+ check_type(n1, n2)&.tap do |err|
48
+ errors << err
49
+ return
50
+ end
51
+
52
+ check_todo_tag(n1, n2)&.tap do |err|
53
+ errors << err
54
+ return
55
+ end
56
+
57
+ if n1.scalar? || n1.alias?
58
+ check_args(n1, n2)&.tap do |err|
59
+ errors << err
60
+ end
61
+ else
62
+ validate_content(n1, n2)
63
+ end
64
+ end
65
+
66
+ def check_only_tag(n1, n2)
67
+ return unless n1&.marked_as_only? || n2&.marked_as_only?
68
+
69
+ if n1 && !n1.valid_locale?
70
+ return InvalidLocaleError.new(n1.full_key,
71
+ expect: n1.valid_locales,
72
+ actual: n1.locale,
73
+ ).set_location(n1)
74
+ end
75
+
76
+ if n2 && !n2.valid_locale?
77
+ return InvalidLocaleError.new(n2.full_key,
78
+ expect: n2.valid_locales,
79
+ actual: n2.locale,
80
+ ).set_location(n2)
81
+ end
82
+
83
+ if n1 && !n2 && n1.marked_as_only?
84
+ return false
85
+ end
86
+
87
+ if !n1 && n2 && n2.marked_as_only?
88
+ return false
89
+ end
90
+
91
+ if n1 && n2 && n1.valid_locales.any? && !n1.valid_locales.include?(n2.locale)
92
+ return InvalidLocaleError.new(n2.full_key,
93
+ expect: n1.valid_locales,
94
+ actual: n2.locale,
95
+ ).set_location(n2)
96
+ end
97
+
98
+ if n1 && n2 && n2.valid_locales.any? && !n2.valid_locales.include?(n1.locale)
99
+ return InvalidLocaleError.new(n1.full_key,
100
+ expect: n2.valid_locales,
101
+ actual: n1.locale,
102
+ ).set_location(n1)
103
+ end
104
+
105
+ false
106
+ end
107
+
108
+ def check_type(n1, n2)
109
+ return unless n1 && n2
110
+ return if n1.scalar? == n2.scalar?
111
+
112
+ InvalidTypeError.new(n2.full_key).set_location(n2)
113
+ end
114
+
115
+ def check_asymmetric_key(n1, n2, t2)
116
+ return false if n1&.ignored_violation == :key || n2&.ignored_violation == :key
117
+ return if n1 && n2
118
+
119
+ if n1
120
+ full_key = [t2.locale, *n1.scopes.drop(1)].join('.')
121
+ MissingKeyError.new(full_key).set_location(t2)
122
+ else
123
+ ExtraKeyError.new(n2.full_key).set_location(n2)
124
+ end
125
+ end
126
+
127
+ def check_todo_tag(n1, n2)
128
+ return unless n2.marked_as_todo?
129
+
130
+ if !n2.scalar?
131
+ InvalidTodoError.new(n2.full_key).set_location(n2)
132
+ elsif n2.value != n1.value
133
+ TodoContentError.new(n2.full_key,
134
+ expect: n1.value,
135
+ actual: n2.value,
136
+ ).set_location(n2)
137
+ end
138
+ end
139
+
140
+ def check_args(n1, n2)
141
+ return if n1.ignored_violation == :args || n2.ignored_violation == :args
142
+
143
+ args_1 = I18nFlow::Util.extract_args(n1.value).uniq
144
+ args_2 = I18nFlow::Util.extract_args(n2.value).uniq
145
+
146
+ return if args_1 == args_2
147
+
148
+ AsymmetricArgsError.new(n2.full_key,
149
+ expect: args_1,
150
+ actual: args_2,
151
+ ).set_location(n2)
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,4 @@
1
+ module I18nFlow::Validator
2
+ end
3
+
4
+ require_relative 'validator/multiplexer'
@@ -0,0 +1,7 @@
1
+ module I18nFlow
2
+ MAJOR = 0
3
+ MINOR = 1
4
+ REVISION = 0
5
+
6
+ VERSION = [MAJOR, MINOR, REVISION].join('.')
7
+ end
@@ -0,0 +1,72 @@
1
+ require 'psych'
2
+ require_relative 'node'
3
+
4
+ module I18nFlow::YamlAstProxy
5
+ class Mapping < Node
6
+ extend Forwardable
7
+
8
+ def_delegators :indexed_object, :==, :keys, :size
9
+
10
+ def each
11
+ indexed_object.each do |k, _|
12
+ yield k, cache[k]
13
+ end
14
+ end
15
+
16
+ def values
17
+ indexed_object.map { |k, _| cache[k] }
18
+ end
19
+
20
+ def set(key, value)
21
+ super.tap do
22
+ cache.delete(key)
23
+ synchronize!
24
+ end
25
+ end
26
+ alias []= set
27
+
28
+ def batch
29
+ @locked = true
30
+ yield
31
+ ensure
32
+ @locked = false
33
+ synchronize!
34
+ end
35
+
36
+ def merge!(other)
37
+ return unless other&.is_a?(Mapping)
38
+
39
+ batch do
40
+ other.batch do
41
+ other.each do |k, rhs|
42
+ if (lhs = self[k])
43
+ lhs.merge!(rhs)
44
+ else
45
+ self[k] = rhs.node
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def cache
55
+ @cache ||= Hash.new { |h, k| h[k] = wrap(indexed_object[k], key: k) }
56
+ end
57
+
58
+ def indexed_object
59
+ @indexed_object ||= node.children
60
+ .each_slice(2)
61
+ .map { |k, v| [k.value, v] }
62
+ .to_h
63
+ end
64
+
65
+ def synchronize!
66
+ return if @locked
67
+
68
+ children = indexed_object.flat_map { |k, v| [Psych::Nodes::Scalar.new(k), v] }
69
+ node.children.replace(children)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,128 @@
1
+ require_relative 'node_meta_data'
2
+ require 'forwardable'
3
+
4
+ module I18nFlow::YamlAstProxy
5
+ class Node
6
+ extend Forwardable
7
+ include NodeMetaData
8
+
9
+ TAG_IGNORE = /^!ignore:(args|key)$/
10
+ TAG_TODO = /^!todo(?::([,a-zA-Z_-]+))?$/
11
+ TAG_ONLY = /^!only(?::([,a-zA-Z_-]+))?$/
12
+
13
+ attr_reader :node
14
+ attr_reader :parent
15
+ attr_reader :scopes
16
+ attr_reader :file_path
17
+ attr_reader :ignored_violation
18
+
19
+ def_delegators :indexed_object, :each
20
+
21
+ def initialize(
22
+ node,
23
+ parent: nil,
24
+ scopes: [],
25
+ file_path: nil
26
+ )
27
+ @node = node
28
+ @parent = parent
29
+ @scopes = scopes
30
+ @file_path = file_path
31
+
32
+ parse_tag!(node.tag)
33
+ end
34
+
35
+ def get(key)
36
+ wrap(indexed_object[key], key: key)
37
+ end
38
+ alias [] get
39
+
40
+ def set(key, value)
41
+ indexed_object[key] = value
42
+ end
43
+ alias []= set
44
+
45
+ def value
46
+ node.value if node.respond_to?(:value)
47
+ end
48
+
49
+ def merge!(other)
50
+ return unless other&.is_a?(Node)
51
+
52
+ if scalar? && other.scalar?
53
+ node.value = other.value
54
+ return
55
+ end
56
+
57
+ if !scalar? && !other.scalar?
58
+ batch do
59
+ other.batch do
60
+ other.each do |k, rhs|
61
+ if (lhs = self[k])
62
+ lhs.merge!(rhs)
63
+ else
64
+ self[k] = rhs.node
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ def batch
73
+ yield
74
+ end
75
+
76
+ def ==(other)
77
+ return false unless other.is_a?(self.class)
78
+ identity_data == other.identity_data
79
+ end
80
+
81
+ def to_yaml
82
+ parent.to_yaml(nil, line_width: -1)
83
+ end
84
+
85
+ def keys
86
+ return [] if scalar? || alias?
87
+ indexed_object.keys
88
+ end
89
+
90
+ private
91
+
92
+ def identity_data
93
+ (scalar? || alias?) ? value : indexed_object
94
+ end
95
+
96
+ def indexed_object
97
+ @indexed_object ||= I18nFlow::YamlAstProxy.create(node,
98
+ parent: parent,
99
+ scopes: scopes,
100
+ file_path: file_path,
101
+ )
102
+ end
103
+
104
+ def wrap(value, key:)
105
+ I18nFlow::YamlAstProxy.create(value,
106
+ parent: node,
107
+ scopes: [*scopes, key],
108
+ file_path: file_path,
109
+ )
110
+ end
111
+
112
+ def parse_tag!(tag)
113
+ return unless tag
114
+
115
+ case tag
116
+ when TAG_TODO
117
+ @tag = :todo
118
+ @todo_locales = $1.to_s.split(',').freeze
119
+ when TAG_ONLY
120
+ @tag = :only
121
+ @valid_locales = $1.to_s.split(',').freeze
122
+ when TAG_IGNORE
123
+ @tag = :ignore
124
+ @ignored_violation = $1.freeze.to_sym
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,86 @@
1
+ module I18nFlow::YamlAstProxy
2
+ module NodeMetaData
3
+ def num_lines
4
+ return 1 unless end_line
5
+ end_line - start_line + 1
6
+ end
7
+
8
+ def key
9
+ scopes.last
10
+ end
11
+
12
+ def locale
13
+ scopes.first
14
+ end
15
+
16
+ def full_key
17
+ scopes.join('.')
18
+ end
19
+
20
+ def start_line
21
+ node.start_line + line_correction
22
+ end
23
+
24
+ def end_line
25
+ node.end_line + line_correction
26
+ end
27
+
28
+ def start_column
29
+ node.start_column
30
+ end
31
+
32
+ def end_column
33
+ node.end_column
34
+ end
35
+
36
+ def anchor
37
+ node.anchor
38
+ end
39
+
40
+ def sequence?
41
+ is_a?(Sequence)
42
+ end
43
+
44
+ def mapping?
45
+ is_a?(Mapping)
46
+ end
47
+
48
+ def scalar?
49
+ node.is_a?(Psych::Nodes::Scalar)
50
+ end
51
+
52
+ def alias?
53
+ node.is_a?(Psych::Nodes::Alias)
54
+ end
55
+
56
+ def has_anchor?
57
+ !!anchor
58
+ end
59
+
60
+ def marked_as_todo?
61
+ @tag == :todo
62
+ end
63
+
64
+ def marked_as_only?
65
+ @tag == :only
66
+ end
67
+
68
+ def todo_locales
69
+ @todo_locales ||= []
70
+ end
71
+
72
+ def valid_locales
73
+ @valid_locales ||= []
74
+ end
75
+
76
+ def valid_locale?
77
+ valid_locales.empty? || valid_locales.include?(locale)
78
+ end
79
+
80
+ private
81
+
82
+ def line_correction
83
+ node.is_a?(Psych::Nodes::Scalar) ? 1 : 0
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,29 @@
1
+ require_relative 'node'
2
+
3
+ module I18nFlow::YamlAstProxy
4
+ class Sequence < Node
5
+ def_delegators :indexed_object, :==, :<<, :size
6
+
7
+ def each
8
+ indexed_object.each.with_index do |o, i|
9
+ yield i, wrap(o, key: i)
10
+ end
11
+ end
12
+
13
+ def merge!(other)
14
+ return unless other&.is_a?(Sequence)
15
+
16
+ indexed_object.concat(other.send(:indexed_object))
17
+ end
18
+
19
+ def keys
20
+ (0...size).to_a
21
+ end
22
+
23
+ private
24
+
25
+ def indexed_object
26
+ node.children
27
+ end
28
+ end
29
+ end