planter-cli 0.0.3

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,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