snibbets 2.0.7
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +96 -0
- data/LICENSE.txt +20 -0
- data/README.md +173 -0
- data/README.rdoc +6 -0
- data/Rakefile +87 -0
- data/bin/snibbets +427 -0
- data/buildnotes.md +39 -0
- data/lib/snibbets/config.rb +70 -0
- data/lib/snibbets/hash.rb +17 -0
- data/lib/snibbets/highlight.rb +26 -0
- data/lib/snibbets/lexers.rb +34 -0
- data/lib/snibbets/menu.rb +136 -0
- data/lib/snibbets/os.rb +100 -0
- data/lib/snibbets/string.rb +153 -0
- data/lib/snibbets/version.rb +5 -0
- data/lib/snibbets/which.rb +36 -0
- data/lib/snibbets.rb +32 -0
- data/snibbets.gemspec +44 -0
- metadata +242 -0
@@ -0,0 +1,136 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Snibbets
|
4
|
+
# Menu functions
|
5
|
+
module Menu
|
6
|
+
class << self
|
7
|
+
def find_query_in_options(filename, res, query)
|
8
|
+
options = res.map { |m| "#{filename} #{m['title']}" }
|
9
|
+
words = query.split(/ +/)
|
10
|
+
words.delete_if { |word| options.none? { |o| o =~ /#{word}/i } }
|
11
|
+
words.map(&:downcase).join(' ')
|
12
|
+
end
|
13
|
+
|
14
|
+
def remove_items_without_query(filename, res, query)
|
15
|
+
q = find_query_in_options(filename, res, query).split(/ /)
|
16
|
+
res.delete_if do |opt|
|
17
|
+
q.none? do |word|
|
18
|
+
"#{filename} #{opt['title']}" =~ /#{word}/i
|
19
|
+
end
|
20
|
+
end
|
21
|
+
res
|
22
|
+
end
|
23
|
+
|
24
|
+
def gum_menu(executable, res, title, query, filename)
|
25
|
+
unless query.nil? || query.empty?
|
26
|
+
res = remove_items_without_query(filename, res, query)
|
27
|
+
return res[0] if res.count == 1
|
28
|
+
end
|
29
|
+
|
30
|
+
if res.count.zero?
|
31
|
+
warn 'No matches found'
|
32
|
+
Process.exit 1
|
33
|
+
end
|
34
|
+
|
35
|
+
options = res.map { |m| m['title'] }
|
36
|
+
|
37
|
+
puts title
|
38
|
+
args = [
|
39
|
+
"--height=#{options.count}"
|
40
|
+
]
|
41
|
+
selection = `echo #{Shellwords.escape(options.join("\n"))} | #{executable} filter #{args.join(' ')}`.strip
|
42
|
+
Process.exit 1 if selection.empty?
|
43
|
+
|
44
|
+
res.select { |m| m['title'] =~ /#{Regexp.escape(selection)}/ }[0]
|
45
|
+
end
|
46
|
+
|
47
|
+
def fzf_menu(executable, res, title, query, filename)
|
48
|
+
orig_options = res.dup
|
49
|
+
unless query.nil? || query.empty?
|
50
|
+
res = remove_items_without_query(filename, res, query)
|
51
|
+
return res[0] if res.count == 1
|
52
|
+
end
|
53
|
+
|
54
|
+
res = orig_options if res.count.zero?
|
55
|
+
|
56
|
+
options = res.map { |m| "#{filename}: #{m['title']}" }
|
57
|
+
q = query.nil? ? '' : find_query_in_options(filename, res, query)
|
58
|
+
args = [
|
59
|
+
"--height=#{options.count + 2}",
|
60
|
+
%(--prompt="#{title} > "),
|
61
|
+
'-1',
|
62
|
+
%(--header="#{filename}"),
|
63
|
+
# '--header-first',
|
64
|
+
'--reverse',
|
65
|
+
'--no-info',
|
66
|
+
%(--query="#{q}"),
|
67
|
+
'--tac'
|
68
|
+
]
|
69
|
+
selection = `echo #{Shellwords.escape(options.join("\n"))} | #{executable} #{args.join(' ')}`.strip
|
70
|
+
Process.exit 1 if selection.empty?
|
71
|
+
|
72
|
+
result = res.select { |m| m['title'] == selection.sub(/^.*?: /, '') }
|
73
|
+
|
74
|
+
result[0]
|
75
|
+
end
|
76
|
+
|
77
|
+
# Generate a numbered menu, items passed must have a title property
|
78
|
+
def console_menu(res, title, filename, query: nil)
|
79
|
+
unless query.nil? || query.empty?
|
80
|
+
res = remove_items_without_query(filename, res, query)
|
81
|
+
return res[0] if res.count == 1
|
82
|
+
end
|
83
|
+
|
84
|
+
if res.count.zero?
|
85
|
+
warn 'No matches found'
|
86
|
+
Process.exit 1
|
87
|
+
end
|
88
|
+
|
89
|
+
stty_save = `stty -g`.chomp
|
90
|
+
|
91
|
+
trap('INT') do
|
92
|
+
system('stty', stty_save)
|
93
|
+
Process.exit 1
|
94
|
+
end
|
95
|
+
|
96
|
+
# Generate a numbered menu, items passed must have a title property('INT') { system('stty', stty_save); exit }
|
97
|
+
counter = 1
|
98
|
+
$stderr.puts
|
99
|
+
res.each do |m|
|
100
|
+
$stderr.printf("%<counter>2d) %<title>s\n", counter: counter, title: m['title'])
|
101
|
+
counter += 1
|
102
|
+
end
|
103
|
+
$stderr.puts
|
104
|
+
|
105
|
+
begin
|
106
|
+
$stderr.printf(title.sub(/:?$/, ': '), res.length)
|
107
|
+
while (line = Readline.readline('', true))
|
108
|
+
unless line =~ /^[0-9]/
|
109
|
+
system('stty', stty_save) # Restore
|
110
|
+
exit
|
111
|
+
end
|
112
|
+
line = line.to_i
|
113
|
+
return res[line - 1] if line.positive? && line <= res.length
|
114
|
+
|
115
|
+
warn 'Out of range'
|
116
|
+
console_menu(res, title)
|
117
|
+
end
|
118
|
+
rescue Interrupt
|
119
|
+
system('stty', stty_save)
|
120
|
+
exit
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def menu(res, filename: nil, title: 'Select one', query: nil)
|
125
|
+
query&.remove_spotlight_tags!
|
126
|
+
fzf = TTY::Which.which('fzf')
|
127
|
+
return fzf_menu(fzf, res, title, query, filename) unless fzf.empty?
|
128
|
+
|
129
|
+
gum = TTY::Which.which('gum')
|
130
|
+
return gum_menu(gum, res, title, query, filename) unless gum.empty?
|
131
|
+
|
132
|
+
console_menu(res, title, filename, query: query)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
data/lib/snibbets/os.rb
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Snibbets
|
4
|
+
module OS
|
5
|
+
class << self
|
6
|
+
##
|
7
|
+
## Platform-agnostic copy command
|
8
|
+
##
|
9
|
+
## @param text The text to copy
|
10
|
+
##
|
11
|
+
def copy(text)
|
12
|
+
os = RbConfig::CONFIG['target_os']
|
13
|
+
case os
|
14
|
+
when /darwin.*/i
|
15
|
+
`echo #{Shellwords.escape(text)} | pbcopy`
|
16
|
+
else
|
17
|
+
if TTY::Which.exist?('xclip')
|
18
|
+
`echo #{Shellwords.escape(text)} | xclip -sel c`
|
19
|
+
elsif TTY::Which.exist('xsel')
|
20
|
+
`echo #{Shellwords.escape(text)} | xsel -ib`
|
21
|
+
else
|
22
|
+
puts 'Copy not supported on this system, please install xclip or xsel.'
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
##
|
28
|
+
## Platform-agnostic paste command
|
29
|
+
##
|
30
|
+
def paste
|
31
|
+
os = RbConfig::CONFIG['target_os']
|
32
|
+
case os
|
33
|
+
when /darwin.*/i
|
34
|
+
`pbpaste -pboard general -Prefer txt`
|
35
|
+
else
|
36
|
+
if TTY::Which.exist?('xclip')
|
37
|
+
`xclip -o -sel c`
|
38
|
+
elsif TTY::Which.exist('xsel')
|
39
|
+
`xsel -ob`
|
40
|
+
else
|
41
|
+
puts 'Paste not supported on this system, please install xclip or xsel.'
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
##
|
47
|
+
## Platform-agnostic open command
|
48
|
+
##
|
49
|
+
## @param file [String] The file to open
|
50
|
+
##
|
51
|
+
def open(file, app: nil)
|
52
|
+
os = RbConfig::CONFIG['target_os']
|
53
|
+
case os
|
54
|
+
when /darwin.*/i
|
55
|
+
darwin_open(file, app: app)
|
56
|
+
when /mingw|mswin/i
|
57
|
+
win_open(file)
|
58
|
+
else
|
59
|
+
linux_open(file)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
##
|
64
|
+
## macOS open command
|
65
|
+
##
|
66
|
+
## @param file The file
|
67
|
+
## @param app The application
|
68
|
+
##
|
69
|
+
def darwin_open(file, app: nil)
|
70
|
+
if app
|
71
|
+
`open -a "#{app}" #{Shellwords.escape(file)}`
|
72
|
+
else
|
73
|
+
`open #{Shellwords.escape(file)}`
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
##
|
78
|
+
## Windows open command
|
79
|
+
##
|
80
|
+
## @param file The file
|
81
|
+
##
|
82
|
+
def win_open(file)
|
83
|
+
`start #{Shellwords.escape(file)}`
|
84
|
+
end
|
85
|
+
|
86
|
+
##
|
87
|
+
## Linux open command
|
88
|
+
##
|
89
|
+
## @param file The file
|
90
|
+
##
|
91
|
+
def linux_open(file)
|
92
|
+
if TTY::Which.exist?('xdg-open')
|
93
|
+
`xdg-open #{Shellwords.escape(file)}`
|
94
|
+
else
|
95
|
+
notify('{r}Unable to determine executable for `xdg-open`.')
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Snibbets
|
4
|
+
# String helpers
|
5
|
+
class ::String
|
6
|
+
def remove_spotlight_tags
|
7
|
+
words = Shellwords.shellsplit(self)
|
8
|
+
words.delete_if do |word|
|
9
|
+
word =~ /^\w+:/
|
10
|
+
end
|
11
|
+
|
12
|
+
words.join(' ').strip
|
13
|
+
end
|
14
|
+
|
15
|
+
def remove_spotlight_tags!
|
16
|
+
replace remove_spotlight_tags
|
17
|
+
end
|
18
|
+
|
19
|
+
def remove_meta
|
20
|
+
input = dup
|
21
|
+
lines = input.split(/\n/)
|
22
|
+
loop do
|
23
|
+
line = lines.shift
|
24
|
+
next if line =~ /^\s*[A-Z\s]+\w:\s*\S+/i || line =~ /^-{3,}\s*$/
|
25
|
+
|
26
|
+
break
|
27
|
+
end
|
28
|
+
lines.join("\n")
|
29
|
+
end
|
30
|
+
|
31
|
+
# Are there multiple snippets (indicated by ATX headers)
|
32
|
+
def multiple?
|
33
|
+
gsub(/(`{3,}).*?\n\1/m, '').scan(/^#+/).length > 1
|
34
|
+
end
|
35
|
+
|
36
|
+
# Is the snippet in this block fenced?
|
37
|
+
def fenced?
|
38
|
+
count = scan(/^```/).length
|
39
|
+
count > 1 && count.even?
|
40
|
+
end
|
41
|
+
|
42
|
+
def indented?
|
43
|
+
self =~ /^( {4,}|\t+)/
|
44
|
+
end
|
45
|
+
|
46
|
+
def rx
|
47
|
+
".*#{gsub(/\s+/, '.*')}.*"
|
48
|
+
end
|
49
|
+
|
50
|
+
# remove outside comments, fences, and indentation
|
51
|
+
def clean_code
|
52
|
+
block = dup
|
53
|
+
|
54
|
+
# if it's a fenced code block, just discard the fence and everything
|
55
|
+
# outside it
|
56
|
+
if block.fenced?
|
57
|
+
code_blocks = block.scan(/(`{3,})(\w+)?\s*\n(.*?)\n\1/m)
|
58
|
+
code_blocks.map! { |b| b[2].strip }
|
59
|
+
return code_blocks.join("\n\n")
|
60
|
+
end
|
61
|
+
|
62
|
+
# assume it's indented code, discard non-indented lines and outdent
|
63
|
+
# the rest
|
64
|
+
block = block.outdent if block.indented?
|
65
|
+
|
66
|
+
block
|
67
|
+
end
|
68
|
+
|
69
|
+
def outdent
|
70
|
+
lines = split(/\n/)
|
71
|
+
|
72
|
+
incode = false
|
73
|
+
code = []
|
74
|
+
lines.each do |line|
|
75
|
+
next if line =~ /^\s*$/ && !incode
|
76
|
+
|
77
|
+
incode = true
|
78
|
+
code.push(line)
|
79
|
+
end
|
80
|
+
|
81
|
+
return self unless code[0]
|
82
|
+
|
83
|
+
indent = code[0].match(/^( {4,}|\t+)(?=\S)/)
|
84
|
+
|
85
|
+
if indent
|
86
|
+
code.map! { |line| line.sub(/(?mi)^#{indent[1]}/, '') }.join("\n")
|
87
|
+
else
|
88
|
+
self
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Returns an array of snippets. Single snippets are returned without a
|
93
|
+
# title, multiple snippets get titles from header lines
|
94
|
+
def snippets
|
95
|
+
content = dup.remove_meta
|
96
|
+
# If there's only one snippet, just clean it and return
|
97
|
+
# return [{ 'title' => '', 'code' => content.clean_code.strip }] unless multiple?
|
98
|
+
|
99
|
+
# Split content by ATX headers. Everything on the line after the #
|
100
|
+
# becomes the title, code is gleaned from text between that and the
|
101
|
+
# next ATX header (or end)
|
102
|
+
sections = []
|
103
|
+
counter = 0
|
104
|
+
code_blocks = {}
|
105
|
+
|
106
|
+
sans_blocks = content.gsub(/^(`{3,})(\w+)?\s*\n(.*?)\n\1/m) do
|
107
|
+
counter += 1
|
108
|
+
code_blocks["block#{counter}"] = Regexp.last_match(3)
|
109
|
+
"<block#{counter}>\n"
|
110
|
+
end
|
111
|
+
|
112
|
+
sans_blocks = sans_blocks.gsub(/(?mi)^((?:\s{4,}|\t+)\S[\S\s]*?)(?=\n\S|\Z)/) do
|
113
|
+
counter += 1
|
114
|
+
code = Regexp.last_match(1).split(/\n/)
|
115
|
+
|
116
|
+
code_blocks["block#{counter}"] = code.join("\n").outdent
|
117
|
+
|
118
|
+
"<block#{counter}>\n"
|
119
|
+
end
|
120
|
+
|
121
|
+
content = []
|
122
|
+
if sans_blocks =~ /<block\d+>/
|
123
|
+
sans_blocks.each_line do |line|
|
124
|
+
content << line if line =~ /^#/ || line =~ /<block\d+>/
|
125
|
+
end
|
126
|
+
|
127
|
+
parts = content.join("\n").split(/^#+/)
|
128
|
+
else
|
129
|
+
parts = sans_blocks.gsub(/\n{2,}/, "\n\n").split(/^#+/)
|
130
|
+
end
|
131
|
+
|
132
|
+
parts.shift if parts.count > 1
|
133
|
+
|
134
|
+
parts.each do |p|
|
135
|
+
lines = p.split(/\n/)
|
136
|
+
|
137
|
+
title = lines.count > 1 ? lines.shift.strip.sub(/[.:]$/, '') : 'Default snippet'
|
138
|
+
block = lines.join("\n").gsub(/<(block\d+)>/) { code_blocks[Regexp.last_match(1)] }
|
139
|
+
|
140
|
+
code = block.clean_code
|
141
|
+
|
142
|
+
next unless code && !code.empty?
|
143
|
+
|
144
|
+
sections << {
|
145
|
+
'title' => title,
|
146
|
+
'code' => code
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
sections
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TTY
|
4
|
+
# Additional app-bundle-specific routines for TTY::Which
|
5
|
+
module Which
|
6
|
+
def app_bundle(cmd)
|
7
|
+
app = cmd.sub(/(\.app)?$/, '.app')
|
8
|
+
command = cmd.dup
|
9
|
+
command.sub!(/\.app$/, '')
|
10
|
+
app_dirs = %w[/Applications /Applications/Setapp ~/Applications]
|
11
|
+
return command if ::File.exist?(app)
|
12
|
+
|
13
|
+
return command if app_dirs.any? { |dir| ::File.exist?(::File.join(dir, app)) }
|
14
|
+
|
15
|
+
false
|
16
|
+
end
|
17
|
+
module_function :app_bundle
|
18
|
+
|
19
|
+
def bundle_id?(cmd)
|
20
|
+
cmd =~ /^\w+(\.\w+){2,}/
|
21
|
+
end
|
22
|
+
module_function :bundle_id?
|
23
|
+
|
24
|
+
def app?(cmd)
|
25
|
+
if file_with_path?(cmd)
|
26
|
+
return cmd if app_bundle(cmd)
|
27
|
+
else
|
28
|
+
app = app_bundle(cmd)
|
29
|
+
return app if app
|
30
|
+
end
|
31
|
+
|
32
|
+
false
|
33
|
+
end
|
34
|
+
module_function :app?
|
35
|
+
end
|
36
|
+
end
|
data/lib/snibbets.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'optparse'
|
4
|
+
require 'readline'
|
5
|
+
require 'json'
|
6
|
+
require 'cgi'
|
7
|
+
require 'shellwords'
|
8
|
+
require 'yaml'
|
9
|
+
require 'fileutils'
|
10
|
+
require 'tty-which'
|
11
|
+
require_relative 'snibbets/version'
|
12
|
+
require_relative 'snibbets/config'
|
13
|
+
require_relative 'snibbets/which'
|
14
|
+
require_relative 'snibbets/string'
|
15
|
+
require_relative 'snibbets/hash'
|
16
|
+
require_relative 'snibbets/menu'
|
17
|
+
require_relative 'snibbets/os'
|
18
|
+
require_relative 'snibbets/highlight'
|
19
|
+
require_relative 'snibbets/lexers'
|
20
|
+
|
21
|
+
# Top level module
|
22
|
+
module Snibbets
|
23
|
+
class << self
|
24
|
+
def config
|
25
|
+
@config ||= Config.new
|
26
|
+
end
|
27
|
+
|
28
|
+
def options
|
29
|
+
@options = config.options
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/snibbets.gemspec
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/snibbets/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "snibbets"
|
7
|
+
spec.version = Snibbets::VERSION
|
8
|
+
spec.author = "Brett Terpstra"
|
9
|
+
spec.email = "me@brettterpstra.com"
|
10
|
+
|
11
|
+
spec.summary = "Snibbets"
|
12
|
+
spec.description = "A plain text code snippet manager"
|
13
|
+
spec.homepage = "https://github.com/ttscoff/snibbets"
|
14
|
+
spec.license = "MIT"
|
15
|
+
spec.required_ruby_version = ">= 3.0.0"
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
19
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.metadata["source_code_uri"]}/issues"
|
20
|
+
spec.metadata["changelog_uri"] = "#{spec.metadata["source_code_uri"]}/blob/main/CHANGELOG.md"
|
21
|
+
spec.metadata["github_repo"] = "git@github.com:ttscoff/snibbets.git"
|
22
|
+
|
23
|
+
spec.require_paths << 'lib'
|
24
|
+
spec.extra_rdoc_files = ['README.md']
|
25
|
+
spec.rdoc_options << '--title' << 'na' << '--main' << 'README.md' << '--markup' << 'markdown'
|
26
|
+
|
27
|
+
spec.bindir = "bin"
|
28
|
+
spec.executables << 'snibbets'
|
29
|
+
|
30
|
+
spec.files = Dir["lib/**/*.rb"].reject { |f| f.end_with?("_spec.rb") }
|
31
|
+
spec.files += Dir["[A-Z]*"]
|
32
|
+
|
33
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
34
|
+
spec.add_development_dependency "gem-release", "~> 2.2"
|
35
|
+
spec.add_development_dependency "parse_gemspec-cli", "~> 1.0"
|
36
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
37
|
+
spec.add_development_dependency 'rdoc', '~> 4.3'
|
38
|
+
spec.add_development_dependency 'yard', '~> 0.9', '>= 0.9.26'
|
39
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
40
|
+
spec.add_development_dependency "simplecov", "~> 0.21"
|
41
|
+
spec.add_development_dependency "simplecov-console", "~> 0.9"
|
42
|
+
spec.add_development_dependency "standard", "~> 1.3"
|
43
|
+
spec.add_runtime_dependency 'tty-which', '~> 0.5', '>= 0.5.0'
|
44
|
+
end
|