snibbets 2.0.7
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/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
|