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.
@@ -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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Snibbets
4
+ VERSION = '2.0.7'
5
+ 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