yarsh 0.1.1 → 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: 5273b4e67dcb97cfa9dd5af63b83cfe7aa1d47cf27803baf27a0e35544346c19
4
- data.tar.gz: 195f587ad4ee4dda6890520fdf2f2be20c7bdb2a6ab92f6d709dc24aaac5a7ad
3
+ metadata.gz: 149709074192906833f0d530ba07da00c9a6591ea79864fd5d5f2e9c39019ad8
4
+ data.tar.gz: 4c7336c81d2531dd94052c05344d186e97878b94c92e1ad5852955d10df07da3
5
5
  SHA512:
6
- metadata.gz: d0b448b5c4fbdec98e05c3519989e5ff74f8b8a7a5372a7d8772196ced90b15a64a5be7b97af6a7b954d9c08d046172e1c60ebb348018552f56fd09ca85aa8ee
7
- data.tar.gz: b177ff3a557c6acdf842e2e29af9dcba09a118926b7d43c7d74507e5877952bef9bdf5faeab6b5f8f210d45ad1e2ce13eb8f01bf5593e08dbbabb464d42aa767
6
+ metadata.gz: 030f6d98caa7410911fd8cff2e55e9062dda276c6f45f27d2e29073b53717aa273a0b58ad3b761833c1a955fa0b8ade8501c7c0fcab0af8095eff09a906451ce
7
+ data.tar.gz: 85d5081c0f07e9ddea3e0ea5202abea2d2adbea4b6cedf22f8d9a81c4a34fad0b1a9c037de8da601579b2e5bbbea4f649e9a2661b31c3496bf5f8f91d65f727f
data/AGENTS.md CHANGED
@@ -15,13 +15,23 @@ A hybrid Ruby/shell interactive REPL. Input is evaluated as Ruby first; on
15
15
 
16
16
  ## Architecture
17
17
 
18
- - **Main logic** lives in `lib/yarsh.rb` (module `Yarsh` with `self.shell`,
19
- `self.console`, `self.execute`, `self.shell_path?`, `self.execute_pipe`, etc.).
20
- `bin/console` calls `Yarsh.console` (IRB session); `exe/yarsh` calls `Yarsh.shell`.
21
- - `exe/yarsh` is the gem executable (packaged via `spec.bindir = "exe"` in the
22
- gemspec). `bin/console` is for development only (adds `bundler/setup`).
23
- - `Yarsh.shell_path?` recognizes absolute/relative/home paths for shell execution.
24
- - `Yarsh.execute_pipe` enables piping shell output into Ruby via the `-->` operator.
18
+ - **Entry point** is `exe/yarsh`: it `extend`s `Yarsh::InstanceMethods` onto the
19
+ top-level object, creates `Yarsh::Shell.new(binding)`, and calls `.shell`
20
+ (the REPL loop). `bin/console` is for development only (adds `bundler/setup`
21
+ and starts an IRB session via `Yarsh.console`).
22
+ - **`lib/yarsh.rb`** defines the module-level helpers: constants (`YARSH_DIR`,
23
+ `HISTORY_FILE`, `CONFIG_FILE`), `ExecOutput`, `self.current_shell`,
24
+ `self.expand_aliases`, `shell_path?` (absolute/relative/home path detection,
25
+ mixed into `Shell`), `self.execute_system_command` (Open3, dead code), and
26
+ `self.console`.
27
+ - **`Yarsh::Shell`** (`lib/yarsh/shell.rb`) holds the REPL loop (`shell`),
28
+ the prompt/history/completion wiring (`setup_reline_*`), the evaluation
29
+ pipeline (`execute_multiline`, `yarsh_eval`, `execute_pipe`, `__sh_exec`),
30
+ `cd`, `shell_command?`, and `execute` (`system(cmd, exception: true)`).
31
+ - Multiline input lives in `lib/yarsh/multiline.rb` (`Yarsh::Multiline`):
32
+ `incomplete?` decides when to keep reading, `transform` rewrites shell lines
33
+ into `__sh__`/`__yarsh__` calls, `classify_line` classifies each line. The
34
+ active shell is exposed as `Yarsh.current_shell` for `__sh__` dispatch.
25
35
 
26
36
  ## `-->` pipe operator
27
37
 
@@ -39,15 +49,72 @@ Examples:
39
49
  | `ls --> lines.count` | (file count) |
40
50
  | `cat data.txt --> _.split("\n").grep(/error/)` | matching lines |
41
51
 
42
- ## REPL evaluation flow
52
+ ## Multiline input
53
+
54
+ Input is read via `Reline.readmultiline`; the REPL keeps reading while the
55
+ buffer is syntactically incomplete (`Yarsh::Multiline.incomplete?` checks the
56
+ `SyntaxError` from `RubyVM::InstructionSequence.compile` for unterminated
57
+ constructs: missing `end`, strings, heredocs, regexps, operators, `case`
58
+ clauses). Known-bad syntax (`@@@`) is not incomplete. Shell-command-shaped
59
+ lines (`cd /tmp`, `grep foo /etc/passwd`) never enter multiline mode even
60
+ though they fail to compile as Ruby (unterminated regexp) — `shell_command?`
61
+ is checked first. An empty line forces submission; continuation lines get a
62
+ ` > ` prompt via `Reline.prompt_proc`.
63
+
64
+ The finished buffer is rewritten by `Yarsh::Multiline.transform` (each line
65
+ classified 1:1 by `classify_line`), then evaluated via `@bind.eval`:
66
+
67
+ - `:structural` — lines whose `Ripper.lex` tokens include a structural
68
+ keyword (`do`, `if`, `def`, `class`, `when`, …) are kept verbatim, but an
69
+ opening structural line gets `bind = binding` appended (nested structures
70
+ get `bind = binding + bind`) so inner lines can reach outer locals
71
+ - `:yarsh` — every other non-blank line (shell lines *and* Ruby expressions
72
+ inside a structural block) is rewritten to
73
+ `__yarsh__("...", bind|nil)`; local variables assigned in an outer
74
+ structural line are wrapped as `__yarsh__("name", bind)` references
75
+ (`IdentifierSurround`)
76
+ - `:skip` — blank lines are dropped; heredoc bodies are never rewritten
77
+ - `:shell` — reserved for `__sh__("...")` calls (currently unemitted, see
78
+ Known issues)
79
+
80
+ `__yarsh__` (`Yarsh::InstanceMethods`, extended onto the top-level binding in
81
+ `exe/yarsh`) temporarily swaps `Shell#bind` for the block binding and calls
82
+ `Shell#yarsh_eval`, which handles `-->` pipes, shell paths, `cd`, and Ruby
83
+ evaluation with the shell fallback (see REPL evaluation flow). Shell lines
84
+ therefore work inside Ruby blocks, defs, and classes; the whole buffer is
85
+ evaluated in the shared REPL binding so methods/variables persist. Examples:
86
+
87
+ | Input | Result |
88
+ |---|---|
89
+ | `Dir['*.rb'].each do \|f|` … `ls -l` … `puts f` … `end` | runs `ls -l` for each file |
90
+ | `def hi` … `puts 'yo'` … `end` then `hi` | `yo` (def persists) |
43
91
 
44
- All steps below are wrapped in a single `begin..rescue` so no error crashes the REPL:
92
+ Known limitations:
93
+
94
+ - Pure-bash multiline (e.g. bash `if ...; then`) is not supported; shell
95
+ lines only work inside Ruby structures.
96
+ - Shell execution inside a block relies on the `NameError` fallback of
97
+ `yarsh_eval`, so a Ruby expression that raises a real `NameError` may be
98
+ shell-executed instead of reported.
99
+
100
+ ## REPL evaluation flow
45
101
 
46
- 1. `input.include?("-->")` → `Yarsh.execute_pipe(input)`, print result
47
- 2. `Yarsh.shell_path?(input)` → execute directly via `bash -c`
48
- 3. `bind.eval(input)` — Ruby evaluation
49
- 4. `NameError` → if first token is `cd`, call `Dir.chdir`; else shell-execute
50
- 5. Other exceptions → print `Error: ExceptionClass: message`
102
+ All steps below are wrapped in a single `begin..rescue` in `Shell#shell` so no
103
+ error crashes the REPL:
104
+
105
+ 1. Input is read via `Reline.readmultiline` (see Multiline input above);
106
+ `read_input` keeps reading while `incomplete_line?` (incomplete and not a
107
+ shell command) or until a blank line ends the buffer
108
+ 2. The whole input goes through `execute_multiline`: `Yarsh::Multiline.transform`
109
+ rewrites non-structural lines to `__yarsh__(...)` calls (structural lines
110
+ kept verbatim), then `@bind.eval` runs the buffer; per-line execution
111
+ happens inside `Shell#yarsh_eval` / `Shell#__sh_exec`
112
+ 3. `yarsh_eval`: `-->` pipe → `execute_pipe`; shell path (`shell_path?`) →
113
+ `execute`; `cd`/`cd <path>` → `Dir.chdir`; otherwise `bind.eval` — on
114
+ `NameError`/`SyntaxError`/`ArgumentError` it falls back to aliases +
115
+ shell execution (unless the input looks like a Ruby error, then it is
116
+ reported)
117
+ 4. Other exceptions → print `Error: ExceptionClass: message`
51
118
 
52
119
  ## Error handling
53
120
 
@@ -60,12 +127,30 @@ All steps below are wrapped in a single `begin..rescue` so no error crashes the
60
127
 
61
128
  ## Known issues
62
129
 
63
- - **Bug:** `Yarsh.execute` (in `lib/yarsh.rb`) uses `system("bash -c 'source
64
- ~/.bashrc; #{cmd}'")` — the command is passed correctly, but this depends on
65
- `~/.bashrc` existing.
66
- - **Dead code:** `Yarsh.execute_system_command` (Open3 version) is defined but
67
- never called. `Yarsh.shell` calls `Yarsh.execute`, not
68
- `Yarsh.execute_system_command`.
130
+ - **Dead code:** `Yarsh.execute_system_command` (Open3 version) in
131
+ `lib/yarsh.rb` is defined but never called; shell execution goes through
132
+ `Shell#execute` (`system(cmd, exception: true)`).
133
+ - **Bug risk:** `setup_reline_history` replays history with
134
+ `@core.readmultiline`-independent heuristics (`incomplete_line?`),
135
+ which may mishandle historical shell-command lines (see Multiline input).
136
+ - **Unemitted `:shell`:** `Multiline.transform` never emits `:shell`/`__sh__`
137
+ (the `classify_line` branches are commented out behind FIXMEs); shell lines
138
+ and Ruby expressions both go through `__yarsh__`, with the shell fallback of
139
+ `yarsh_eval` deciding. `__sh__`/`__sh_exec` still exist for potential
140
+ re-enabling.
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).
69
154
 
70
155
  ## Style
71
156
 
data/CHANGES.md CHANGED
@@ -26,19 +26,19 @@ a shell command, matching the existing `NameError` fallback behavior.
26
26
 
27
27
  ### REPL history persisted to `~/.yarsh/history`
28
28
 
29
- (53032be) Commands are saved to `~/.yarsh/history` across sessions. Max 1000 lines;
29
+ Commands are saved to `~/.yarsh/history` across sessions. Max 1000 lines;
30
30
  concurrent-session safe via `File.flock`.
31
- (77af3b8) Add tests for append_history: creation, append, trim, concurrent safety.
31
+ Add tests for append_history: creation, append, trim, concurrent safety.
32
32
 
33
33
  ### `bin/console` launches an IRB session
34
34
 
35
- (33f86c8) `bin/console` now starts a standard IRB session with the gem loaded
35
+ `bin/console` now starts a standard IRB session with the gem loaded
36
36
  (`Yarsh.console`). The hybrid REPL remains available via `bundle exec ruby exe/yarsh`
37
37
  (`Yarsh.shell`).
38
38
 
39
39
  ### Test suite adapted to multi-file architecture
40
40
 
41
- (13655c4) Tests split per class: `test_yarsh.rb` (module-level: constants,
41
+ Tests split per class: `test_yarsh.rb` (module-level: constants,
42
42
  `ExecOutput`, `console`), `test_shell.rb` (`Yarsh::Shell`: `shell_path?`, `cd`,
43
43
  completion, history, `execute_pipe` incl. bind-var feature), `test_config.rb`
44
44
  (`Yarsh::Config`: prompt, `log_level`), `test_prompt.rb` (`Prompt`,
@@ -47,14 +47,14 @@ validation in `Config#prompt=`.
47
47
 
48
48
  ### Console test works under minitest 6
49
49
 
50
- (e3d1f44) `test_console_launches_irb_session` no longer uses
50
+ `test_console_launches_irb_session` no longer uses
51
51
  `IRB.stub` (`minitest/mock` was removed in minitest 6, breaking plain
52
52
  `rake test`); it now stubs `IRB.start` via `define_singleton_method`.
53
53
  README documents running tests with `bundle exec rake test`.
54
54
 
55
55
  ### `expand_aliases` no longer strips whitespace
56
56
 
57
- (ab3998e) Registering the first alias via `sh_alias`/`add_alias` ran the
57
+ Registering the first alias via `sh_alias`/`add_alias` ran the
58
58
  command through `expand_aliases` with an empty alias hash; the empty
59
59
  alternation degraded the regex to `(^| +)() *` and every space was
60
60
  deleted (`exa -l --icons=always` was stored as `exa-l--icons=always`).
@@ -66,7 +66,7 @@ with a regression test for the empty-alias case.
66
66
 
67
67
  ### `Yarsh::InvalidPromptError` for invalid prompt configuration
68
68
 
69
- (4f0364e) `Config#prompt=` raises the new `Yarsh::InvalidPromptError`
69
+ `Config#prompt=` raises the new `Yarsh::InvalidPromptError`
70
70
  (subclass of `Yarsh::Error`) instead of a bare `StandardError`. The
71
71
  message is a constructor argument with the documented default.
72
72
  `Yarsh::Error` is now defined before sub-files are required, fixing a
@@ -75,7 +75,7 @@ assert the error class, the default message, and custom messages.
75
75
 
76
76
  ### Logging methods in `InstanceMethods`
77
77
 
78
- (d867a11) Besides the existing `debug`, the REPL now exposes `info`,
78
+ Besides the existing `debug`, the REPL now exposes `info`,
79
79
  `warn`, `error`, `fatal`, and `unknown` — each delegating to
80
80
  `Config.instance.logger`, covering all `Logger` severities. Methods are
81
81
  defined dynamically from the `SEVERITIES` constant
@@ -83,7 +83,7 @@ defined dynamically from the `SEVERITIES` constant
83
83
 
84
84
  ### 256-color and text styles in `AnsiColorFormatter`
85
85
 
86
- (3883da3) New `fg_color256`/`bg_color256` emit 8-bit indexed color
86
+ New `fg_color256`/`bg_color256` emit 8-bit indexed color
87
87
  codes (`38;5;N` / `48;5;N`) and raise `Yarsh::Error` for indices outside
88
88
  0..255. Text styles are defined dynamically from `STYLE_CODES`: `bold`,
89
89
  `dim`, `italic`, `underline`, `blink`, `reverse`, `hidden`,
@@ -93,7 +93,7 @@ now tolerates calls with neither text nor a block, so bare style calls
93
93
 
94
94
  ### Real `ArgumentError`s are no longer shell-executed
95
95
 
96
- (13ea85f) Shell commands like `bundle exec rake test` raise
96
+ Shell commands like `bundle exec rake test` raise
97
97
  `ArgumentError` (the innermost call resolves to `Kernel#test`, which
98
98
  requires 2 args). The fallback previously treated every `ArgumentError`
99
99
  as a shell command, so genuine Ruby errors (`"x".sub`) were
@@ -105,10 +105,99 @@ keep their unconditional shell fallback.
105
105
 
106
106
  ### Renamed from `rsh` to `yarsh`
107
107
 
108
- (9009bfb) The project is now called **yarsh** throughout: module `Rsh`
108
+ The project is now called **yarsh** throughout: module `Rsh`
109
109
  → `Yarsh`, gem name `yarsh` (`spec.name`, require paths), data dir
110
110
  `~/.rsh` → `~/.yarsh`, executable `exe/ruby-shell` → `exe/yarsh`.
111
111
  Files renamed: `rsh.gemspec`, `lib/rsh.rb`, `lib/rsh/`,
112
112
  `sig/rsh.rbs`, `test/test_rsh.rb`. All references updated (tests,
113
113
  `bin/console`, README, AGENTS.md, CHANGES.md); `Gemfile.lock`
114
- regenerated. Version stays 0.1.0.
114
+ regenerated.
115
+
116
+ ## 0.1.0
117
+
118
+ First development release!
119
+
120
+ * `eee1b3e` Initial commit for a clean git project
121
+ * `da76411` and `b02a1b8` Updated README for better clarity and fix repository links
122
+
123
+ ## 0.1.1
124
+
125
+ Small correction in the gem description
126
+
127
+ * `5741a00` update the gem description
128
+
129
+ ## 0.1.2
130
+
131
+ Bug fix and small improvement for the prompt type **powerline**
132
+
133
+ See commits:
134
+
135
+ * `d59834b`
136
+ * `d81ffcb`
137
+ * `3e93937`
138
+ * `4b77901`
139
+
140
+ ## 0.2.0
141
+
142
+ ### Multi-line input with shell lines inside Ruby blocks
143
+
144
+ Input is now read via `Reline.readmultiline` — the REPL keeps reading while the
145
+ buffer is syntactically incomplete (`Yarsh::Multiline.incomplete?` checks the
146
+ `SyntaxError` from `RubyVM::InstructionSequence.compile` for unterminated
147
+ constructs: missing `end`, strings, heredocs, regexps, operators, `case`
148
+ clauses). Known-bad syntax (`@@@`) is not treated as incomplete, and
149
+ shell-command-shaped lines (`cd /tmp`, `grep foo /etc/passwd`) never enter
150
+ multiline mode even though they fail to compile as Ruby (unterminated regexp).
151
+ An empty line forces submission; continuation lines get a ` > ` prompt via
152
+ `Reline.prompt_proc`.
153
+
154
+ The finished buffer is rewritten by `Yarsh::Multiline.transform` (each line
155
+ classified 1:1 by `classify_line`), then evaluated via `@bind.eval`:
156
+
157
+ - Lines whose `Ripper.lex` tokens include a structural keyword (`do`, `if`,
158
+ `def`, `class`, `when`, …) are kept verbatim; an opening structural line
159
+ gets `bind = binding` appended (nested structures get `bind = binding +
160
+ bind`) so inner lines can reach outer locals
161
+ - Every other non-blank line — shell lines *and* Ruby expressions inside a
162
+ structural block — is rewritten to `__yarsh__(...)` with the surrounding
163
+ binding; local variables assigned in an outer structural line are wrapped
164
+ as `__yarsh__("name", bind)` references (`IdentifierSurround`)
165
+ - Blank lines are dropped; heredoc bodies are never rewritten
166
+
167
+ `__yarsh__` (`Yarsh::InstanceMethods`, extended onto the top-level binding in
168
+ `exe/yarsh`) temporarily swaps `Yarsh::Shell#bind` for the block binding and
169
+ calls `Shell#yarsh_eval`, which handles `-->` pipes, shell paths, `cd`, and
170
+ Ruby evaluation with the shell fallback. Shell lines therefore work inside
171
+ Ruby blocks, defs, and classes; the whole buffer is evaluated in the shared
172
+ REPL binding so methods and variables persist. Examples:
173
+
174
+ ```ruby
175
+ Dir['*.rb'].each do |f|
176
+ ls -l
177
+ puts f
178
+ end
179
+ # runs `ls -l` for each file
180
+ ```
181
+
182
+ ```ruby
183
+ def hi
184
+ puts 'yo'
185
+ end
186
+ hi
187
+ #=> yo (def persists)
188
+ ```
189
+
190
+ Known limitations:
191
+
192
+ - Pure-bash multiline (e.g. bash `if ...; then`) is not supported; shell
193
+ lines only work inside Ruby structures.
194
+ - Shell execution inside a block relies on the `NameError` fallback of
195
+ `yarsh_eval`, so a Ruby expression that raises a real `NameError` may be
196
+ shell-executed instead of reported.
197
+
198
+ See commits: `bcc25b4`, `ea92742`, `51c9db8`, `2218f29`, `0fceeb8`. Tests in
199
+ `test/test_multiline.rb` (completeness detection, line classification,
200
+ transformation, heredoc handling, binding threading, integration with
201
+ `Yarsh::Shell`).
202
+
203
+
data/README.md CHANGED
@@ -4,7 +4,7 @@ Yet Another Ruby SHell is a mix of Ruby and standard Shell REPL. Priority is giv
4
4
 
5
5
  This project was made just for fun, to remember the basis of Ruby and to discover new features. To provide a good user experience, it uses `reline` under the hood which already implement all we want for a REPL: History, completion, etc.
6
6
 
7
- I wanted a simple version but already working version. If I have time, more features will come. For example, multi-line editing or non interactive with a file as argument. I dream of a shell syntax we can do:
7
+ I wanted a simple version but already working version. If I have time, more features will come. For example, non interactive with a file as argument. I dream of a shell syntax we can do:
8
8
 
9
9
  ```ruby
10
10
  def install_rails
@@ -16,8 +16,15 @@ end
16
16
  install_rails
17
17
  ```
18
18
 
19
+ And this is now a reality: multi-line editing is supported, and shell commands
20
+ work inside Ruby blocks, defs, and classes (see [Multiline](#multiline)).
21
+
19
22
  I developed this project half of the time offline, another part with the help of AI. It was a way for me to try to develop with AI. I used `opencode` with DeepSeek V4 Flash Free.
20
23
 
24
+ ## install
25
+
26
+ Run `gem install yarsh`
27
+
21
28
  ## Usage
22
29
 
23
30
  `yarsh` start the shell.
@@ -55,9 +62,66 @@ ls -al . -->(sorted) lines.sort {|a, b| a.length <=> b.length }
55
62
  sorted.each { |file| puts "What ever you wanna do" }
56
63
  ```
57
64
 
65
+ ### Multiline
66
+
67
+ Multi-line input is supported: the REPL keeps reading while the buffer is
68
+ syntactically incomplete (missing `end`, unterminated string/heredoc/regexp,
69
+ trailing operator, ...) and submits when the syntax is complete or on an empty
70
+ line. Continuation lines get a ` > ` prompt.
71
+
72
+ The best part: you can mix Ruby and shell lines in the same block, def, or
73
+ class:
74
+
75
+ ```ruby
76
+ def install_rails
77
+ sudo apt update
78
+ sudo apt install ruby
79
+ gem install rails
80
+ end
81
+ ```
82
+
83
+ ```ruby
84
+ Dir['*.rb'].each do |f|
85
+ ls -l
86
+ puts "Processed: #{f}"
87
+ end
88
+ ```
89
+
90
+ The whole buffer is evaluated in the shared REPL binding, so methods,
91
+ variables, and shell side-effects persist between lines and across inputs.
92
+ Shell lines are executed via the usual fallback (they are evaluated as Ruby
93
+ first, then as a shell command). Heredoc bodies are kept verbatim:
94
+
95
+ ```ruby
96
+ x = <<~EOF
97
+ not evaluated, just a string
98
+ EOF
99
+ ```
100
+
101
+ Known limitations:
102
+
103
+ - Pure-bash multiline (e.g. bash `if ...; then`) is not supported; shell
104
+ lines only work inside Ruby structures.
105
+ - A Ruby expression that raises a real `NameError` inside a block may be
106
+ executed as a shell command instead of being reported.
107
+
58
108
  ### Completion
59
109
 
60
- 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.
61
125
 
62
126
  ### Errors
63
127
 
@@ -0,0 +1,14 @@
1
+ class Binding
2
+ def +(other)
3
+ return self if other.nil?
4
+
5
+ other.local_variables.each do |var|
6
+ local_variable_set(var, other.local_variable_get(var))
7
+ end
8
+ self
9
+ end
10
+
11
+ def inspect
12
+ "#<Binding:>\n local_vars: #{local_variables.inspect}"
13
+ end
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