puttext 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../po_entry'
4
+
5
+ module PutText
6
+ module Parser
7
+ # Thrown when any error parsing a file occurs
8
+ class ParseError < StandardError; end
9
+
10
+ class Base
11
+ # Parse gettext strings from a file in the path.
12
+ # @param [String] path the path of the file to parse.
13
+ # @return [Array<POEntry>] an array of POEntry objects extracted
14
+ # from the given file.
15
+ def strings_from_file(path)
16
+ strings_from_source(File.read(path), filename: path)
17
+ end
18
+
19
+ # @abstract Subclass is expected to implement #strings_from_source
20
+ # @!method strings_from_source(source, opts)
21
+ # Parse gettext strings from a given snippet of source code.
22
+ # @param [String] source the snippet of source code to parse.
23
+ # @param [Hash] opts
24
+ # @option opts [String] :filename path of the file being parsed.
25
+ # Defaults to "(string)".
26
+ # @option opts [Integer] :first_line number of the first line being
27
+ # parsed. Defaults to 1.
28
+ # @return [Array<POEntry>] an array of POEntry objects
29
+ # extracted from the given source code.
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../po_entry'
5
+
6
+ require 'parser/current'
7
+
8
+ # opt-in to most recent Parser AST format:
9
+ Parser::Builders::Default.emit_lambda = true
10
+ Parser::Builders::Default.emit_procarg0 = true
11
+
12
+ module PutText
13
+ module Parser
14
+ class Ruby < Base
15
+ METHODS = {
16
+ gettext: :regular,
17
+ _: :regular,
18
+ ngettext: :plural,
19
+ n_: :plural,
20
+ sgettext: :context_sep,
21
+ s_: :context_sep,
22
+ nsgettext: :context_sep_plural,
23
+ ns_: :context_sep_plural,
24
+ pgettext: :context,
25
+ p_: :context,
26
+ npgettext: :context_plural,
27
+ np_: :context_plural
28
+ }.freeze
29
+
30
+ PARAMS = {
31
+ regular: %i(msgid),
32
+ plural: %i(msgid msgid_plural),
33
+ context: %i(msgctxt msgid),
34
+ context_plural: %i(msgctxt msgid msgid_plural),
35
+ context_sep: %i(msgid separator),
36
+ context_sep_plural: %i(msgid msgid_plural _ separator)
37
+ }.freeze
38
+
39
+ def initialize
40
+ @ruby_parser = ::Parser::CurrentRuby.new
41
+ end
42
+
43
+ def strings_from_source(source, filename: '(string)', first_line: 1)
44
+ buffer = ::Parser::Source::Buffer.new(filename, first_line)
45
+ buffer.source = source
46
+
47
+ @ruby_parser.reset
48
+ ast = @ruby_parser.parse(buffer)
49
+
50
+ if ast.is_a? ::Parser::AST::Node
51
+ find_strings_in_ast(ast)
52
+ else
53
+ []
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def string_from_ast_node(ast_node)
60
+ return if ast_node.nil?
61
+
62
+ case ast_node.type
63
+ when :str
64
+ ast_node.children[0]
65
+ else
66
+ raise ParseError,
67
+ format('unsupported AST node type: %s', ast_node.type)
68
+ end
69
+ end
70
+
71
+ def po_entry_from_ast_node(ast_node, type)
72
+ filename = ast_node.location.expression.source_buffer.name
73
+ line = ast_node.location.line
74
+
75
+ entry_attrs = { references: ["#{filename}:#{line}"] }
76
+
77
+ PARAMS[type].each_with_index do |name, index|
78
+ next if name == :_ # skip parameters named _
79
+
80
+ param = string_from_ast_node(ast_node.children[index + 2])
81
+ entry_attrs[name] = param if param
82
+ end
83
+
84
+ PutText::POEntry.new(entry_attrs)
85
+ end
86
+
87
+ def find_strings_in_ast(ast_node)
88
+ entries = []
89
+
90
+ if ast_node.type == :send && METHODS[ast_node.children[1]]
91
+ entries << po_entry_from_ast_node(
92
+ ast_node,
93
+ METHODS[ast_node.children[1]]
94
+ )
95
+ else
96
+ entries += find_strings_in_each_ast(ast_node.children)
97
+ end
98
+
99
+ entries
100
+ end
101
+
102
+ def find_strings_in_each_ast(ast_nodes)
103
+ entries = []
104
+
105
+ ast_nodes.each do |node|
106
+ next unless node.is_a? ::Parser::AST::Node
107
+ entries += find_strings_in_ast(node)
108
+ end
109
+
110
+ entries
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'ruby'
5
+
6
+ begin
7
+ require 'slim'
8
+
9
+ module PutText
10
+ module Parser
11
+ class Slim < Base
12
+ def initialize
13
+ @ruby_parser = PutText::Parser::Ruby.new
14
+ @slim_engine = Engine.new
15
+ end
16
+
17
+ def strings_from_source(source, filename: '(string)', first_line: 1)
18
+ slim_ruby_code = @slim_engine.call(source)
19
+
20
+ @ruby_parser.strings_from_source(
21
+ slim_ruby_code,
22
+ filename: filename,
23
+ first_line: first_line
24
+ )
25
+ end
26
+
27
+ class IgnoreEmbedded < ::Slim::Filter
28
+ def on_slim_embedded(_name, body)
29
+ newlines = count_newlines(body)
30
+
31
+ node = [:multi]
32
+ newlines.times { node.push [:newline] }
33
+ node
34
+ end
35
+
36
+ private
37
+
38
+ def count_newlines(body)
39
+ newlines = 0
40
+ newlines += 1 if body.first == :newline
41
+
42
+ body.each do |el|
43
+ newlines += count_newlines(el) if el.is_a?(Array)
44
+ end
45
+
46
+ newlines
47
+ end
48
+ end
49
+
50
+ class Engine < ::Slim::Engine
51
+ replace ::Slim::Embedded, IgnoreEmbedded
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ # rubocop:disable Lint/HandleExceptions
58
+ rescue LoadError
59
+ # Optional dependency, do not fail if not found
60
+ end
61
+ # rubocop:enable Lint/HandleExceptions
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PutText
4
+ class POEntry
5
+ NS_SEPARATOR = '|'.freeze
6
+
7
+ PO_C_STYLE_ESCAPES = {
8
+ "\n" => '\\n',
9
+ "\r" => '\\r',
10
+ "\t" => '\\t',
11
+ '\\' => '\\\\',
12
+ '"' => '\\"'
13
+ }.freeze
14
+
15
+ attr_reader :msgid
16
+ attr_reader :msgid_plural
17
+ attr_reader :msgctxt
18
+ attr_reader :references
19
+
20
+ # Create a new POEntry
21
+ #
22
+ # @param [Hash] attrs
23
+ # @option attrs [String] :msgid the id of the string (the string that needs
24
+ # to be translated). Can include a context, separated from the id by
25
+ # {NS_SEPARATOR} or by the specified :separator.
26
+ # @option attrs [String] :msgid_plural the pluralized id of the string (the
27
+ # pluralized string that needs to be translated).
28
+ # @option attrs [String] :msgctxt the context of the string.
29
+ # @option attrs [Array<String>] :references a list of files with line
30
+ # numbers, pointing to where the string was found.
31
+ # @option attrs [String] :separator the separator of context from id in
32
+ # :msgid.
33
+ def initialize(attrs)
34
+ id, ctx = extract_context(
35
+ attrs[:msgid], attrs[:separator] || NS_SEPARATOR
36
+ )
37
+
38
+ @msgid = id
39
+ @msgctxt = attrs[:msgctxt] || ctx
40
+ @msgid_plural = attrs[:msgid_plural]
41
+ @references = attrs[:references] || []
42
+ end
43
+
44
+ # Convert the entry to a string representation, to be written to a .po file
45
+ # @return [String] a string representation of the entry.
46
+ def to_s
47
+ str = String.new('')
48
+
49
+ # Add comments
50
+ str = add_comment(str, ':', @references.join(' ')) if references?
51
+
52
+ # Add id and context
53
+ str = add_string(str, 'msgctxt', @msgctxt) if @msgctxt
54
+ str = add_string(str, 'msgid', @msgid)
55
+ str = add_string(str, 'msgid_plural', @msgid_plural) if plural?
56
+ str = add_translations(str)
57
+
58
+ str
59
+ end
60
+
61
+ # Check if the entry has any references.
62
+ # @return [Boolean] whether the entry has any references.
63
+ def references?
64
+ !@references.empty?
65
+ end
66
+
67
+ # Check if the entry has a plural form.
68
+ # @return [Boolean] whether the entry has a plural form.
69
+ def plural?
70
+ !@msgid_plural.nil?
71
+ end
72
+
73
+ # Return an object uniquely identifying this entry. The returned object can
74
+ # be used to find duplicate entries.
75
+ # @return an object uniquely identifying this entry.
76
+ def unique_key
77
+ [@msgid, @msgctxt]
78
+ end
79
+
80
+ # Merge this entry with another entry. Modifies the current entry in place.
81
+ # Currently, merges only the references, and leaves other attributes of the
82
+ # current entry untouched.
83
+ #
84
+ # @param [POEntry] other_entry the entry to merge with.
85
+ # @return [POEntry] the merged entry.
86
+ def merge(other_entry)
87
+ @references += other_entry.references
88
+ self
89
+ end
90
+
91
+ def ==(other)
92
+ @msgid == other.msgid &&
93
+ @msgid_plural == other.msgid_plural &&
94
+ @msgctxt == other.msgctxt &&
95
+ @references == other.references
96
+ end
97
+
98
+ private
99
+
100
+ def extract_context(str, separator)
101
+ parts = str.rpartition(separator)
102
+ [parts[2], parts[0] == '' ? nil : parts[0]]
103
+ end
104
+
105
+ def add_comment(str, comment_type, value)
106
+ value.each_line do |line|
107
+ str << '#'
108
+ str << comment_type
109
+ str << ' '
110
+ str << line
111
+ str << "\n"
112
+ end
113
+
114
+ str
115
+ end
116
+
117
+ def add_string(str, id, value)
118
+ str << id
119
+ str << ' '
120
+ str << string_to_po(value)
121
+ str << "\n"
122
+ end
123
+
124
+ def add_translations(str)
125
+ if plural?
126
+ add_string(str, 'msgstr[0]', '')
127
+ add_string(str, 'msgstr[1]', '')
128
+ else
129
+ add_string(str, 'msgstr', '')
130
+ end
131
+
132
+ str
133
+ end
134
+
135
+ def string_to_po(str)
136
+ lines = str.split("\n", -1)
137
+
138
+ if lines.empty?
139
+ '""'
140
+ elsif lines.length == 1
141
+ "\"#{po_escape_string(lines[0])}\""
142
+ else
143
+ multiline_string_to_po(lines)
144
+ end
145
+ end
146
+
147
+ def multiline_string_to_po(str_lines)
148
+ po_str = String.new('""')
149
+
150
+ str_lines.each_with_index do |line, index|
151
+ last = index == str_lines.length - 1
152
+ add_multiline_str_part(po_str, line, last)
153
+ end
154
+
155
+ po_str
156
+ end
157
+
158
+ def add_multiline_str_part(str, part, last)
159
+ return if last && part.empty?
160
+
161
+ str << "\n\""
162
+ str << po_escape_string(part)
163
+ str << '\\n' unless last
164
+ str << '"'
165
+ end
166
+
167
+ def po_escape_string(str)
168
+ encoded = String.new('')
169
+
170
+ str.each_char do |char|
171
+ encoded << if PO_C_STYLE_ESCAPES[char]
172
+ PO_C_STYLE_ESCAPES[char]
173
+ else
174
+ char
175
+ end
176
+ end
177
+
178
+ encoded
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PutText
4
+ class POFile
5
+ attr_accessor :entries
6
+
7
+ # Create a new POFile
8
+ # @param [Array<POEntry>] entries an array of POEntry objects, that should
9
+ # be placed in this file.
10
+ def initialize(entries)
11
+ @entries = entries
12
+ end
13
+
14
+ def to_s
15
+ str_io = StringIO.new
16
+ write_to(str_io)
17
+ str_io.string
18
+ end
19
+
20
+ # Write the contents of this file to the specified IO object.
21
+ # @param [IO] io the IO object to write the contents of the file to.
22
+ def write_to(io)
23
+ deduplicate
24
+
25
+ @entries.each_with_index do |entry, index|
26
+ io.write("\n") unless index == 0
27
+ io.write(entry.to_s)
28
+ end
29
+ end
30
+
31
+ def ==(other)
32
+ @entries.sort == other.entries.sort
33
+ end
34
+
35
+ private
36
+
37
+ def deduplicate
38
+ uniq_entries = {}
39
+
40
+ @entries.each do |entry|
41
+ key = entry.unique_key
42
+
43
+ if uniq_entries[key]
44
+ uniq_entries[key].merge(entry)
45
+ else
46
+ uniq_entries[key] = entry
47
+ end
48
+ end
49
+
50
+ @entries = uniq_entries.values
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'puttext'
7
+ s.version = '0.1.0'
8
+ s.date = Date.today.to_s
9
+ s.summary = 'Extract gettext strings from Ruby source'
10
+ s.authors = ['Mantas Norvaiša']
11
+ s.email = 'mntnorv@gmail.com'
12
+ s.homepage = 'https://github.com/mntnorv/puttext'
13
+ s.license = 'MIT'
14
+
15
+ s.files = `git ls-files`.split
16
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ s.require_paths = ['lib']
18
+
19
+ s.required_ruby_version = '>= 2.0.0'
20
+
21
+ s.add_runtime_dependency('parser', '>= 2.4.0.0', '< 3.0')
22
+
23
+ # Optional dependencies at runtime, required for development
24
+ s.add_development_dependency('slim', '~> 3.0')
25
+
26
+ # Tools
27
+ s.add_development_dependency('rake')
28
+
29
+ # Testing
30
+ s.add_development_dependency('rspec', '~> 3.5')
31
+ s.add_development_dependency('unindent')
32
+
33
+ # Linters and code policies
34
+ s.add_development_dependency('rubocop', '~> 0.46.0')
35
+ s.add_development_dependency('simplecov')
36
+ s.add_development_dependency('codeclimate-test-reporter', '~> 1.0.0')
37
+ end