snibbets 2.0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -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