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.
- checksums.yaml +7 -0
- data/.codeclimate.yml +19 -0
- data/.gitattributes +17 -0
- data/.gitignore +50 -0
- data/.rspec +1 -0
- data/.rubocop.yml +26 -0
- data/.ruby-version +1 -0
- data/.travis.yml +18 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +68 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/Rakefile +8 -0
- data/bin/puttext +6 -0
- data/lib/puttext.rb +6 -0
- data/lib/puttext/cmdline.rb +71 -0
- data/lib/puttext/extractor.rb +114 -0
- data/lib/puttext/parser/base.rb +32 -0
- data/lib/puttext/parser/ruby.rb +114 -0
- data/lib/puttext/parser/slim.rb +61 -0
- data/lib/puttext/po_entry.rb +181 -0
- data/lib/puttext/po_file.rb +53 -0
- data/puttext.gemspec +37 -0
- data/spec/fixtures/extractor_fixtures/another_file.php +0 -0
- data/spec/fixtures/extractor_fixtures/file_1.rb +0 -0
- data/spec/fixtures/extractor_fixtures/file_2.rb +0 -0
- data/spec/fixtures/extractor_fixtures/random_file.txt +0 -0
- data/spec/fixtures/extractor_fixtures/subfolder/subfile.py +0 -0
- data/spec/fixtures/extractor_fixtures/subfolder/subfile_1.rb +0 -0
- data/spec/fixtures/extractor_fixtures/subfolder/subfile_2.rb +0 -0
- data/spec/fixtures/parser_base_shared_fixture.rb +3 -0
- data/spec/shared/parser_base_shared.rb +38 -0
- data/spec/spec_helper.rb +12 -0
- data/spec/unit/extractor_spec.rb +168 -0
- data/spec/unit/parser/ruby_spec.rb +176 -0
- data/spec/unit/parser/slim_spec.rb +86 -0
- data/spec/unit/po_entry_spec.rb +327 -0
- data/spec/unit/po_file_spec.rb +53 -0
- metadata +199 -0
@@ -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
|
data/puttext.gemspec
ADDED
@@ -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
|