yarsh 0.2.0 → 0.3.1

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: a2abe40eb2be33ef53eba4b99743447c9db93831dd1e8c00221b1934cd5bc264
4
+ data.tar.gz: 3f53d3e1a4a560b7c04982a7874899d8be8aaba714a3cc42dc4ecff6adae99b2
5
5
  SHA512:
6
- metadata.gz: '097af7754f04c9f8ec210fcd844d745b01e5aa681f36619e47ec3ae979ac00f162e0579085acb8c7f5cbb7a6515a38fad5ee973456618bc32b8cd78f732f3f26'
7
- data.tar.gz: 891cd97331a879529d3ad545fba07590c0d3ca1d09e295550d96444bda867bd69f9f10cf494ec0cf61944978813831422e9162826b0415c339434022c44db15e
6
+ metadata.gz: d89b9258f28f13778ad03734cd98db71e09f0b55132631fb2e054ec37286cde8ba688db5dede69835f1ca2ce51fd2cce4a9759707ef5df27cfe8218e6305f6c3
7
+ data.tar.gz: 7ca6c547b1eb3aac0c4c8c1582e42b71007fe2181877aa2d74379c24a175ff48d6307dc978c13a752d4ca054e69a40fc94ee0f4c661e0ca4ed571e82653926de
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/CHANGES.md CHANGED
@@ -200,4 +200,13 @@ See commits: `bcc25b4`, `ea92742`, `51c9db8`, `2218f29`, `0fceeb8`. Tests in
200
200
  transformation, heredoc handling, binding threading, integration with
201
201
  `Yarsh::Shell`).
202
202
 
203
+ ## 0.3.0
203
204
 
205
+ A completion engine for writing shell commands complete in ruby. It comes with a Shell completion engine using bash autocomplete when available.
206
+
207
+ It alos comes with completion for the gem command.
208
+
209
+ ## 0.3.1
210
+
211
+ * Small bug fix for the REPL.
212
+ * Added the export reserved word to define global variables in the shell.
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
@@ -12,6 +14,8 @@ module Yarsh
12
14
  local logout mapfile popd printf pushd pwd read readonly
13
15
  return set shift shopt source suspend test times trap true
14
16
  type typeset ulimit umask unalias unset wait].freeze
17
+ EXPORT = 'export'
18
+ DO_NOT_PRINT_RETURN_VALUE = :do_not_print_return_value80y0327y47123
15
19
 
16
20
  def initialize(bind)
17
21
  @bind = bind
@@ -19,10 +23,14 @@ module Yarsh
19
23
  @history_file = File.expand_path(HISTORY_FILE)
20
24
  @config_file = File.expand_path(CONFIG_FILE)
21
25
  @cf = Yarsh::AnsiColorFormatter.new
26
+ @typing = false
22
27
  Yarsh.current_shell = self
28
+ setup_completion_registry
23
29
  setup_reline_history
24
30
  setup_reline_completion
25
31
  setup_reline_prompt
32
+ setup_output_modifier_proc
33
+ setup_completion_dialog
26
34
  end
27
35
 
28
36
  def shell
@@ -52,26 +60,49 @@ module Yarsh
52
60
  puts @cf.fg_red("Error: #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}")
53
61
  end
54
62
  end
63
+ ensure
64
+ Config.instance.logger.close
55
65
  end
56
66
 
57
67
  private
58
68
 
59
69
  def format_output(object)
60
- pp object
70
+ pp object unless object == DO_NOT_PRINT_RETURN_VALUE
61
71
  end
62
72
  alias fp format_output
63
73
 
64
74
  def read_input
65
- @core.readmultiline(Config.instance.prompt, true) do |buffer|
75
+ @typing = true
76
+ input = @core.readmultiline(Config.instance.prompt, true) do |buffer|
66
77
  !incomplete_line?(buffer) || buffer.match?(/\n[ \t]*\n\z/)
67
78
  end
79
+ @typing = false
80
+ input
68
81
  end
69
82
 
70
83
  # A line is "incomplete" (multiline continues) only when it is not a
71
84
  # shell command: `cd /tmp`, `grep foo /etc/passwd` etc. fail to compile
72
85
  # as Ruby (unterminated regexp) but must stay single-line.
73
86
  def incomplete_line?(line)
74
- Yarsh::Multiline.incomplete?(line) && !shell_command?(line)
87
+ return true if shell_command?(line) && unclosed_quote?(line)
88
+
89
+ Yarsh::Multiline.incomplete?(line) && !shell_command?(line) && !line&.start_with?('cd')
90
+ end
91
+
92
+ def unclosed_quote?(line)
93
+ return false if line.nil?
94
+
95
+ double_quotes = line.scan(/(?<!\\)"/).size
96
+ single_quotes = line.scan(/(?<!\\)'/).size
97
+ double_quotes.odd? || single_quotes.odd?
98
+ end
99
+
100
+ def inside_quote?(pre)
101
+ return false if pre.nil? || pre.empty?
102
+
103
+ double_quotes = pre.scan(/(?<!\\)"/).size
104
+ single_quotes = pre.scan(/(?<!\\)'/).size
105
+ double_quotes.odd? || single_quotes.odd?
75
106
  end
76
107
 
77
108
  def execute_multiline(input)
@@ -89,7 +120,17 @@ module Yarsh
89
120
 
90
121
  if input == 'cd' || input.start_with?('cd ')
91
122
  cd(input.split(' ', 2)[1])
92
- return nil
123
+ return DO_NOT_PRINT_RETURN_VALUE
124
+ end
125
+
126
+ if input.start_with?("#{EXPORT} ")
127
+ assignment = input[EXPORT.length + 1..].strip.split('=', 2)
128
+ if assignment.size == 2
129
+ ENV[assignment[0]] = assignment[1].gsub(/\A['"]|['"]\z/, '') # Remove surrounding quotes if present
130
+ return DO_NOT_PRINT_RETURN_VALUE
131
+ end
132
+ puts @cf.fg_red("Error: Invalid export syntax. Use 'export VAR=value'")
133
+ return DO_NOT_PRINT_RETURN_VALUE
93
134
  end
94
135
 
95
136
  begin
@@ -104,11 +145,13 @@ module Yarsh
104
145
  begin
105
146
  input = Yarsh.expand_aliases(input, Config.instance.aliases)
106
147
  execute(input)
148
+ DO_NOT_PRINT_RETURN_VALUE
107
149
  rescue Exception => e
108
150
  $sher = e
109
151
  info("#{e.class}: #{e.message}")
110
152
  puts "#{@cf.bold.underline.fg_blue('Shell:')} '#{input}': #{e.message}"
111
153
  puts "#{@cf.bold.underline.fg_red('Ruby:')} '#{input}': #{rb_error.message}"
154
+ DO_NOT_PRINT_RETURN_VALUE
112
155
  end
113
156
  end
114
157
  end
@@ -137,6 +180,7 @@ module Yarsh
137
180
  system(cmd, exception: true)
138
181
  end
139
182
 
183
+ # TODO: Use exec_in_path?
140
184
  def shell_command?(input)
141
185
  first_token = input.strip.split(/\s+/, 2).first
142
186
  return true if shell_path?(first_token)
@@ -147,7 +191,39 @@ module Yarsh
147
191
  end
148
192
  end
149
193
 
194
+ def exec_in_path?(input)
195
+ ENV['PATH'].split(File::PATH_SEPARATOR).any? do |dir|
196
+ File.executable?(File.join(dir, input))
197
+ end
198
+ end
199
+
150
200
  def complete_command(word)
201
+ registry = @completion_registry
202
+ return [] if word.nil?
203
+
204
+ if word.empty?
205
+ return ENV['PATH'].split(File::PATH_SEPARATOR)
206
+ .flat_map { |dir| Dir["#{dir}/*"] }
207
+ .map { |f| File.basename(f) }
208
+ .uniq
209
+ .sort
210
+ end
211
+
212
+ cmd = word.strip.split(/\s+/).first
213
+ return [] if cmd.nil? || cmd.empty?
214
+
215
+ # Strip privileged prefix (sudo, doas, etc.) — complete the real command
216
+ if Yarsh::Completion::Engine::PRIVILEGED_COMMANDS.include?(cmd) && cmd != word
217
+ remaining = word.sub(/\A#{Regexp.escape(cmd)}\s+/, '')
218
+ return complete_command(remaining)
219
+ end
220
+
221
+ completions = registry.complete(cmd, word)
222
+ return completions unless completions.empty?
223
+
224
+ builtins = SHELL_BUILTINS.select { |b| b.start_with?(word) }
225
+ return builtins unless builtins.empty?
226
+
151
227
  ENV['PATH'].split(File::PATH_SEPARATOR)
152
228
  .flat_map { |dir| Dir["#{dir}/#{word}*"] }
153
229
  .map { |f| File.basename(f) }
@@ -155,6 +231,62 @@ module Yarsh
155
231
  .sort
156
232
  end
157
233
 
234
+ def setup_completion_registry
235
+ @completion_registry = Yarsh::Completion::Registry.new
236
+ @completion_registry.register(Yarsh::Completion::BashAutocomplete.new)
237
+ @completion_registry.register(Yarsh::Completion::GemCompletion.new)
238
+ @completion_registry.register(Yarsh::Completion::HelpCompletion.new)
239
+ end
240
+
241
+ def complete_ruby(word)
242
+ IRB::InputCompletor.retrieve_completion_data(word, bind: @bind)
243
+ rescue StandardError
244
+ []
245
+ end
246
+
247
+ def setup_output_modifier_proc
248
+ @core.output_modifier_proc = proc do |data|
249
+ if @typing
250
+ IRB::Color.colorize_code(data)
251
+ else
252
+ data
253
+ end
254
+ end
255
+ end
256
+
257
+ def setup_completion_dialog
258
+ Reline.add_dialog_proc(:docs, proc {
259
+ begin
260
+ dialog.trap_key = nil
261
+
262
+ next nil if just_cursor_moving && completion_journey_data.nil?
263
+
264
+ cursor_pos_to_render, result, pointer, autocomplete_dialog = context.pop(4)
265
+ next nil if result.nil? || pointer.nil? || pointer.negative?
266
+
267
+ selection = result[pointer]
268
+ help_command = Help::Command.new(screen_width, cursor_pos_to_render, autocomplete_dialog)
269
+ next nil unless help_command.info(result[pointer])
270
+
271
+ dialog.trap_key = Yarsh::ALT_D_SEQUENCES
272
+
273
+ if key.match?(dialog.name)
274
+ begin
275
+ print "\e[?1049h"
276
+ system(*Shellwords.shellsplit("man #{selection}"))
277
+ ensure
278
+ print "\e[?1049l"
279
+ end
280
+ end
281
+ help_command.render_dialog
282
+ rescue StandardError => e
283
+ extend Yarsh::InstanceMethods
284
+ debug("Error occurend for add help dialog: #{e.message}")
285
+ nil
286
+ end
287
+ }, Reline::DEFAULT_DIALOG_CONTEXT)
288
+ end
289
+
158
290
  def complete_path(word)
159
291
  word = './' if word.empty?
160
292
  glob_word = word.start_with?('~') ? File.expand_path(word) : word
@@ -199,17 +331,30 @@ module Yarsh
199
331
  end
200
332
 
201
333
  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
334
+ Reline.autocompletion = Config.instance.autocompletion
335
+ Reline.completion_append_character = ' '
336
+ Reline.core.config.keyseq_timeout = 0.5
337
+ Reline.completion_proc = proc { |target, pre, _post|
338
+ next [] if inside_quote?(pre) || pre =~ /'|"\z/
339
+
340
+ full = pre.empty? ? target : pre.rstrip + ' ' + target
341
+ first_word = full.strip.split(/\s+/).first.to_s
342
+ ruby_completions = if !Config.instance.ruby_completion ||
343
+ Yarsh::Completion::Engine::PRIVILEGED_COMMANDS.include?(first_word)
344
+ []
345
+ else
346
+ complete_ruby(target).compact
347
+ end
348
+ ruby_completions +
349
+ if target.match?(%r{\A\.\.?(/|\z)}) || target.start_with?('/', '~') || target.include?('/')
350
+ complete_path(target)
351
+ elsif target.empty? && pre.empty?
352
+ complete_command(target)
353
+ elsif pre.end_with?(' ')
354
+ complete_command(full)
355
+ else
356
+ complete_command(full)
357
+ end
213
358
  }
214
359
  end
215
360
 
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.1'
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.1
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