puttext 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.
@@ -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