snibbets 2.0.29 → 2.0.31

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env ruby
2
+ # Snibbets 2.0.0
3
+
4
+ require 'optparse'
5
+ require 'readline'
6
+ require 'json'
7
+ require 'cgi'
8
+ require 'shellwords'
9
+
10
+ $search_path = File.expand_path("~/Desktop/Code/Snippets")
11
+
12
+ class String
13
+ # Are there multiple snippets (indicated by ATX headers)
14
+ def multiple?
15
+ return self.scan(/^#+/).length > 1
16
+ end
17
+
18
+ # Is the snippet in this block fenced?
19
+ def fenced?
20
+ count = self.scan(/^```/).length
21
+ return count > 1 && count.even?
22
+ end
23
+
24
+ def rx
25
+ return ".*" + self.gsub(/\s+/,'.*') + ".*"
26
+ end
27
+
28
+ # remove outside comments, fences, and indentation
29
+ def clean_code
30
+ block = self
31
+
32
+ # if it's a fenced code block, just discard the fence and everything
33
+ # outside it
34
+ if block.fenced?
35
+ return block.gsub(/(?:^|.*?\n)(`{3,})(\w+)?(.*?)\n\1.*/m) {|m| $3.strip }
36
+ end
37
+
38
+ # assume it's indented code, discard non-indented lines and outdent
39
+ # the rest
40
+ indent = nil
41
+ inblock = false
42
+ code = []
43
+ block.split(/\n/).each {|line|
44
+ if line =~ /^\s*$/ && inblock
45
+ code.push(line)
46
+ elsif line =~ /^( {4,}|\t+)/
47
+ inblock = true
48
+ indent ||= Regexp.new("^#{$1}")
49
+ code.push(line.sub(indent,''))
50
+ else
51
+ inblock = false
52
+ end
53
+ }
54
+ code.join("\n")
55
+ end
56
+
57
+ # Returns an array of snippets. Single snippets are returned without a
58
+ # title, multiple snippets get titles from header lines
59
+ def snippets
60
+ content = self.dup
61
+ # If there's only one snippet, just clean it and return
62
+ unless self.multiple?
63
+ return [{"title" => "", "code" => content.clean_code.strip}]
64
+ end
65
+
66
+ # Split content by ATX headers. Everything on the line after the #
67
+ # becomes the title, code is gleaned from text between that and the
68
+ # next ATX header (or end)
69
+ sections = []
70
+ parts = content.split(/^#+/)
71
+ parts.shift
72
+
73
+ parts.each do |p|
74
+ lines = p.split(/\n/)
75
+ title = lines.shift.strip.sub(/[.:]$/, '')
76
+ block = lines.join("\n")
77
+ code = block.clean_code
78
+ next unless code && !code.empty?
79
+
80
+ sections << {
81
+ 'title' => title,
82
+ 'code' => code.strip
83
+ }
84
+ end
85
+ return sections
86
+ end
87
+ end
88
+
89
+ def gum_menu(executable, res, title)
90
+ options = res.map { |m| m['title'] }
91
+ puts title
92
+ selection = `echo #{Shellwords.escape(options.join("\n"))} | #{executable} choose --limit 1`.strip
93
+ Process.exit 1 if selection.empty?
94
+
95
+ res.select { |m| m['title'] =~ /#{selection}/ }[0]
96
+ end
97
+
98
+ def fzf_menu(executable, res, title)
99
+ options = res.map { |m| m['title'] }
100
+ args = [
101
+ "--height=#{options.count + 2}",
102
+ %(--prompt="#{title} > "),
103
+ '-1'
104
+ ]
105
+ selection = `echo #{Shellwords.escape(options.join("\n"))} | #{executable} #{args.join(' ')}`.strip
106
+ Process.exit 1 if selection.empty?
107
+
108
+ res.select { |m| m['title'] =~ /#{selection}/ }[0]
109
+ end
110
+
111
+ def menu(res, title = 'Select one')
112
+ fzf = `which fzf`.strip
113
+ return fzf_menu(fzf, res, title) unless fzf.empty?
114
+
115
+ gum = `which gum`.strip
116
+ return gum_menu(gum, res, title) unless gum.empty?
117
+
118
+ console_menu(res, title)
119
+ end
120
+
121
+ # Generate a numbered menu, items passed must have a title property
122
+ def console_menu(res, title)
123
+ stty_save = `stty -g`.chomp
124
+
125
+ trap('INT') do
126
+ system('stty', stty_save)
127
+ Process.exit 1
128
+ end
129
+
130
+ # Generate a numbered menu, items passed must have a title property('INT') { system('stty', stty_save); exit }
131
+ counter = 1
132
+ $stderr.puts
133
+ res.each do |m|
134
+ $stderr.printf("%<counter>2d) %<title>s\n", counter: counter, title: m['title'])
135
+ counter += 1
136
+ end
137
+ $stderr.puts
138
+
139
+ begin
140
+ $stderr.printf(title.sub(/:?$/, ': '), res.length)
141
+ while (line = Readline.readline('', true))
142
+ unless line =~ /^[0-9]/
143
+ system('stty', stty_save) # Restore
144
+ exit
145
+ end
146
+ line = line.to_i
147
+ return res[line - 1] if line.positive? && line <= res.length
148
+
149
+ warn 'Out of range'
150
+ menu(res, title)
151
+ end
152
+ rescue Interrupt
153
+ system('stty', stty_save)
154
+ exit
155
+ end
156
+ end
157
+
158
+ # Search the snippets directory for query using find and grep
159
+ def search(query, folder, try = 0)
160
+ # First try only search by filenames
161
+ # Second try search with grep
162
+ # Third try search with Spotlight name only
163
+ # Fourth try search with Spotlight all contents
164
+ cmd = case try
165
+ when 0
166
+ %(find "#{folder}" -iregex '#{query.rx}')
167
+ when 1
168
+ %(grep -iEl '#{query.rx}' "#{folder}/"*.md)
169
+ when 2
170
+ %(mdfind -onlyin "#{folder}" -name '#{query}' 2>/dev/null)
171
+ when 3
172
+ %(mdfind -onlyin "#{folder}" '#{query}' 2>/dev/null)
173
+ end
174
+
175
+ matches = `#{cmd}`.strip
176
+
177
+ results = []
178
+
179
+ if !matches.empty?
180
+ lines = matches.split(/\n/)
181
+ lines.each do |l|
182
+ results << {
183
+ 'title' => File.basename(l, '.*'),
184
+ 'path' => l
185
+ }
186
+ end
187
+ results
188
+ else
189
+ return [] if try > 2
190
+
191
+ # if no results on the first try, try again searching all text
192
+ search(query, folder, try + 1)
193
+ end
194
+ end
195
+
196
+ def highlight_pygments(executable, code, syntax, theme)
197
+ syntax = syntax.empty? ? '-g' : "-l #{syntax}"
198
+ `echo #{Shellwords.escape(code)} | #{executable} #{syntax}`
199
+ end
200
+
201
+ def highlight_skylight(executable, code, syntax, theme)
202
+ return code if syntax.empty?
203
+
204
+ `echo #{Shellwords.escape(code)} | #{executable} --syntax #{syntax}`
205
+ end
206
+
207
+ def highlight(code, filename, theme = 'monokai')
208
+ syntax = syntax_from_extension(filename)
209
+
210
+ skylight = `which skylighting`.strip
211
+ return highlight_skylight(skylight, code, syntax, theme) unless skylight.empty?
212
+
213
+ pygments = `which pygmentize`.strip
214
+ return highlight_pygments(pygments, code, syntax, theme) unless pygments.empty?
215
+
216
+ code
217
+ end
218
+
219
+ def ext_to_lang(ext)
220
+ case ext
221
+ when /^(as|applescript|scpt)$/
222
+ 'applescript'
223
+ when /^m$/
224
+ 'objective-c'
225
+ when /^(pl|perl)$/
226
+ 'perl'
227
+ when /^py$/
228
+ 'python'
229
+ when /^(js|jq(uery)?|jxa)$/
230
+ 'javascript'
231
+ when /^rb$/
232
+ 'ruby'
233
+ when /^cc$/
234
+ 'c'
235
+ when /^(ba|fi|z|c)?sh$/
236
+ 'bash'
237
+ when /^pl$/
238
+ 'perl'
239
+ else
240
+ if %w[awk sed css sass scss less cpp php c sh swift html erb json xpath sql htaccess].include?(ext)
241
+ ext
242
+ else
243
+ ''
244
+ end
245
+ end
246
+ end
247
+
248
+ def syntax_from_extension(filename)
249
+ ext_to_lang(filename.split(/\./)[1])
250
+ end
251
+
252
+ options = {
253
+ interactive: true,
254
+ launchbar: false,
255
+ output: 'raw',
256
+ source: $search_path,
257
+ highlight: false,
258
+ all: false
259
+ }
260
+
261
+ optparse = OptionParser.new do |opts|
262
+ opts.banner = "Usage: #{File.basename(__FILE__)} [options] query"
263
+
264
+ opts.on('-q', '--quiet', 'Skip menus and display first match') do
265
+ options[:interactive] = false
266
+ options[:launchbar] = false
267
+ end
268
+
269
+ opts.on('-a', '--all', 'If a file contains multiple snippets, output all of them (no menu)') do
270
+ options[:all] = true
271
+ end
272
+
273
+ opts.on('-o', '--output FORMAT', 'Output format (launchbar or raw)') do |outformat|
274
+ valid = %w[json launchbar lb raw]
275
+ if outformat.downcase =~ /(launchbar|lb)/
276
+ options[:launchbar] = true
277
+ options[:interactive] = false
278
+ elsif valid.include?(outformat.downcase)
279
+ options[:output] = outformat.downcase
280
+ end
281
+ end
282
+
283
+ opts.on('-s', '--source FOLDER', 'Snippets folder to search') do |folder|
284
+ options[:source] = File.expand_path(folder)
285
+ end
286
+
287
+ opts.on('--highlight', 'Use pygments or skylighting to syntax highlight (if installed)') do
288
+ options[:highlight] = true
289
+ end
290
+
291
+ opts.on('-h', '--help', 'Display this screen') do
292
+ puts optparse
293
+ Process.exit 0
294
+ end
295
+ end
296
+
297
+ optparse.parse!
298
+
299
+ query = ''
300
+
301
+ if options[:launchbar]
302
+ query = if $stdin.stat.size.positive?
303
+ $stdin.read.force_encoding('utf-8')
304
+ else
305
+ ARGV.join(' ')
306
+ end
307
+ elsif ARGV.length
308
+ query = ARGV.join(' ')
309
+ end
310
+
311
+ query = CGI.unescape(query)
312
+
313
+ if query.strip.empty?
314
+ puts 'No search query'
315
+ puts optparse
316
+ Process.exit 1
317
+ end
318
+
319
+ results = search(query, options[:source], 0)
320
+
321
+ if options[:launchbar]
322
+ output = []
323
+
324
+ if results.empty?
325
+ out = {
326
+ 'title' => 'No matching snippets found'
327
+ }.to_json
328
+ puts out
329
+ Process.exit
330
+ end
331
+
332
+ results.each do |result|
333
+ input = IO.read(result['path'])
334
+ snippets = input.snippets
335
+ next if snippets.empty?
336
+
337
+ children = []
338
+
339
+ if snippets.length == 1
340
+ output << {
341
+ 'title' => result['title'],
342
+ 'path' => result['path'],
343
+ 'action' => 'copyIt',
344
+ 'actionArgument' => snippets[0]['code'],
345
+ 'label' => 'Copy'
346
+ }
347
+ next
348
+ end
349
+
350
+ snippets.each { |s|
351
+ children << {
352
+ 'title' => s['title'],
353
+ 'path' => result['path'],
354
+ 'action' => 'copyIt',
355
+ 'actionArgument' => s['code'],
356
+ 'label' => 'Copy'
357
+ }
358
+ }
359
+
360
+ output << {
361
+ 'title' => result['title'],
362
+ 'path' => result['path'],
363
+ 'children' => children
364
+ }
365
+ end
366
+
367
+ puts output.to_json
368
+ else
369
+ filepath = nil
370
+ if results.empty?
371
+ warn 'No results'
372
+ Process.exit 0
373
+ elsif results.length == 1 || !options[:interactive]
374
+ filepath = results[0]['path']
375
+ input = IO.read(filepath)
376
+ else
377
+ answer = menu(results, 'Select a file')
378
+ filepath = answer['path']
379
+ input = IO.read(filepath)
380
+ end
381
+
382
+ snippets = input.snippets
383
+
384
+ if snippets.empty?
385
+ warn 'No snippets found'
386
+ Process.exit 0
387
+ elsif snippets.length == 1 || !options[:interactive]
388
+ if options[:output] == 'json'
389
+ $stdout.puts snippets.to_json
390
+ else
391
+ snippets.each do |snip|
392
+ code = snip['code']
393
+ code = highlight(code, filepath) if options[:highlight]
394
+ $stdout.puts code
395
+ end
396
+ end
397
+ elsif snippets.length > 1
398
+ if options[:all]
399
+ if options[:output] == 'json'
400
+ $stdout.puts snippets.to_json
401
+ else
402
+ snippets.each do |snippet|
403
+ $stdout.puts snippet['title']
404
+ $stdout.puts '------'
405
+ $stdout.puts snippet['code']
406
+ $stdout.puts
407
+ end
408
+ end
409
+ else
410
+ answer = menu(snippets, 'Select snippet')
411
+ if options[:output] == 'json'
412
+ $stdout.puts answer.to_json
413
+ else
414
+ code = answer['code']
415
+ code = highlight(code, filepath) if options[:highlight]
416
+ $stdout.puts code
417
+ end
418
+ end
419
+ end
420
+ end
data/bin/snibbets CHANGED
@@ -46,6 +46,10 @@ module Snibbets
46
46
  options[:name_only] = v
47
47
  end
48
48
 
49
+ opts.on('--[no-]notes', 'Display the full content of the snippet') do |v|
50
+ options[:all_notes] = v
51
+ end
52
+
49
53
  opts.on('-o', '--output FORMAT', 'Output format (json|launchbar|*raw)') do |outformat|
50
54
  valid = %w[json launchbar lb raw]
51
55
  if outformat.downcase =~ /(launchbar|lb)/
@@ -4,6 +4,10 @@ module Snibbets
4
4
  select { |el| el =~ /^<block\d+>$/ }.count
5
5
  end
6
6
 
7
+ def notes
8
+ select { |el| el !~ /^<block\d+>$/ && el !~ /^```/ && !el.strip.empty? }.count
9
+ end
10
+
7
11
  def strip_empty
8
12
  remove_leading_empty_elements.remove_trailing_empty_elements
9
13
  end
@@ -17,7 +21,7 @@ module Snibbets
17
21
 
18
22
  in_leader = true
19
23
  each do |line|
20
- if (line =~ /^\s*$/ || line.empty?) && in_leader
24
+ if (line.strip.empty?) && in_leader
21
25
  next
22
26
  else
23
27
  in_leader = false
@@ -14,6 +14,7 @@ module Snibbets
14
14
 
15
15
  DEFAULT_OPTIONS = {
16
16
  all: false,
17
+ all_notes: false,
17
18
  copy: false,
18
19
  editor: nil,
19
20
  extension: 'md',
@@ -41,8 +41,24 @@ module Snibbets
41
41
  # `echo #{Shellwords.escape(code)} | #{executable} #{theme}--syntax #{syntax}`
42
42
  end
43
43
 
44
+ def highlight_fences(code, filename, syntax)
45
+ content = code.dup
46
+
47
+ content.fences.each do |f|
48
+ rx = Regexp.new(Regexp.escape(f[:code]))
49
+ highlighted = highlight(f[:code].gsub(/\\k</, '\k\<'), filename, f[:lang] || syntax).strip
50
+ code.sub!(/#{rx}/, highlighted)
51
+ end
52
+
53
+ Snibbets.options[:all_notes] ? code.gsub(/k\\</, 'k<') : code.gsub(/k\\</, 'k<').clean_code
54
+ end
55
+
44
56
  def highlight(code, filename, syntax, theme = nil)
45
- return code unless $stdout.isatty
57
+ unless $stdout.isatty
58
+ return Snibbets.options[:all_notes] && code.replace_blocks[0].notes? ? code : code.clean_code
59
+ end
60
+
61
+ return highlight_fences(code, filename, syntax) if code.fenced?
46
62
 
47
63
  theme ||= Snibbets.options[:highlight_theme] || 'monokai'
48
64
  syntax ||= Lexers.syntax_from_extension(filename)
data/lib/snibbets/menu.rb CHANGED
@@ -14,9 +14,7 @@ module Snibbets
14
14
  def remove_items_without_query(filename, res, query)
15
15
  q = find_query_in_options(filename, res, query).split(/ /)
16
16
  res.delete_if do |opt|
17
- q.none? do |word|
18
- "#{filename} #{opt['title']}" =~ /#{word}/i
19
- end
17
+ q.none? { |word| "#{filename} #{opt['title']}" =~ /#{word}/i }
20
18
  end
21
19
  res
22
20
  end
@@ -28,16 +26,14 @@ module Snibbets
28
26
  end
29
27
 
30
28
  if res.count.zero?
31
- warn 'No matches found'
29
+ warn 'No matches found' if Snibbets.options[:interactive]
32
30
  Process.exit 1
33
31
  end
34
32
 
35
33
  options = res.map { |m| m['title'] }
36
34
 
37
35
  puts title
38
- args = [
39
- "--height=#{options.count}"
40
- ]
36
+ args = ["--height=#{options.count}"]
41
37
  selection = `echo #{Shellwords.escape(options.join("\n"))} | #{executable} filter #{args.join(' ')}`.strip
42
38
  Process.exit 1 if selection.empty?
43
39
 
@@ -82,7 +78,7 @@ module Snibbets
82
78
  end
83
79
 
84
80
  if res.count.zero?
85
- warn 'No matches found'
81
+ warn 'No matches found' if Snibbets.options[:interactive]
86
82
  Process.exit 1
87
83
  end
88
84
 
data/lib/snibbets/os.rb CHANGED
@@ -31,12 +31,12 @@ module Snibbets
31
31
  os = RbConfig::CONFIG['target_os']
32
32
  case os
33
33
  when /darwin.*/i
34
- `pbpaste -pboard general -Prefer txt`
34
+ `pbpaste -pboard general -Prefer txt`.strip_newlines
35
35
  else
36
36
  if TTY::Which.exist?('xclip')
37
- `xclip -o -sel c`
37
+ `xclip -o -sel c`.strip_newlines
38
38
  elsif TTY::Which.exist('xsel')
39
- `xsel -ob`
39
+ `xsel -ob`.strip_newlines
40
40
  else
41
41
  puts 'Paste not supported on this system, please install xclip or xsel.'
42
42
  end
@@ -47,6 +47,28 @@ module Snibbets
47
47
  count > 1 && count.even?
48
48
  end
49
49
 
50
+ def blocks
51
+ replace_blocks[1].count
52
+ end
53
+
54
+ def blocks?
55
+ blocks.positive?
56
+ end
57
+
58
+ def notes?
59
+ replace_blocks[0].split("\n").notes.positive?
60
+ end
61
+
62
+ # Return array of fenced code blocks
63
+ def fences
64
+ return [] unless fenced?
65
+
66
+ rx = /(?mi)^(?<fence>`{3,})(?<lang> *\S+)? *\n(?<code>[\s\S]*?)\n\k<fence> *(?=\n|\Z)/
67
+ matches = []
68
+ scan(rx) { matches << Regexp.last_match }
69
+ matches.each_with_object([]) { |m, fenced| fenced.push({ code: m['code'], lang: m['lang'] }) }
70
+ end
71
+
50
72
  def indented?
51
73
  self =~ /^( {4,}|\t+)/
52
74
  end
@@ -90,11 +112,9 @@ module Snibbets
90
112
 
91
113
  indent = code[0].match(/^( {4,}|\t+)(?=\S)/)
92
114
 
93
- if indent
94
- code.map! { |line| line.sub(/(?mi)^#{indent[1]}/, '') }.join("\n")
95
- else
96
- self
97
- end
115
+ return self if indent.nil?
116
+
117
+ code.map! { |line| line.sub(/(?mi)^#{indent[1]}/, '') }.join("\n")
98
118
  end
99
119
 
100
120
  def replace_blocks
@@ -129,50 +149,44 @@ module Snibbets
129
149
  [sans_blocks, code_blocks]
130
150
  end
131
151
 
132
- # Returns an array of snippets. Single snippets are returned without a
133
- # title, multiple snippets get titles from header lines
134
- def snippets
135
- content = dup.remove_meta
136
- # If there's only one snippet, just clean it and return
137
- # return [{ 'title' => '', 'code' => content.clean_code.strip }] unless multiple?
138
-
139
- # Split content by ATX headers. Everything on the line after the #
140
- # becomes the title, code is gleaned from text between that and the
141
- # next ATX header (or end)
142
- sections = []
143
-
144
- sans_blocks, code_blocks = content.replace_blocks
145
-
146
- content = []
147
- if sans_blocks =~ /<block\d+>/
148
- sans_blocks.each_line do |line|
149
- content << line if line =~ /^#/ || line =~ /<block\d+>/
150
- end
151
-
152
- parts = content.join("\n").split(/^#+/)
153
- else
154
- parts = sans_blocks.gsub(/\n{2,}/, "\n\n").split(/^#+/)
152
+ def parse_lang_marker(block)
153
+ lang = nil
154
+ if block =~ /<lang:(.*?)>/
155
+ lang = Regexp.last_match(1)
156
+ block = block.gsub(/<lang:.*?>\n+/, '').strip_empty
155
157
  end
156
158
 
157
- # parts.shift if parts.count > 1
159
+ [lang, block]
160
+ end
161
+
162
+ def restore_blocks(parts, code_blocks)
163
+ sections = []
158
164
 
159
165
  parts.each do |part|
160
166
  lines = part.split(/\n/).strip_empty
161
- next if lines.blocks == 0
162
167
 
163
- title = lines.count > 1 && lines[0] !~ /<block\d+>/ ? lines.shift.strip.sub(/[.:]$/, '') : 'Default snippet'
164
- block = lines.join("\n").gsub(/<(block\d+)>/) { code_blocks[Regexp.last_match(1)] }
168
+ notes = part.notes?
169
+
170
+ next if lines.blocks.zero? && !notes
171
+
172
+ title = if lines.count > 1 && lines[0] !~ /<block\d+>/ && lines[0] =~ /^ +/
173
+ lines.shift.strip.sub(/[.:]$/, '')
174
+ else
175
+ 'Default snippet'
176
+ end
165
177
 
166
- lang = nil
167
- if block =~ /<lang:(.*?)>/
168
- lang = Regexp.last_match(1)
169
- block.gsub!(/<lang:.*?>\n/, '')
178
+ block = lines.join("\n").gsub(/<(block\d+)>/) do
179
+ code = code_blocks[Regexp.last_match(1)].strip_empty
180
+ lang, code = parse_lang_marker(code)
181
+ "\n```#{lang}\n#{code.strip}\n```"
170
182
  end
171
183
 
172
- code = block.clean_code
184
+ lang, code = parse_lang_marker(block)
173
185
 
174
186
  next unless code && !code.empty?
175
187
 
188
+ # code = code.clean_code unless notes || code.fences.count > 1
189
+
176
190
  sections << {
177
191
  'title' => title,
178
192
  'code' => code.strip_empty,
@@ -182,5 +196,32 @@ module Snibbets
182
196
 
183
197
  sections
184
198
  end
199
+
200
+ # Returns an array of snippets. Single snippets are returned without a
201
+ # title, multiple snippets get titles from header lines
202
+ def snippets
203
+ content = dup.remove_meta
204
+ # If there's only one snippet, just clean it and return
205
+ # return [{ 'title' => '', 'code' => content.clean_code.strip }] unless multiple?
206
+
207
+ # Split content by ATX headers. Everything on the line after the #
208
+ # becomes the title, code is gleaned from text between that and the
209
+ # next ATX header (or end)
210
+ sans_blocks, code_blocks = content.replace_blocks
211
+
212
+ parts = if Snibbets.options[:all_notes]
213
+ sans_blocks.split(/^#+/)
214
+ elsif sans_blocks =~ /<block\d+>/
215
+ sans_blocks.split(/\n/).each_with_object([]) do |line, arr|
216
+ arr << line if line =~ /^#/ || line =~ /<block\d+>/
217
+ end.join("\n").split(/^#+/)
218
+ else
219
+ sans_blocks.gsub(/\n{2,}/, "\n\n").split(/^#+/)
220
+ end
221
+
222
+ # parts.shift if parts.count > 1
223
+
224
+ restore_blocks(parts, code_blocks)
225
+ end
185
226
  end
186
227
  end