planter-cli 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Planter
4
+ ## String helpers
5
+ class ::String
6
+ ##
7
+ ## Convert string to snake-cased variable name
8
+ ##
9
+ ## @example "Planter String" #=> planter_string
10
+ ## @example "Planter-String" #=> planter_string
11
+ ##
12
+ ## @return [Symbol] string as variable key
13
+ ##
14
+ def to_var
15
+ snake_case.to_sym
16
+ end
17
+
18
+ ##
19
+ ## Convert a slug into a class name
20
+ ##
21
+ ## @example "planter-string".to_class_name #=> PlanterString
22
+ ##
23
+ ## @return Class name representation of the object.
24
+ ##
25
+ def to_class_name
26
+ strip.no_ext.split(/[-_ ]/).map(&:capitalize).join('').gsub(/[^a-z0-9]/i, '')
27
+ end
28
+
29
+ ##
30
+ ## Convert a class name to a file slug
31
+ ##
32
+ ## @example "PlanterString".to_class_name #=> planter-string
33
+ ##
34
+ ## @return Filename representation of the object.
35
+ ##
36
+ def to_slug
37
+ strip.split(/(?=[A-Z ])/).map(&:downcase).join('-')
38
+ .gsub(/[^a-z0-9_-]/i, &:slugify)
39
+ .gsub(/-+/, '-')
40
+ .gsub(/(^-|-$)/, '')
41
+ end
42
+
43
+ ## Convert some characters to text
44
+ ##
45
+ ## @return [String] slugified character or empty string
46
+ ##
47
+ def slugify
48
+ char = to_s
49
+ slug_version = {
50
+ '.' => 'dot',
51
+ '/' => 'slash',
52
+ ':' => 'colon',
53
+ ',' => 'comma',
54
+ '!' => 'bang',
55
+ '#' => 'hash'
56
+ }
57
+ slug_version[char] ? "-#{slug_version[char]}-" : ''
58
+ end
59
+
60
+ ##
61
+ ## Convert a string to snake case, handling spaces or CamelCasing
62
+ ##
63
+ ## @example "ClassName".snake_case #=> class-name
64
+ ## @example "A title string".snake_case #=> a-title-string
65
+ ##
66
+ ## @return [String] Snake-cased version of string
67
+ ##
68
+ def snake_case
69
+ strip.gsub(/\S[A-Z]/) { |pair| pair.split('').join('_') }
70
+ .gsub(/[ -]+/, '_')
71
+ .gsub(/[^a-z0-9_]+/i, '')
72
+ .gsub(/_+/, '_')
73
+ .gsub(/(^_|_$)/, '').downcase
74
+ end
75
+
76
+ ##
77
+ ## Convert a string to camel case, handling spaces or snake_casing
78
+ ##
79
+ ## @example "class_name".camel_case #=> className
80
+ ## @example "A title string".camel_case #=> aTitleString
81
+ ##
82
+ ## @return [String] Snake-cased version of string
83
+ ##
84
+ def camel_case
85
+ strip.gsub(/[ _]+(\S)/) { Regexp.last_match(1).upcase }
86
+ .gsub(/[^a-z0-9]+/i, '')
87
+ .sub(/^(\w)/) { Regexp.last_match(1).downcase }
88
+ end
89
+
90
+ ##
91
+ ## Capitalize the first character after a word border. Prevents downcasing
92
+ ## intercaps.
93
+ ##
94
+ ## @example "a title string".title_case #=> A Title String
95
+ ##
96
+ ## @return [String] title cased string
97
+ ##
98
+ def title_case
99
+ gsub(/\b(\w)/) { Regexp.last_match(1).upcase }
100
+ end
101
+
102
+ ##
103
+ ## Apply key/value substitutions to a string. Variables are represented as
104
+ ## %%key%%, and the hash passed to the function is { key: value }
105
+ ##
106
+ ## @param last_only [Boolean] Only replace the last instance of %%key%%
107
+ ##
108
+ ## @return [String] string with variables substituted
109
+ ##
110
+ def apply_variables(last_only: false)
111
+ content = dup.clean_encode
112
+ mod_rx = '(?<mod>
113
+ (?::
114
+ (
115
+ l(?:ow(?:er)?)?)?|
116
+ u(?:p(?:per)?)?|
117
+ c(?:ap(?:ital(?:ize)?)?)?|
118
+ t(?:itle)?|
119
+ snake|camel|slug|
120
+ f(?:ile(?:name)?
121
+ )?
122
+ )*
123
+ )'
124
+
125
+ Planter.variables.each do |k, v|
126
+ if last_only
127
+ pattern = "%%#{k.to_var}"
128
+ content = content.reverse.sub(/(?mix)%%(?:(?<mod>.*?):)*(?<key>#{pattern.reverse})/) do
129
+ m = Regexp.last_match
130
+ if m['mod']
131
+ m['mod'].reverse.split(/:/).each do |mod|
132
+ v = v.apply_mod(mod.normalize_mod)
133
+ end
134
+ end
135
+
136
+ v.reverse
137
+ end.reverse
138
+ else
139
+ rx = /(?mix)%%(?<key>#{k.to_var})#{mod_rx}%%/
140
+
141
+ content.gsub!(rx) do
142
+ m = Regexp.last_match
143
+
144
+ mods = m['mod']&.split(/:/)
145
+ mods&.each do |mod|
146
+ v = v.apply_mod(mod.normalize_mod)
147
+ end
148
+ v
149
+ end
150
+ end
151
+ end
152
+
153
+ content
154
+ end
155
+
156
+ ##
157
+ ## Apply regex replacements from @config[:replacements]
158
+ ##
159
+ ## @return [String] string with regexes applied
160
+ ##
161
+ def apply_regexes
162
+ content = dup.clean_encode
163
+ return self unless Planter.config.key?(:replacements)
164
+
165
+ Planter.config[:replacements].stringify_keys.each do |pattern, replacement|
166
+ pattern = Regexp.new(pattern) unless pattern.is_a?(Regexp)
167
+ replacement = replacement.gsub(/\$(\d)/, '\\\1').apply_variables
168
+ content.gsub!(pattern, replacement)
169
+ end
170
+ content
171
+ end
172
+
173
+ ##
174
+ ## Destructive version of #apply_variables
175
+ ##
176
+ ## @param last_only [Boolean] Only replace the last instance of %%key%%
177
+ ##
178
+ ## @return [String] string with variables substituted
179
+ ##
180
+ def apply_variables!(last_only: false)
181
+ replace apply_variables(last_only: last_only)
182
+ end
183
+
184
+ ##
185
+ ## Destructive version of #apply_regexes
186
+ ##
187
+ ## @return [String] string with variables substituted
188
+ ##
189
+ def apply_regexes!
190
+ replace apply_regexes
191
+ end
192
+
193
+ ##
194
+ ## Remove any file extension
195
+ ##
196
+ ## @example "planter-string.rb".no_ext #=> planter-string
197
+ ##
198
+ ## @return [String] string with no extension
199
+ ##
200
+ def no_ext
201
+ sub(/\.\w{2,4}$/, '')
202
+ end
203
+
204
+ ##
205
+ ## Add an extension to the string, replacing existing extension if needed
206
+ ##
207
+ ## @example "planter-string".ext('rb') #=> planter-string.rb
208
+ ##
209
+ ## @example "planter-string.rb".ext('erb') #=> planter-string.erb
210
+ ##
211
+ ## @param extension [String] The extension to add
212
+ ##
213
+ ## @return [String] string with new extension
214
+ ##
215
+ def ext(extension)
216
+ extension = extension.sub(/^\./, '')
217
+ sub(/(\.\w+)?$/, ".#{extension}")
218
+ end
219
+
220
+ ##
221
+ ## Apply a modification to string
222
+ ##
223
+ ## @param mod [Symbol] The modifier to apply
224
+ ##
225
+ def apply_mod(mod)
226
+ case mod
227
+ when :slug
228
+ to_slug
229
+ when :title_case
230
+ title_case
231
+ when :lowercase
232
+ downcase
233
+ when :uppercase
234
+ upcase
235
+ when :snake_case
236
+ snake_case
237
+ when :camel_case
238
+ camel_case
239
+ else
240
+ self
241
+ end
242
+ end
243
+
244
+ ##
245
+ ## Convert mod string to symbol
246
+ ##
247
+ ## @example "snake" => :snake_case
248
+ ## @example "cap" => :title_case
249
+ ##
250
+ ## @return [Symbol] symbolized modifier
251
+ ##
252
+ def normalize_mod
253
+ case self
254
+ when /^(f|slug)/
255
+ :slug
256
+ when /^cam/
257
+ :camel_case
258
+ when /^s/
259
+ :snake_case
260
+ when /^u/
261
+ :uppercase
262
+ when /^l/
263
+ :lowercase
264
+ when /^[ct]/
265
+ :title_case
266
+ end
267
+ end
268
+
269
+ ##
270
+ ## Convert operator string to symbol
271
+ ##
272
+ ## @example "ignore" => :ignore
273
+ ## @example "m" => :merge
274
+ ##
275
+ ## @return [Symbol] symbolized operator
276
+ ##
277
+ def normalize_operator
278
+ case self
279
+ # merge or append
280
+ when /^i/
281
+ :ignore
282
+ when /^(m|ap)/
283
+ :merge
284
+ # ask or optional
285
+ when /^(a|op)/
286
+ :ask
287
+ # overwrite
288
+ when /^o/
289
+ :overwrite
290
+ else
291
+ :copy
292
+ end
293
+ end
294
+
295
+ ##
296
+ ## Convert type string to symbol
297
+ ##
298
+ ## @example "string".coerce #=> :string
299
+ ## @example "date".coerce #=> :date
300
+ ## @example "num".coerce #=> :number
301
+ ##
302
+ ## @return [Symbol] type symbol
303
+ ##
304
+ def normalize_type
305
+ case self
306
+ # date
307
+ when /^da/
308
+ :date
309
+ # integer
310
+ when /^i/
311
+ :integer
312
+ # number or float
313
+ when /^[nf]/
314
+ :float
315
+ # multiline or paragraph
316
+ when /^(mu|p)/
317
+ :multiline
318
+ # class
319
+ when /^c/
320
+ :class
321
+ # module
322
+ when /^m/
323
+ :module
324
+ # string
325
+ else
326
+ :string
327
+ end
328
+ end
329
+
330
+ ##
331
+ ## Coerce a variable to a type
332
+ ##
333
+ ## @param type [Symbol] The type
334
+ ##
335
+ ##
336
+ def coerce(type)
337
+ case type
338
+ when :date
339
+ Chronic.parse(self)
340
+ when :integer || :number
341
+ to_i
342
+ when :float
343
+ to_f
344
+ when :class || :module
345
+ to_class_name
346
+ else
347
+ to_s
348
+ end
349
+ end
350
+
351
+ ##
352
+ ## Get a clean UTF-8 string by forcing an ISO encoding and then re-encoding
353
+ ##
354
+ ## @return [String] UTF-8 string
355
+ ##
356
+ def clean_encode
357
+ force_encoding('ISO-8859-1').encode('utf-8', replace: nil)
358
+ end
359
+
360
+ ##
361
+ ## Destructive version of #clean_encode
362
+ ##
363
+ ## @return [String] UTF-8 string, in place
364
+ ##
365
+ def clean_encode!
366
+ replace clean_encode
367
+ end
368
+
369
+ ##
370
+ ## Highlight characters in parenthesis, with special color for default if
371
+ ## provided. Output is color templated string, unprocessed.
372
+ ##
373
+ ## @param default [String] The default
374
+ ##
375
+ def highlight_character(default: nil)
376
+ if default
377
+ gsub(/\((#{default})\)/, '{dw}({xbc}\1{dw}){xw}').gsub(/\((.)\)/, '{dw}({xbw}\1{dw}){xw}')
378
+ else
379
+ gsub(/\((.)\)/, '{dw}({xbw}\1{dw}){xw}')
380
+ end
381
+ end
382
+ end
383
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Symbol helpers
4
+ class ::Symbol
5
+ # Handle calling to_var on a Symbol
6
+ #
7
+ # @return [Symbol] same symbol, normalized if needed
8
+ #
9
+ def to_var
10
+ to_s.to_var
11
+ end
12
+
13
+ # Handle calling normalize_type on a Symbol
14
+ #
15
+ # @return [Symbol] same symbol, normalized if needed
16
+ #
17
+ def normalize_type
18
+ to_s.normalize_type
19
+ end
20
+
21
+ # Handle calling normalize_operator on a Symbol
22
+ #
23
+ # @return [Symbol] same symbol, normalized if needed
24
+ #
25
+ def normalize_operator
26
+ to_s.normalize_operator
27
+ end
28
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Primary module for this gem.
4
+ module Planter
5
+ # Current Planter version.
6
+ VERSION = '0.0.3'
7
+ end
data/lib/planter.rb ADDED
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'shellwords'
5
+ require 'json'
6
+ require 'yaml'
7
+ require 'fileutils'
8
+ require 'open3'
9
+
10
+ require 'chronic'
11
+ require 'tty-reader'
12
+ require 'tty-screen'
13
+ require 'tty-spinner'
14
+ require 'tty-which'
15
+
16
+ require_relative 'planter/version'
17
+ require_relative 'planter/hash'
18
+ require_relative 'planter/array'
19
+ require_relative 'planter/symbol'
20
+ require_relative 'planter/file'
21
+ require_relative 'planter/color'
22
+ require_relative 'planter/errors'
23
+ require_relative 'planter/prompt'
24
+ require_relative 'planter/string'
25
+ require_relative 'planter/filelist'
26
+ require_relative 'planter/fileentry'
27
+ require_relative 'planter/plant'
28
+
29
+ # Main Journal module
30
+ module Planter
31
+ # Base directory for templates
32
+ BASE_DIR = File.expand_path('~/.config/planter/')
33
+
34
+ class << self
35
+ include Color
36
+ include Prompt
37
+
38
+ ## Debug mode
39
+ attr_accessor :debug
40
+
41
+ ## Target
42
+ attr_accessor :target
43
+
44
+ ## Overwrite files
45
+ attr_accessor :overwrite
46
+
47
+ ## Current date
48
+ attr_accessor :date
49
+
50
+ ## Template name
51
+ attr_accessor :template
52
+
53
+ ## Config Hash
54
+ attr_reader :config
55
+
56
+ ## Variable key/values
57
+ attr_accessor :variables
58
+
59
+ ## Filter patterns
60
+ attr_writer :patterns
61
+
62
+ ## Accept all defaults
63
+ attr_accessor :accept_defaults
64
+
65
+ ##
66
+ ## Print a message on the command line
67
+ ##
68
+ ## @param string [String] The message string
69
+ ## @param notification_type [Symbol] The notification type (:debug, :error, :warn, :info)
70
+ ## @param exit_code [Integer] If provided, exit with code after delivering message
71
+ ##
72
+ def notify(string, notification_type = :info, exit_code: nil)
73
+ case notification_type
74
+ when :debug
75
+ warn "\n{dw}#{string}{x}".x if @debug
76
+ when :error
77
+ warn "{br}#{string}{x}".x
78
+ when :warn
79
+ warn "{by}#{string}{x}".x
80
+ else
81
+ warn "{bw}#{string}{x}".x
82
+ end
83
+
84
+ Process.exit exit_code unless exit_code.nil?
85
+ end
86
+
87
+ ##
88
+ ## Global progress indicator reader, will init if nil
89
+ ##
90
+ ## @return [TTY::Spinner] Spinner object
91
+ ##
92
+ def spinner
93
+ @spinner ||= TTY::Spinner.new('{bw}[{by}:spinner{bw}] {w}:title'.x,
94
+ hide_cursor: true,
95
+ format: :dots,
96
+ success_mark: '{bg}✔{x}'.x,
97
+ error_mark: '{br}✖{x}'.x)
98
+ end
99
+
100
+ ##
101
+ ## Build a configuration from template name
102
+ ##
103
+ ## @param template [String] The template name
104
+ ##
105
+ ## @return [Hash] Configuration object
106
+ ##
107
+ def config=(template)
108
+ Planter.spinner.update(title: 'Initializing configuration')
109
+ @template = template
110
+ Planter.variables ||= {}
111
+ FileUtils.mkdir_p(BASE_DIR) unless File.directory?(BASE_DIR)
112
+ base_config = File.join(BASE_DIR, 'config.yml')
113
+
114
+ unless File.exist?(base_config)
115
+ default_base_config = {
116
+ defaults: false,
117
+ git_init: false,
118
+ files: { '_planter.yml' => 'ignore' },
119
+ color: true
120
+ }
121
+ File.open(base_config, 'w') { |f| f.puts(YAML.dump(default_base_config.stringify_keys)) }
122
+ Planter.notify("New configuration written to #{config}, edit as needed.", :warn)
123
+ end
124
+
125
+ @config = YAML.load(IO.read(base_config)).symbolize_keys
126
+
127
+ base_dir = File.join(BASE_DIR, 'templates', @template)
128
+ unless File.directory?(base_dir)
129
+ notify("Template #{@template} does not exist", :error)
130
+ res = Prompt.yn('Create template directory', default_response: false)
131
+
132
+ raise Errors::InputError.new('Canceled') unless res
133
+
134
+ FileUtils.mkdir_p(base_dir)
135
+ end
136
+
137
+ load_template_config
138
+
139
+ config_array_to_hash(:files) if @config[:files].is_a?(Array)
140
+ config_array_to_hash(:replacements) if @config[:replacements].is_a?(Array)
141
+ rescue Psych::SyntaxError => e
142
+ raise Errors::ConfigError.new "Parse error in configuration file:\n#{e.message}"
143
+ end
144
+
145
+ ##
146
+ ## Load a template-specific configuration
147
+ ##
148
+ ## @return [Hash] updated config object
149
+ ##
150
+ def load_template_config
151
+ base_dir = File.join(BASE_DIR, 'templates', @template)
152
+ config = File.join(base_dir, '_planter.yml')
153
+
154
+ unless File.exist?(config)
155
+ default_config = {
156
+ variables: [
157
+ key: 'var_key',
158
+ prompt: 'CLI Prompt',
159
+ type: '[string, float, integer, number, date]',
160
+ value: '(optional, for date type can be today, time, now, etc., empty to prompt)',
161
+ default: '(optional default value, leave empty or remove key for no default)',
162
+ min: '(optional, for number type set a minimum value)',
163
+ max: '(optional, for number type set a maximum value)'
164
+ ],
165
+ git_init: false,
166
+ files: { '*.tmp' => 'ignore' }
167
+ }
168
+ File.open(config, 'w') { |f| f.puts(YAML.dump(default_config.stringify_keys)) }
169
+ puts "New configuration written to #{config}, please edit."
170
+ Process.exit 0
171
+ end
172
+ @config = @config.deep_merge(YAML.load(IO.read(config)).symbolize_keys)
173
+ end
174
+
175
+ ##
176
+ ## Convert an errant array to a hash
177
+ ##
178
+ ## @param key [Symbol] The key in @config to convert
179
+ ##
180
+ def config_array_to_hash(key)
181
+ files = {}
182
+ @config[key].each do |k, v|
183
+ files[k] = v
184
+ end
185
+ @config[key] = files
186
+ end
187
+
188
+ ##
189
+ ## Patterns reader, file handling config
190
+ ##
191
+ ## @return [Hash] hash of file patterns
192
+ ##
193
+ def patterns
194
+ @patterns ||= process_patterns
195
+ end
196
+
197
+ ##
198
+ ## Process :files in config into regex pattern/operator pairs
199
+ ##
200
+ ## @return [Hash] { regex => operator } hash
201
+ ##
202
+ def process_patterns
203
+ patterns = {}
204
+ @config[:files].each do |file, oper|
205
+ pattern = Regexp.new(".*?/#{file.to_s.sub(%r{^/}, '').gsub(/\./, '\.').gsub(/\*/, '.*?').gsub(/\?/, '.')}$")
206
+ operator = oper.normalize_operator
207
+ patterns[pattern] = operator
208
+ end
209
+ patterns
210
+ end
211
+
212
+ ##
213
+ ## Execute a shell command and return a Boolean success response
214
+ ##
215
+ ## @param cmd [String] The shell command
216
+ ##
217
+ def pass_fail(cmd)
218
+ _, status = Open3.capture2("#{cmd} &> /dev/null")
219
+ status.exitstatus.zero?
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,48 @@
1
+ lib = File.expand_path(File.join('..', 'lib'), __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'planter/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'planter-cli'
7
+ spec.version = Planter::VERSION
8
+ spec.authors = ['Brett Terpstra']
9
+ spec.email = ['me@brettterpstra.com']
10
+ spec.description = 'Plant a file and directory structure'
11
+ spec.summary = 'Plant files and directories using templates'
12
+ spec.homepage = 'https://github.com/ttscoff/planter-cli'
13
+ spec.license = 'MIT'
14
+
15
+ spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(features|spec|test)/})
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.required_ruby_version = '>= 2.6.0'
21
+
22
+ spec.add_development_dependency 'bump', '~> 0.10'
23
+ spec.add_development_dependency 'bundler', '~> 2.2'
24
+ spec.add_development_dependency 'rake', '~> 13.0'
25
+
26
+ spec.add_development_dependency 'guard', '~> 2.11'
27
+ spec.add_development_dependency 'guard-rspec', '~> 4.5'
28
+ spec.add_development_dependency 'guard-rubocop', '~> 1.2'
29
+ spec.add_development_dependency 'guard-yard', '~> 2.1'
30
+
31
+ spec.add_development_dependency 'cli-test', '~> 1.0'
32
+ spec.add_development_dependency 'fuubar', '~> 2.0'
33
+ spec.add_development_dependency 'rspec', '~> 3.13'
34
+ spec.add_development_dependency 'rubocop', '>= 1.50'
35
+ spec.add_development_dependency 'rubocop-rake', '>= 0.6.0'
36
+ spec.add_development_dependency 'rubocop-rspec', '>= 2.20.0'
37
+ spec.add_development_dependency 'simplecov', '~> 0.9'
38
+
39
+ spec.add_development_dependency 'github-markup', '~> 1.3'
40
+ spec.add_development_dependency 'redcarpet', '~> 3.2'
41
+ spec.add_development_dependency 'yard', '~> 0.9.5'
42
+
43
+ spec.add_runtime_dependency 'chronic', '~> 0.10'
44
+ spec.add_runtime_dependency 'tty-reader', '~> 0.9'
45
+ spec.add_runtime_dependency 'tty-screen', '~> 0.8'
46
+ spec.add_runtime_dependency 'tty-spinner', '~> 0.9'
47
+ spec.add_runtime_dependency 'tty-which', '~> 0.5'
48
+ end