yarsh 0.2.0 → 0.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c0badb7f9f245222eb3ff552fff43848240d14e3e8d8efddae83004a77d72ab
4
- data.tar.gz: d2920f423aac65f6dec454ebf84104fb97c60923358790440463bee0ada9cd8b
3
+ metadata.gz: 149709074192906833f0d530ba07da00c9a6591ea79864fd5d5f2e9c39019ad8
4
+ data.tar.gz: 4c7336c81d2531dd94052c05344d186e97878b94c92e1ad5852955d10df07da3
5
5
  SHA512:
6
- metadata.gz: '097af7754f04c9f8ec210fcd844d745b01e5aa681f36619e47ec3ae979ac00f162e0579085acb8c7f5cbb7a6515a38fad5ee973456618bc32b8cd78f732f3f26'
7
- data.tar.gz: 891cd97331a879529d3ad545fba07590c0d3ca1d09e295550d96444bda867bd69f9f10cf494ec0cf61944978813831422e9162826b0415c339434022c44db15e
6
+ metadata.gz: 030f6d98caa7410911fd8cff2e55e9062dda276c6f45f27d2e29073b53717aa273a0b58ad3b761833c1a955fa0b8ade8501c7c0fcab0af8095eff09a906451ce
7
+ data.tar.gz: 85d5081c0f07e9ddea3e0ea5202abea2d2adbea4b6cedf22f8d9a81c4a34fad0b1a9c037de8da601579b2e5bbbea4f649e9a2661b31c3496bf5f8f91d65f727f
data/AGENTS.md CHANGED
@@ -139,6 +139,19 @@ error crashes the REPL:
139
139
  `yarsh_eval` deciding. `__sh__`/`__sh_exec` still exist for potential
140
140
  re-enabling.
141
141
 
142
+ ## Bash autocomplete
143
+
144
+ `Yarsh::Completion::BashAutocomplete` provides tab-completion by invoking bash
145
+ completion functions:
146
+
147
+ - For commands supporting the `__complete` subcommand (docker, kubectl, etc.),
148
+ it calls `<command> __complete <word>` and parses tab-separated output.
149
+ - For all other commands, it sources the bash completion script and runs the
150
+ registered completion function with `COMP_WORDS`/`COMP_CWORD` set.
151
+ - Privileged commands (`sudo`, `doas`, `pkexec`, `su`) skip the `__complete`
152
+ path to avoid password prompts; they always use the bash completion function.
153
+ - Results are cached in a bounded in-memory `CACHE` (max 100 entries).
154
+
142
155
  ## Style
143
156
 
144
157
  - `# frozen_string_literal: true` on every `.rb` file.
data/README.md CHANGED
@@ -107,7 +107,21 @@ Known limitations:
107
107
 
108
108
  ### Completion
109
109
 
110
- For the moment, there is autocompletion for exectuables in PATH and for file and dir path for shell commands
110
+ Tab completion is powered by a multi-engine system:
111
+
112
+ - **Command completion** — completes executables in PATH and shell builtins
113
+ - **Path completion** — completes file and directory paths (including `~` expansion)
114
+ - **Ruby completion** — completes Ruby methods and constants (can be disabled via `Config.instance.ruby_completion = false`)
115
+ - **Bash completion** — invokes bash completion functions for commands that have them (e.g. `git`, `docker`, `systemctl`), including multi-word commands like `systemctl restart <service>`
116
+ - **Gem completion** — provides subcommand and option completion for `gem`, plus installed gem names for subcommands that operate on installed gems (`info`, `uninstall`, `open`, `contents`, `check`, `cleanup`, `pristine`, `rdoc`, `specification`, `dependency`)
117
+
118
+ Completion is suppressed when the cursor is inside a quoted string, so typing
119
+ `git commit -m ""` won't have options inserted between the quotes.
120
+
121
+ Privileged commands (`sudo`, `doas`, `pkexec`, `su`) are handled transparently:
122
+ `sudo systemctl restart <Tab>` completes the same as `systemctl restart <Tab>`.
123
+
124
+ A base completion engine is provided in order to add more completions with ease. See `lib/yarsh/completion/base.rb` for more information and `lib/yarsh/completion/gem.rb` for an example of a completion for the gem command.
111
125
 
112
126
  ### Errors
113
127
 
data/lib/yarsh/binding.rb CHANGED
@@ -7,4 +7,8 @@ class Binding
7
7
  end
8
8
  self
9
9
  end
10
+
11
+ def inspect
12
+ "#<Binding:>\n local_vars: #{local_variables.inspect}"
13
+ end
10
14
  end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require 'tempfile'
5
+ require_relative 'engine'
6
+
7
+ module Yarsh
8
+ module Completion
9
+ class BashAutocomplete < Engine
10
+ BASH_COMPLETION_MAIN = '/usr/share/bash-completion/bash_completion'
11
+ COMPLETION_DIR = '/usr/share/bash-completion/completions'
12
+ CACHE = {}
13
+
14
+ def supports?(command)
15
+ return false if command.nil? || command.empty?
16
+ command = command.strip.split(/\s+/).first
17
+ File.exist?("#{COMPLETION_DIR}/#{command}") ||
18
+ File.exist?("#{COMPLETION_DIR}/#{command}.bash") ||
19
+ bash_completion_defined?(command)
20
+ end
21
+
22
+ def complete(command, word, args = [])
23
+ command = command.strip.split(/\s+/).first
24
+ return [] unless supports?(command)
25
+ return [] unless find_script(command)
26
+
27
+ # Try __complete subcommand first (works for docker and others)
28
+ sub_output = bash_invoke_complete_subcommand(command, word)
29
+ return sub_output unless sub_output.nil? || sub_output.empty?
30
+
31
+ # Fall back to bash completion function
32
+ func = completion_function(command)
33
+ return [] if func.nil? || func.empty?
34
+
35
+ output = bash_invoke_completion(func, command, word)
36
+ return [] if output.nil? || output.empty?
37
+
38
+ has_trailing_space = word.end_with?(' ')
39
+ prefix = has_trailing_space ? '' : word.strip.split(/\s+/).last
40
+ return output.split if prefix == command
41
+
42
+ output.split.select { |r| r.start_with?(prefix) }
43
+ rescue
44
+ []
45
+ end
46
+
47
+ private
48
+
49
+ def find_script(command)
50
+ return "#{COMPLETION_DIR}/#{command}" if File.exist?("#{COMPLETION_DIR}/#{command}")
51
+ return "#{COMPLETION_DIR}/#{command}.bash" if File.exist?("#{COMPLETION_DIR}/#{command}.bash")
52
+ nil
53
+ end
54
+
55
+ def bash_completion_defined?(command)
56
+ func = completion_function(command)
57
+ !func.nil? && !func.empty?
58
+ rescue
59
+ false
60
+ end
61
+
62
+ def completion_function(command)
63
+ return '' unless File.exist?(BASH_COMPLETION_MAIN)
64
+ script = find_script(command)
65
+ return '' unless script
66
+
67
+ `bash -c 'source #{Shellwords.shellescape(BASH_COMPLETION_MAIN)} 2>/dev/null; source #{Shellwords.shellescape(script)} 2>/dev/null; complete -p #{Shellwords.shellescape(command)} 2>/dev/null | grep -oP "(?<= -F )\\S+"' 2>/dev/null`.strip
68
+ rescue
69
+ ''
70
+ end
71
+
72
+ def bash_invoke_completion(func, command, partial)
73
+ has_trailing_space = partial.end_with?(' ')
74
+ words = if has_trailing_space
75
+ partial.strip.split(/\s+/) << ''
76
+ else
77
+ partial.strip.split(/\s+/)
78
+ end
79
+ cword = [words.size - 1, 0].max
80
+ comp_words = words.map { |w| Shellwords.shellescape(w) }.join(' ')
81
+ comp_words += ' ""' unless has_trailing_space
82
+ script = Shellwords.shellescape(find_script(command))
83
+ cmd = "source #{Shellwords.shellescape(BASH_COMPLETION_MAIN)} 2>/dev/null; source #{script} 2>/dev/null; COMP_WORDS=(#{comp_words}); COMP_CWORD=#{cword}; COMPREPLY=(); #{Shellwords.shellescape(func)} 2>/dev/null; IFS=; printf '%s\\n' \"\${COMPREPLY[@]}\""
84
+ return CACHE[cmd] if CACHE.key?(cmd)
85
+ raw = nil
86
+ Tempfile.create('yarsh_completion') do |f|
87
+ f.write(cmd)
88
+ f.close
89
+ raw = `timeout 4 bash #{f.path} 2>/dev/null`.strip
90
+ end
91
+ CACHE[cmd] = raw
92
+ CACHE.delete_if { |_, v| v.nil? || v.empty? } if CACHE.size > 100
93
+ raw
94
+ rescue
95
+ nil
96
+ end
97
+
98
+ def bash_invoke_complete_subcommand(command, partial)
99
+ has_trailing_space = partial.end_with?(' ')
100
+ words = partial.strip.split(/\s+/)
101
+ return nil if words.size > 2
102
+
103
+ # Don't invoke __complete for privileged commands (prompts for password)
104
+ return nil if PRIVILEGED_COMMANDS.include?(command)
105
+
106
+ # Don't invoke __complete when only the command + space is typed (no arg yet)
107
+ return nil if has_trailing_space && words.size == 1
108
+
109
+ last_word = has_trailing_space ? '' : (words.last || '')
110
+ raw = `timeout 4 #{Shellwords.shellescape(command)} __complete #{Shellwords.shellescape(last_word)} 2>/dev/null`.strip
111
+ return nil if raw.empty?
112
+
113
+ # Filter to only lines that contain tab-separated data
114
+ tab_lines = raw.each_line.select { |line| line.include?("\t") }
115
+ return nil if tab_lines.empty?
116
+
117
+ results = tab_lines.map { |line| line.split("\t").first }.reject(&:empty?)
118
+ return nil if results.empty?
119
+
120
+ # If the only result is the last_word itself, try subcommand completion
121
+ if results.size == 1 && results.first == last_word
122
+ sub_cmd = words[1..-1].join(' ')
123
+ if sub_cmd && !sub_cmd.empty?
124
+ sub_raw = `timeout 4 #{Shellwords.shellescape(command)} #{Shellwords.shellescape(sub_cmd)} __complete 2>/dev/null`.strip
125
+ return nil if sub_raw.empty? || !sub_raw.include?("\t")
126
+ sub_tab_lines = sub_raw.each_line.select { |line| line.include?("\t") }
127
+ return nil if sub_tab_lines.empty?
128
+ sub_results = sub_tab_lines.map { |line| line.split("\t").first }.reject(&:empty?)
129
+ return nil if sub_results.empty?
130
+ results = sub_results
131
+ end
132
+ end
133
+ results
134
+ rescue
135
+ nil
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yarsh
4
+ module Completion
5
+ class Engine
6
+ PRIVILEGED_COMMANDS = %w[sudo doas pkexec su].freeze
7
+
8
+ def complete(command, word, args = [])
9
+ raise NotImplementedError, "Subclasses must implement #complete"
10
+ end
11
+
12
+ def supports?(command)
13
+ false
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'engine'
4
+
5
+ module Yarsh
6
+ module Completion
7
+ class GemCompletion < Engine
8
+ SUBCOMMANDS = %w[
9
+ build cert check cleanup contents dependency environment exec fetch
10
+ generate_index help info install list lock mirror open outdated owner
11
+ pristine push rdoc rebuild search server signin signout sources
12
+ specification stale uninstall unpack update which yank
13
+ ].freeze
14
+
15
+ OPTIONS = {
16
+ 'build' => %w[
17
+ --platform --force --strict -o --output
18
+ -h --help -V --verbose -q --quiet --silent --config-file
19
+ --backtrace --debug --norc
20
+ ].freeze,
21
+ 'cert' => %w[
22
+ -a --add -l --list -r --remove -b --build -C --certificate
23
+ -K --private-key -A --key-algorithm -s --sign -d --days -R --re-sign
24
+ -h --help -V --verbose -q --quiet --silent --config-file
25
+ --backtrace --debug --norc
26
+ ].freeze,
27
+ 'check' => %w[
28
+ -a --alien --doctor --dry-run --gems -v --version
29
+ -h --help -V --verbose -q --quiet --silent --config-file
30
+ --backtrace --debug --norc
31
+ ].freeze,
32
+ 'cleanup' => %w[
33
+ -n -d --dry-run -D --check-development --user-install --dryrun
34
+ -h --help -V --verbose -q --quiet --silent --config-file
35
+ --backtrace --debug --norc
36
+ ].freeze,
37
+ 'contents' => %w[
38
+ -v --version --all -s --spec-dir -l --lib-only --prefix --show-install-dir
39
+ -h --help -V --verbose -q --quiet --silent --config-file
40
+ --backtrace --debug --norc
41
+ ].freeze,
42
+ 'dependency' => %w[
43
+ -v --version --platform --prerelease -R --reverse-dependencies --pipe
44
+ -u --update-sources
45
+ -l --local -r --remote -b --both -B --bulk-threshold --clear-sources
46
+ -s --source -p --http-proxy
47
+ -h --help -V --verbose -q --quiet --silent --config-file
48
+ --backtrace --debug --norc
49
+ ].freeze,
50
+ 'environment' => %w[
51
+ -h --help -V --verbose -q --quiet --silent --config-file
52
+ --backtrace --debug --norc
53
+ ].freeze,
54
+ 'exec' => %w[
55
+ -v --version --prerelease -g --gem --conservative
56
+ -h --help -V --verbose -q --quiet --silent --config-file
57
+ --backtrace --debug --norc
58
+ ].freeze,
59
+ 'fetch' => %w[
60
+ -v --version --platform --prerelease --suggestions
61
+ -B --bulk-threshold -p --http-proxy -s --source --clear-sources
62
+ -h --help -V --verbose -q --quiet --silent --config-file
63
+ --backtrace --debug --norc
64
+ ].freeze,
65
+ 'generate_index' => %w[
66
+ -d --directory --modern --compact --update
67
+ -h --help -V --verbose -q --quiet --silent --config-file
68
+ --backtrace --debug --norc
69
+ ].freeze,
70
+ 'help' => %w[
71
+ -h --help -V --verbose -q --quiet --silent --config-file
72
+ --backtrace --debug --norc
73
+ ].freeze,
74
+ 'info' => %w[
75
+ -i --installed -I -v --version --versions -a --all -e --exact --prerelease
76
+ -u --update-sources
77
+ -l --local -r --remote -b --both -B --bulk-threshold --clear-sources
78
+ -s --source -p --http-proxy
79
+ -h --help -V --verbose -q --quiet --silent --config-file
80
+ --backtrace --debug --norc
81
+ ].freeze,
82
+ 'install' => %w[
83
+ --platform -v --version --prerelease
84
+ --default -u --update-sources
85
+ -i --install-dir -n --bindir -j --build-jobs --document --build-root
86
+ --vendor -N --no-document -E --env-shebang -f --force -w --wrappers
87
+ -P --trust-policy --ignore-dependencies --format-executable --user-install
88
+ --development --development-all --conservative --minimal-deps
89
+ --post-install-message -g --file --without --explain --lock
90
+ --suggestions --target-rbconfig
91
+ -l --local -r --remote -b --both -B --bulk-threshold --clear-sources
92
+ -s --source -p --http-proxy
93
+ -h --help -V --verbose -q --quiet --silent --config-file
94
+ --backtrace --debug --norc
95
+ ].freeze,
96
+ 'list' => %w[
97
+ -i --installed -I -v --version -d --details --versions -a --all -e --exact --prerelease
98
+ -u --update-sources
99
+ -l --local -r --remote -b --both -B --bulk-threshold --clear-sources
100
+ -s --source -p --http-proxy
101
+ -h --help -V --verbose -q --quiet --silent --config-file
102
+ --backtrace --debug --norc
103
+ ].freeze,
104
+ 'lock' => %w[
105
+ -s --strict
106
+ -h --help -V --verbose -q --quiet --silent --config-file
107
+ --backtrace --debug --norc
108
+ ].freeze,
109
+ 'mirror' => %w[
110
+ -h --help -V --verbose -q --quiet --silent --config-file
111
+ --backtrace --debug --norc
112
+ ].freeze,
113
+ 'open' => %w[
114
+ -e --editor -v --version
115
+ -h --help -V --verbose -q --quiet --silent --config-file
116
+ --backtrace --debug --norc
117
+ ].freeze,
118
+ 'outdated' => %w[
119
+ --platform
120
+ -u --update-sources
121
+ -l --local -r --remote -b --both -B --bulk-threshold --clear-sources
122
+ -s --source -p --http-proxy
123
+ -h --help -V --verbose -q --quiet --silent --config-file
124
+ --backtrace --debug --norc
125
+ ].freeze,
126
+ 'owner' => %w[
127
+ -k --key --otp -a --add -r --remove --host
128
+ -p --http-proxy
129
+ -h --help -V --verbose -q --quiet --silent --config-file
130
+ --backtrace --debug --norc
131
+ ].freeze,
132
+ 'pristine' => %w[
133
+ --all --skip --extensions --only-missing-extensions --only-executables
134
+ --only-plugins -E --env-shebang -i --install-dir -n --bindir -v --version
135
+ -h --help -V --verbose -q --quiet --silent --config-file
136
+ --backtrace --debug --norc
137
+ ].freeze,
138
+ 'push' => %w[
139
+ -k --key --otp --host --attestation
140
+ -p --http-proxy
141
+ -h --help -V --verbose -q --quiet --silent --config-file
142
+ --backtrace --debug --norc
143
+ ].freeze,
144
+ 'rdoc' => %w[
145
+ --all --rdoc --ri --overwrite -v --version
146
+ -h --help -V --verbose -q --quiet --silent --config-file
147
+ --backtrace --debug --norc
148
+ ].freeze,
149
+ 'rebuild' => %w[
150
+ --diff --force --strict --source --original --gemspec -C
151
+ -h --help -V --verbose -q --quiet --silent --config-file
152
+ --backtrace --debug --norc
153
+ ].freeze,
154
+ 'search' => %w[
155
+ -i --installed -I -v --version -d --details --versions -a --all -e --exact --prerelease
156
+ -u --update-sources
157
+ -l --local -r --remote -b --both -B --bulk-threshold --clear-sources
158
+ -s --source -p --http-proxy
159
+ -h --help -V --verbose -q --quiet --silent --config-file
160
+ --backtrace --debug --norc
161
+ ].freeze,
162
+ 'server' => %w[
163
+ -h --help -V --verbose -q --quiet --silent --config-file
164
+ --backtrace --debug --norc
165
+ ].freeze,
166
+ 'signin' => %w[
167
+ --host --otp
168
+ -h --help -V --verbose -q --quiet --silent --config-file
169
+ --backtrace --debug --norc
170
+ ].freeze,
171
+ 'signout' => %w[
172
+ -h --help -V --verbose -q --quiet --silent --config-file
173
+ --backtrace --debug --norc
174
+ ].freeze,
175
+ 'sources' => %w[
176
+ -a --add --append --prepend -l --list -r --remove -c --clear-all
177
+ -u --update -f --force
178
+ -p --http-proxy
179
+ -h --help -V --verbose -q --quiet --silent --config-file
180
+ --backtrace --debug --norc
181
+ ].freeze,
182
+ 'specification' => %w[
183
+ -v --version --platform --prerelease --all --ruby --yaml --marshal
184
+ -u --update-sources
185
+ -l --local -r --remote -b --both -B --bulk-threshold --clear-sources
186
+ -s --source -p --http-proxy
187
+ -h --help -V --verbose -q --quiet --silent --config-file
188
+ --backtrace --debug --norc
189
+ ].freeze,
190
+ 'stale' => %w[
191
+ -h --help -V --verbose -q --quiet --silent --config-file
192
+ --backtrace --debug --norc
193
+ ].freeze,
194
+ 'uninstall' => %w[
195
+ -a --all -I --ignore-dependencies -D --check-development -x --executables
196
+ -i --install-dir -n --bindir --user-install --format-executable --force
197
+ --abort-on-dependent -v --version --platform --vendor
198
+ -h --help -V --verbose -q --quiet --silent --config-file
199
+ --backtrace --debug --norc
200
+ ].freeze,
201
+ 'unpack' => %w[
202
+ --target --spec -v --version
203
+ -P --trust-policy
204
+ -h --help -V --verbose -q --quiet --silent --config-file
205
+ --backtrace --debug --norc
206
+ ].freeze,
207
+ 'update' => %w[
208
+ --system --platform --prerelease
209
+ --default -u --update-sources
210
+ -i --install-dir -n --bindir -j --build-jobs --document --build-root
211
+ --vendor -N --no-document -E --env-shebang -f --force -w --wrappers
212
+ -P --trust-policy --ignore-dependencies --format-executable --user-install
213
+ --development --development-all --conservative --minimal-deps
214
+ --post-install-message -g --file --without --explain --lock
215
+ --suggestions --target-rbconfig
216
+ -l --local -r --remote -b --both -B --bulk-threshold --clear-sources
217
+ -s --source -p --http-proxy
218
+ -h --help -V --verbose -q --quiet --silent --config-file
219
+ --backtrace --debug --norc
220
+ ].freeze,
221
+ 'which' => %w[
222
+ -a --all -g --gems-first
223
+ -h --help -V --verbose -q --quiet --silent --config-file
224
+ --backtrace --debug --norc
225
+ ].freeze,
226
+ 'yank' => %w[
227
+ -v --version --platform --otp --host -k --key
228
+ -h --help -V --verbose -q --quiet --silent --config-file
229
+ --backtrace --debug --norc
230
+ ].freeze
231
+ }.freeze
232
+
233
+ GEM_NAME_SUBCOMMANDS = %w[
234
+ info uninstall open contents check cleanup pristine rdoc specification dependency
235
+ ].freeze
236
+
237
+ def supports?(command)
238
+ command.to_s.strip.split(/\s+/).first == 'gem'
239
+ end
240
+
241
+ def complete(command, word, args = [])
242
+ return [] unless supports?(command)
243
+
244
+ words = word.strip.split(/\s+/)
245
+ words.shift if words.first == 'gem'
246
+
247
+ has_trailing_space = word.end_with?(' ')
248
+
249
+ if words.empty?
250
+ return SUBCOMMANDS.dup
251
+ end
252
+
253
+ if words.size == 1 && !has_trailing_space
254
+ prefix = words.first
255
+ return SUBCOMMANDS.select { |s| s.start_with?(prefix) }
256
+ end
257
+
258
+ subcommand = words.first
259
+ return [] unless OPTIONS.key?(subcommand)
260
+
261
+ prefix = has_trailing_space ? '' : words.last
262
+
263
+ if prefix.start_with?('-')
264
+ OPTIONS[subcommand].select { |o| o.start_with?(prefix) }
265
+ elsif GEM_NAME_SUBCOMMANDS.include?(subcommand)
266
+ installed_gems.select { |g| g.start_with?(prefix) }
267
+ else
268
+ OPTIONS[subcommand].select { |o| o.start_with?(prefix) }
269
+ end
270
+ end
271
+
272
+ private
273
+
274
+ def installed_gems
275
+ Gem::Specification.map(&:name).uniq.sort
276
+ rescue
277
+ []
278
+ end
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative 'engine'
5
+
6
+ module Yarsh
7
+ module Completion
8
+ class HelpCompletion < Engine
9
+ attr_reader :cache
10
+
11
+ def initialize
12
+ @cache = {}
13
+ end
14
+
15
+ def complete(command, word, args = [])
16
+ command = command.strip
17
+ return [] if command.nil? || command.empty?
18
+ cmd = command.split(/\s+/).first
19
+ return [] if cmd.nil? || cmd.empty?
20
+ return [] unless command_exists?(cmd)
21
+
22
+ # Skip privileged commands — running --help would prompt for password
23
+ return [] if PRIVILEGED_COMMANDS.include?(cmd)
24
+
25
+ # Use full word for parse_help only if it starts with the command
26
+ # (e.g., word="docker compose u" starts with command="docker")
27
+ help_cmd = if word.strip.start_with?(cmd)
28
+ word.strip
29
+ else
30
+ command
31
+ end
32
+ options = parse_help(help_cmd)
33
+ return [] if options.nil? || options.empty?
34
+
35
+ prefix = word.strip.split(/\s+/).last
36
+ return options if prefix == help_cmd || prefix == cmd
37
+
38
+ options.select { |opt| opt.start_with?(prefix) }
39
+ end
40
+
41
+ def supports?(command)
42
+ return false if command.nil? || command.empty?
43
+ cmd = command.strip.split(/\s+/).first
44
+ command_exists?(cmd)
45
+ end
46
+
47
+ def parse_help(command)
48
+ return @cache[command][:options] if @cache.key?(command) && !expired?(@cache[command])
49
+
50
+ options = do_parse_help(command)
51
+ @cache[command] = { options: options, fetched_at: Time.now.to_i }
52
+ options
53
+ rescue
54
+ []
55
+ end
56
+
57
+ def cache_info(command)
58
+ @cache[command]
59
+ end
60
+
61
+ def clear_cache
62
+ @cache.clear
63
+ end
64
+
65
+ private
66
+
67
+ def command_exists?(command)
68
+ cmd = command.split(/\s+/).first
69
+ !`which #{Shellwords.shellescape(cmd)} 2>/dev/null`.strip.empty?
70
+ rescue
71
+ false
72
+ end
73
+
74
+ def expired?(entry)
75
+ return true unless entry[:fetched_at]
76
+ (Time.now.to_i - entry[:fetched_at]) > CACHE_TTL
77
+ end
78
+
79
+ def do_parse_help(command)
80
+ escaped = command.split(/\s+/).map { |w| Shellwords.shellescape(w) }.join(' ')
81
+ output = `#{escaped} --help 2>&1`.strip
82
+ return [] if output.empty? || output.include?("command not found") || output.include?("No such file")
83
+
84
+ options = []
85
+ output.each_line do |line|
86
+ cmd_match = line.match(/^\s{2}([a-zA-Z][a-zA-Z0-9-]*)/)
87
+ options << cmd_match[1] if cmd_match && !options.include?(cmd_match[1])
88
+
89
+ line.strip!
90
+ next if line.empty?
91
+
92
+ matches = line.scan(/(?:^|[\s|\]\[])(-{1,2}[a-zA-Z][a-zA-Z0-9-]*)/)
93
+ matches.each do |m|
94
+ opt = m.first
95
+ next if opt.empty?
96
+ options << opt unless options.include?(opt)
97
+ end
98
+ end
99
+ options.uniq.sort
100
+ rescue
101
+ []
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'engine'
4
+
5
+ module Yarsh
6
+ module Completion
7
+ class Registry
8
+ def initialize
9
+ @engines = []
10
+ end
11
+
12
+ def register(engine)
13
+ @engines << engine
14
+ end
15
+
16
+ def complete(command, word, args = [])
17
+ return [] if command.nil? || command.empty?
18
+ return [] if word.nil?
19
+
20
+ @engines.each do |engine|
21
+ next unless engine.supports?(command)
22
+ result = engine.complete(command, word, args)
23
+ return result unless result.nil? || result.empty?
24
+ end
25
+
26
+ []
27
+ rescue
28
+ []
29
+ end
30
+
31
+ def find_engine(command)
32
+ @engines.each do |engine|
33
+ return engine if engine.supports?(command)
34
+ end
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Yarsh
6
+ module Completion
7
+ BASE_DIR = '/usr/share/bash-completion/completions'
8
+ CACHE_DIR = File.expand_path('~/.yarsh/completion_cache')
9
+ CACHE_TTL = 3600
10
+
11
+ def self.cache_dir
12
+ FileUtils.mkdir_p(CACHE_DIR) unless File.exist?(CACHE_DIR)
13
+ CACHE_DIR
14
+ end
15
+ end
16
+ end
data/lib/yarsh/config.rb CHANGED
@@ -13,12 +13,14 @@ module Yarsh
13
13
  include Singleton
14
14
 
15
15
  attr_reader :aliases, :log_level
16
- attr_accessor :logger
16
+ attr_accessor :logger, :autocompletion, :ruby_completion
17
17
 
18
18
  def initialize
19
19
  @aliases = {}
20
20
  @prompt = nil
21
21
  @logger = Logger.new($stdout, level: Logger::UNKNOWN)
22
+ @autocompletion = true
23
+ @ruby_completion = true
22
24
  end
23
25
 
24
26
  def log_level=(level)
data/lib/yarsh/help.rb ADDED
@@ -0,0 +1,72 @@
1
+ module Yarsh
2
+ module Help
3
+ class Command
4
+ MINIMAL_WIDTH = 5
5
+
6
+ def initialize(screen_width, cursor_pos_to_render, autocomplete_dialog)
7
+ @x = cursor_pos_to_render.x + autocomplete_dialog.width + 1
8
+ @y = cursor_pos_to_render.y
9
+ @width = screen_width - @x
10
+ @whatis = nil
11
+ end
12
+
13
+ def info(command)
14
+ @info = `whatis #{command} 2>&1`
15
+ return false unless $?.success?
16
+
17
+ @info = @info.split('- ', 2).last.strip
18
+ rescue StandardError => e
19
+ debug("Error during whatis: #{e.message}")
20
+ false
21
+ end
22
+
23
+ def render_dialog
24
+ return nil unless info_and_width?
25
+
26
+ Reline::DialogRenderInfo.new(
27
+ contents: format_output,
28
+ pos: Reline::CursorPos.new(@x, @y - 1),
29
+ width: @width,
30
+ height: height + 1,
31
+ face: :completion_dialog
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ def format_output
38
+ return nil unless info_and_width?
39
+
40
+ dialog_line = []
41
+ line_size = 0
42
+ dialog_content = []
43
+ words = @info.split(' ')
44
+ words.each_with_index do |word, i|
45
+ dialog_line << word
46
+ line_size += word.size + 1
47
+ if i + 1 == words.size
48
+ if line_size >= @width
49
+ dialog_content << dialog_line[0..-2].join(' ').strip << dialog_line.last
50
+ next
51
+ end
52
+ dialog_content << dialog_line.join(' ').strip && next
53
+ end
54
+ next unless line_size >= @width
55
+
56
+ dialog_content << dialog_line[0..-2].join(' ').strip
57
+ line_size = dialog_line.last.size
58
+ dialog_line = [dialog_line.last]
59
+ end
60
+ dialog_content
61
+ end
62
+
63
+ def height
64
+ @height ||= !info_and_width? ? nil : @info.size / @width
65
+ end
66
+
67
+ def info_and_width?
68
+ @info && @width && @width > MINIMAL_WIDTH
69
+ end
70
+ end
71
+ end
72
+ end
data/lib/yarsh/shell.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'shellwords'
4
+
3
5
  module Yarsh
4
6
  class Shell
5
7
  include Yarsh
@@ -19,10 +21,14 @@ module Yarsh
19
21
  @history_file = File.expand_path(HISTORY_FILE)
20
22
  @config_file = File.expand_path(CONFIG_FILE)
21
23
  @cf = Yarsh::AnsiColorFormatter.new
24
+ @typing = false
22
25
  Yarsh.current_shell = self
26
+ setup_completion_registry
23
27
  setup_reline_history
24
28
  setup_reline_completion
25
29
  setup_reline_prompt
30
+ setup_output_modifier_proc
31
+ setup_completion_dialog
26
32
  end
27
33
 
28
34
  def shell
@@ -52,6 +58,8 @@ module Yarsh
52
58
  puts @cf.fg_red("Error: #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}")
53
59
  end
54
60
  end
61
+ ensure
62
+ Config.instance.logger.close
55
63
  end
56
64
 
57
65
  private
@@ -62,16 +70,35 @@ module Yarsh
62
70
  alias fp format_output
63
71
 
64
72
  def read_input
65
- @core.readmultiline(Config.instance.prompt, true) do |buffer|
73
+ @typing = true
74
+ input = @core.readmultiline(Config.instance.prompt, true) do |buffer|
66
75
  !incomplete_line?(buffer) || buffer.match?(/\n[ \t]*\n\z/)
67
76
  end
77
+ @typing = false
78
+ input
68
79
  end
69
80
 
70
81
  # A line is "incomplete" (multiline continues) only when it is not a
71
82
  # shell command: `cd /tmp`, `grep foo /etc/passwd` etc. fail to compile
72
83
  # as Ruby (unterminated regexp) but must stay single-line.
73
84
  def incomplete_line?(line)
74
- Yarsh::Multiline.incomplete?(line) && !shell_command?(line)
85
+ return true if shell_command?(line) && unclosed_quote?(line)
86
+
87
+ Yarsh::Multiline.incomplete?(line) && !shell_command?(line) && !line&.start_with?('cd')
88
+ end
89
+
90
+ def unclosed_quote?(line)
91
+ return false if line.nil?
92
+ double_quotes = line.scan(/(?<!\\)"/).size
93
+ single_quotes = line.scan(/(?<!\\)'/).size
94
+ double_quotes.odd? || single_quotes.odd?
95
+ end
96
+
97
+ def inside_quote?(pre)
98
+ return false if pre.nil? || pre.empty?
99
+ double_quotes = pre.scan(/(?<!\\)"/).size
100
+ single_quotes = pre.scan(/(?<!\\)'/).size
101
+ double_quotes.odd? || single_quotes.odd?
75
102
  end
76
103
 
77
104
  def execute_multiline(input)
@@ -134,9 +161,14 @@ module Yarsh
134
161
  end
135
162
 
136
163
  def execute(cmd)
164
+ return system(cmd, exception: true) if cmd.match?(/[|;&`$(){}\[\]<>]/)
165
+ args = Shellwords.shellsplit(cmd)
166
+ system(*args, exception: true)
167
+ rescue ArgumentError
137
168
  system(cmd, exception: true)
138
169
  end
139
170
 
171
+ # TODO: Use exec_in_path?
140
172
  def shell_command?(input)
141
173
  first_token = input.strip.split(/\s+/, 2).first
142
174
  return true if shell_path?(first_token)
@@ -147,7 +179,39 @@ module Yarsh
147
179
  end
148
180
  end
149
181
 
182
+ def exec_in_path?(input)
183
+ ENV['PATH'].split(File::PATH_SEPARATOR).any? do |dir|
184
+ File.executable?(File.join(dir, input))
185
+ end
186
+ end
187
+
150
188
  def complete_command(word)
189
+ registry = @completion_registry
190
+ return [] if word.nil?
191
+
192
+ if word.empty?
193
+ return ENV['PATH'].split(File::PATH_SEPARATOR)
194
+ .flat_map { |dir| Dir["#{dir}/*"] }
195
+ .map { |f| File.basename(f) }
196
+ .uniq
197
+ .sort
198
+ end
199
+
200
+ cmd = word.strip.split(/\s+/).first
201
+ return [] if cmd.nil? || cmd.empty?
202
+
203
+ # Strip privileged prefix (sudo, doas, etc.) — complete the real command
204
+ if Yarsh::Completion::Engine::PRIVILEGED_COMMANDS.include?(cmd)
205
+ remaining = word.sub(/\A#{Regexp.escape(cmd)}\s+/, '')
206
+ return complete_command(remaining)
207
+ end
208
+
209
+ completions = registry.complete(cmd, word)
210
+ return completions unless completions.empty?
211
+
212
+ builtins = SHELL_BUILTINS.select { |b| b.start_with?(word) }
213
+ return builtins unless builtins.empty?
214
+
151
215
  ENV['PATH'].split(File::PATH_SEPARATOR)
152
216
  .flat_map { |dir| Dir["#{dir}/#{word}*"] }
153
217
  .map { |f| File.basename(f) }
@@ -155,6 +219,62 @@ module Yarsh
155
219
  .sort
156
220
  end
157
221
 
222
+ def setup_completion_registry
223
+ @completion_registry = Yarsh::Completion::Registry.new
224
+ @completion_registry.register(Yarsh::Completion::BashAutocomplete.new)
225
+ @completion_registry.register(Yarsh::Completion::GemCompletion.new)
226
+ @completion_registry.register(Yarsh::Completion::HelpCompletion.new)
227
+ end
228
+
229
+ def complete_ruby(word)
230
+ IRB::InputCompletor.retrieve_completion_data(word, bind: @bind)
231
+ rescue
232
+ []
233
+ end
234
+
235
+ def setup_output_modifier_proc
236
+ @core.output_modifier_proc = proc do |data|
237
+ if @typing
238
+ IRB::Color.colorize_code(data)
239
+ else
240
+ data
241
+ end
242
+ end
243
+ end
244
+
245
+ def setup_completion_dialog
246
+ Reline.add_dialog_proc(:docs, proc {
247
+ begin
248
+ dialog.trap_key = nil
249
+
250
+ next nil if just_cursor_moving && completion_journey_data.nil?
251
+
252
+ cursor_pos_to_render, result, pointer, autocomplete_dialog = context.pop(4)
253
+ next nil if result.nil? || pointer.nil? || pointer.negative?
254
+
255
+ selection = result[pointer]
256
+ help_command = Help::Command.new(screen_width, cursor_pos_to_render, autocomplete_dialog)
257
+ next nil unless help_command.info(result[pointer])
258
+
259
+ dialog.trap_key = Yarsh::ALT_D_SEQUENCES
260
+
261
+ if key.match?(dialog.name)
262
+ begin
263
+ print "\e[?1049h"
264
+ system(*Shellwords.shellsplit("man #{selection}"))
265
+ ensure
266
+ print "\e[?1049l"
267
+ end
268
+ end
269
+ help_command.render_dialog
270
+ rescue StandardError => e
271
+ extend Yarsh::InstanceMethods
272
+ debug("Error occurend for add help dialog: #{e.message}")
273
+ nil
274
+ end
275
+ }, Reline::DEFAULT_DIALOG_CONTEXT)
276
+ end
277
+
158
278
  def complete_path(word)
159
279
  word = './' if word.empty?
160
280
  glob_word = word.start_with?('~') ? File.expand_path(word) : word
@@ -199,17 +319,29 @@ module Yarsh
199
319
  end
200
320
 
201
321
  def setup_reline_completion
202
- Reline.completion_proc = proc { |target, pre|
203
- # puts "target='#{target}' pre='#{pre}'"
204
- if target.match?(%r{\A\.\.?(/|\z)}) || target.start_with?('/', '~') || target.include?('/')
205
- complete_path(target)
206
- elsif target.empty? && pre.empty?
207
- complete_command(target)
208
- elsif pre.end_with?(' ')
209
- complete_path(target)
210
- else
211
- complete_command(target)
212
- end
322
+ Reline.autocompletion = Config.instance.autocompletion
323
+ Reline.completion_append_character = ' '
324
+ Reline.core.config.keyseq_timeout = 0.5
325
+ Reline.completion_proc = proc { |target, pre, post|
326
+ next [] if inside_quote?(pre)
327
+ full = pre.empty? ? target : pre.rstrip + ' ' + target
328
+ first_word = full.strip.split(/\s+/).first.to_s
329
+ ruby_completions = if !Config.instance.ruby_completion ||
330
+ Yarsh::Completion::Engine::PRIVILEGED_COMMANDS.include?(first_word)
331
+ []
332
+ else
333
+ complete_ruby(target).compact
334
+ end
335
+ ruby_completions +
336
+ if target.match?(%r{\A\.\.?(/|\z)}) || target.start_with?('/', '~') || target.include?('/')
337
+ complete_path(target)
338
+ elsif target.empty? && pre.empty?
339
+ complete_command(target)
340
+ elsif pre.end_with?(' ')
341
+ complete_command(full)
342
+ else
343
+ complete_command(full)
344
+ end
213
345
  }
214
346
  end
215
347
 
data/lib/yarsh/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yarsh
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
data/lib/yarsh.rb CHANGED
@@ -4,6 +4,9 @@ require 'reline'
4
4
  require 'open3'
5
5
  require 'singleton'
6
6
  require 'logger'
7
+ require 'irb/completion'
8
+ require_relative 'yarsh/completion/gem_completion'
9
+ require 'irb/color'
7
10
  require 'ripper'
8
11
  require 'prism'
9
12
 
@@ -19,6 +22,13 @@ module Yarsh
19
22
  YARSH_DIR = '~/.yarsh'
20
23
  HISTORY_FILE = File.join Yarsh::YARSH_DIR, 'history'
21
24
  CONFIG_FILE = File.join Yarsh::YARSH_DIR, 'config'
25
+ ALT_D_SEQUENCES = [
26
+ [27, 100], # Normal Alt+d when convert-meta isn't used.
27
+ # When option/alt is not configured as a meta key in terminal emulator,
28
+ # option/alt + d will send a unicode character depend on OS keyboard setting.
29
+ [195, 164], # "ä" in somewhere (FIXME: environment information is unknown).
30
+ [226, 136, 130] # "∂" Alt+d on Mac keyboard.
31
+ ].freeze
22
32
 
23
33
  class ExecOutput < String
24
34
  def initialize(string)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yarsh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - yarsh4all
@@ -45,7 +45,14 @@ files:
45
45
  - lib/yarsh.rb
46
46
  - lib/yarsh/ansi_color_formatter.rb
47
47
  - lib/yarsh/binding.rb
48
+ - lib/yarsh/completion.rb
49
+ - lib/yarsh/completion/bash_autocomplete.rb
50
+ - lib/yarsh/completion/engine.rb
51
+ - lib/yarsh/completion/gem_completion.rb
52
+ - lib/yarsh/completion/help_completion.rb
53
+ - lib/yarsh/completion/registry.rb
48
54
  - lib/yarsh/config.rb
55
+ - lib/yarsh/help.rb
49
56
  - lib/yarsh/instance_methods.rb
50
57
  - lib/yarsh/multiline.rb
51
58
  - lib/yarsh/prompt.rb