rush-shell 0.0.2

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.
Files changed (210) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +94 -0
  4. data/exe/rush +10 -0
  5. data/grammar/shell.y +272 -0
  6. data/grammar/shell.y.output +2354 -0
  7. data/lib/rush/alias_table.rb +48 -0
  8. data/lib/rush/assignments.rb +48 -0
  9. data/lib/rush/ast/and_or.rb +37 -0
  10. data/lib/rush/ast/assignment.rb +20 -0
  11. data/lib/rush/ast/brace_group.rb +25 -0
  12. data/lib/rush/ast/case_node.rb +41 -0
  13. data/lib/rush/ast/condition_loop.rb +34 -0
  14. data/lib/rush/ast/for_node.rb +34 -0
  15. data/lib/rush/ast/function_def.rb +26 -0
  16. data/lib/rush/ast/if_node.rb +29 -0
  17. data/lib/rush/ast/list.rb +47 -0
  18. data/lib/rush/ast/node.rb +17 -0
  19. data/lib/rush/ast/param_ref.rb +59 -0
  20. data/lib/rush/ast/pipeline.rb +45 -0
  21. data/lib/rush/ast/redirect.rb +21 -0
  22. data/lib/rush/ast/redirected.rb +26 -0
  23. data/lib/rush/ast/simple_command.rb +62 -0
  24. data/lib/rush/ast/subshell.rb +24 -0
  25. data/lib/rush/ast/until_node.rb +18 -0
  26. data/lib/rush/ast/while_node.rb +18 -0
  27. data/lib/rush/ast/word.rb +44 -0
  28. data/lib/rush/ast/word_segment.rb +203 -0
  29. data/lib/rush/background_runner.rb +68 -0
  30. data/lib/rush/bracket_expression.rb +107 -0
  31. data/lib/rush/bracket_scanner.rb +64 -0
  32. data/lib/rush/builtins/alias_.rb +74 -0
  33. data/lib/rush/builtins/base.rb +96 -0
  34. data/lib/rush/builtins/break_.rb +19 -0
  35. data/lib/rush/builtins/cd.rb +127 -0
  36. data/lib/rush/builtins/colon.rb +16 -0
  37. data/lib/rush/builtins/command.rb +137 -0
  38. data/lib/rush/builtins/command_options.rb +103 -0
  39. data/lib/rush/builtins/continue_.rb +19 -0
  40. data/lib/rush/builtins/declare.rb +27 -0
  41. data/lib/rush/builtins/defaults.rb +28 -0
  42. data/lib/rush/builtins/dot.rb +52 -0
  43. data/lib/rush/builtins/echo.rb +37 -0
  44. data/lib/rush/builtins/eval.rb +25 -0
  45. data/lib/rush/builtins/exec.rb +54 -0
  46. data/lib/rush/builtins/exit.rb +45 -0
  47. data/lib/rush/builtins/export.rb +20 -0
  48. data/lib/rush/builtins/false_.rb +16 -0
  49. data/lib/rush/builtins/fd_operand.rb +87 -0
  50. data/lib/rush/builtins/getopts.rb +96 -0
  51. data/lib/rush/builtins/hash.rb +64 -0
  52. data/lib/rush/builtins/job_resume.rb +107 -0
  53. data/lib/rush/builtins/jobs.rb +86 -0
  54. data/lib/rush/builtins/kill.rb +152 -0
  55. data/lib/rush/builtins/local.rb +40 -0
  56. data/lib/rush/builtins/loop_jump.rb +45 -0
  57. data/lib/rush/builtins/printf.rb +39 -0
  58. data/lib/rush/builtins/printf_formatter.rb +111 -0
  59. data/lib/rush/builtins/pwd.rb +17 -0
  60. data/lib/rush/builtins/read.rb +93 -0
  61. data/lib/rush/builtins/read_input.rb +146 -0
  62. data/lib/rush/builtins/readonly.rb +20 -0
  63. data/lib/rush/builtins/registry.rb +36 -0
  64. data/lib/rush/builtins/return_.rb +24 -0
  65. data/lib/rush/builtins/set.rb +101 -0
  66. data/lib/rush/builtins/shift.rb +35 -0
  67. data/lib/rush/builtins/test_.rb +48 -0
  68. data/lib/rush/builtins/test_context.rb +24 -0
  69. data/lib/rush/builtins/test_expr.rb +74 -0
  70. data/lib/rush/builtins/test_grammar.rb +117 -0
  71. data/lib/rush/builtins/test_operators.rb +94 -0
  72. data/lib/rush/builtins/test_tokens.rb +69 -0
  73. data/lib/rush/builtins/times.rb +35 -0
  74. data/lib/rush/builtins/trap.rb +85 -0
  75. data/lib/rush/builtins/true_.rb +16 -0
  76. data/lib/rush/builtins/type_.rb +28 -0
  77. data/lib/rush/builtins/ulimit.rb +258 -0
  78. data/lib/rush/builtins/umask.rb +81 -0
  79. data/lib/rush/builtins/unalias.rb +46 -0
  80. data/lib/rush/builtins/unset.rb +40 -0
  81. data/lib/rush/builtins/wait.rb +127 -0
  82. data/lib/rush/cli.rb +31 -0
  83. data/lib/rush/command_assignments.rb +56 -0
  84. data/lib/rush/command_lookup.rb +228 -0
  85. data/lib/rush/command_resolution.rb +115 -0
  86. data/lib/rush/command_runner.rb +152 -0
  87. data/lib/rush/command_text.rb +140 -0
  88. data/lib/rush/environment.rb +134 -0
  89. data/lib/rush/errexit_context.rb +68 -0
  90. data/lib/rush/error_policy.rb +64 -0
  91. data/lib/rush/errors.rb +100 -0
  92. data/lib/rush/escape_table.rb +22 -0
  93. data/lib/rush/escaped_bracket.rb +49 -0
  94. data/lib/rush/executor.rb +164 -0
  95. data/lib/rush/exit_trap.rb +109 -0
  96. data/lib/rush/expansion/arithmetic/evaluator.rb +43 -0
  97. data/lib/rush/expansion/arithmetic/nodes.rb +180 -0
  98. data/lib/rush/expansion/arithmetic/number.rb +104 -0
  99. data/lib/rush/expansion/arithmetic/parser.rb +155 -0
  100. data/lib/rush/expansion/arithmetic/tokenizer.rb +56 -0
  101. data/lib/rush/expansion/arithmetic_expander.rb +38 -0
  102. data/lib/rush/expansion/command_substitution.rb +87 -0
  103. data/lib/rush/expansion/field_part.rb +12 -0
  104. data/lib/rush/expansion/field_splitter.rb +23 -0
  105. data/lib/rush/expansion/glob_expander.rb +48 -0
  106. data/lib/rush/expansion/ifs.rb +62 -0
  107. data/lib/rush/expansion/ifs_scanner.rb +168 -0
  108. data/lib/rush/expansion/parameter_expander.rb +204 -0
  109. data/lib/rush/expansion/parameter_forms.rb +22 -0
  110. data/lib/rush/expansion/pattern_removal.rb +63 -0
  111. data/lib/rush/expansion/pipeline.rb +124 -0
  112. data/lib/rush/expansion/read_char.rb +13 -0
  113. data/lib/rush/expansion/read_field_scanner.rb +159 -0
  114. data/lib/rush/expansion/read_splitter.rb +40 -0
  115. data/lib/rush/expansion/resolver.rb +23 -0
  116. data/lib/rush/expansion/tilde_expander.rb +102 -0
  117. data/lib/rush/external.rb +75 -0
  118. data/lib/rush/fd_entry.rb +60 -0
  119. data/lib/rush/for_runner.rb +50 -0
  120. data/lib/rush/function_frame.rb +30 -0
  121. data/lib/rush/function_runner.rb +37 -0
  122. data/lib/rush/function_table.rb +35 -0
  123. data/lib/rush/getopts_parser.rb +195 -0
  124. data/lib/rush/getopts_state.rb +104 -0
  125. data/lib/rush/here_doc.rb +57 -0
  126. data/lib/rush/interactive_signals.rb +26 -0
  127. data/lib/rush/invocation.rb +190 -0
  128. data/lib/rush/io_table.rb +94 -0
  129. data/lib/rush/job_control.rb +197 -0
  130. data/lib/rush/job_report.rb +45 -0
  131. data/lib/rush/job_spec.rb +51 -0
  132. data/lib/rush/job_table/control.rb +103 -0
  133. data/lib/rush/job_table/interruptible_wait.rb +70 -0
  134. data/lib/rush/job_table/job.rb +181 -0
  135. data/lib/rush/job_table.rb +194 -0
  136. data/lib/rush/lexer/alias_expander.rb +129 -0
  137. data/lib/rush/lexer/braced_reader.rb +86 -0
  138. data/lib/rush/lexer/case_frame.rb +114 -0
  139. data/lib/rush/lexer/case_tracker.rb +146 -0
  140. data/lib/rush/lexer/dollar_scanner.rb +53 -0
  141. data/lib/rush/lexer/double_quote_scanner.rb +71 -0
  142. data/lib/rush/lexer/heredoc_body.rb +91 -0
  143. data/lib/rush/lexer/heredoc_reader.rb +93 -0
  144. data/lib/rush/lexer/lex_state.rb +128 -0
  145. data/lib/rush/lexer/operator_table.rb +32 -0
  146. data/lib/rush/lexer/param_scanner.rb +43 -0
  147. data/lib/rush/lexer/paren_reader.rb +113 -0
  148. data/lib/rush/lexer/paren_regions.rb +74 -0
  149. data/lib/rush/lexer/quote_skips.rb +77 -0
  150. data/lib/rush/lexer/quoted_word.rb +67 -0
  151. data/lib/rush/lexer/scanner_predicates.rb +20 -0
  152. data/lib/rush/lexer/source_lines.rb +43 -0
  153. data/lib/rush/lexer/substitution_reader.rb +77 -0
  154. data/lib/rush/lexer/token_classifier.rb +144 -0
  155. data/lib/rush/lexer/token_predicates.rb +23 -0
  156. data/lib/rush/lexer/word_scanner.rb +134 -0
  157. data/lib/rush/lexer.rb +174 -0
  158. data/lib/rush/loop_control_handling.rb +29 -0
  159. data/lib/rush/loop_nesting.rb +50 -0
  160. data/lib/rush/loop_runner.rb +55 -0
  161. data/lib/rush/option_cluster.rb +39 -0
  162. data/lib/rush/options.rb +76 -0
  163. data/lib/rush/param_text.rb +39 -0
  164. data/lib/rush/parser.rb +1213 -0
  165. data/lib/rush/parser_support.rb +112 -0
  166. data/lib/rush/pattern_scanner.rb +35 -0
  167. data/lib/rush/pending_signals.rb +57 -0
  168. data/lib/rush/pipeline_runner.rb +173 -0
  169. data/lib/rush/pipeline_statuses.rb +45 -0
  170. data/lib/rush/positional.rb +45 -0
  171. data/lib/rush/posix_pattern.rb +78 -0
  172. data/lib/rush/program_input.rb +89 -0
  173. data/lib/rush/program_reader.rb +69 -0
  174. data/lib/rush/program_session.rb +83 -0
  175. data/lib/rush/prompt.rb +52 -0
  176. data/lib/rush/redirect_scope.rb +64 -0
  177. data/lib/rush/redirection/dup_redirect.rb +58 -0
  178. data/lib/rush/redirection/file_redirect.rb +50 -0
  179. data/lib/rush/redirection/here_doc_redirect.rb +17 -0
  180. data/lib/rush/redirection/registry.rb +53 -0
  181. data/lib/rush/repl.rb +133 -0
  182. data/lib/rush/runtime_type_checks.rb +16 -0
  183. data/lib/rush/scope.rb +79 -0
  184. data/lib/rush/segment_buffer.rb +47 -0
  185. data/lib/rush/shell_parameters.rb +77 -0
  186. data/lib/rush/shell_pattern.rb +80 -0
  187. data/lib/rush/shell_state.rb +163 -0
  188. data/lib/rush/shell_variables.rb +67 -0
  189. data/lib/rush/signal_report.rb +41 -0
  190. data/lib/rush/signals.rb +68 -0
  191. data/lib/rush/source.rb +82 -0
  192. data/lib/rush/source_line_counter.rb +41 -0
  193. data/lib/rush/source_runner.rb +47 -0
  194. data/lib/rush/startup.rb +75 -0
  195. data/lib/rush/status.rb +93 -0
  196. data/lib/rush/stop_relay.rb +32 -0
  197. data/lib/rush/subshell_runner.rb +70 -0
  198. data/lib/rush/system_calls/collation.rb +155 -0
  199. data/lib/rush/system_calls/file_tests.rb +95 -0
  200. data/lib/rush/system_calls/process_control.rb +165 -0
  201. data/lib/rush/system_calls/process_identity.rb +43 -0
  202. data/lib/rush/system_calls/resource_limits.rb +51 -0
  203. data/lib/rush/system_calls.rb +206 -0
  204. data/lib/rush/terminal.rb +126 -0
  205. data/lib/rush/trap_runner.rb +169 -0
  206. data/lib/rush/trap_table.rb +49 -0
  207. data/lib/rush/umask_mode.rb +133 -0
  208. data/lib/rush/version.rb +6 -0
  209. data/lib/rush.rb +190 -0
  210. metadata +527 -0
@@ -0,0 +1,43 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class SystemCalls
6
+ # Who the shell process is, mixed into SystemCalls: pids for $$/PPID,
7
+ # effective privileges for the PS1 default, and the invocation name for
8
+ # login-shell detection.
9
+ module ProcessIdentity
10
+ extend T::Sig
11
+
12
+ sig { returns(Integer) }
13
+ def pid
14
+ Process.pid
15
+ end
16
+
17
+ sig { returns(Integer) }
18
+ def ppid
19
+ Process.ppid
20
+ end
21
+
22
+ # The shell's own process group: compared against the terminal's
23
+ # foreground group when job control acquires the tty (rush-mv8.3).
24
+ sig { returns(Integer) }
25
+ def pgrp
26
+ Process.getpgrp
27
+ end
28
+
29
+ # Whether the shell runs with root privileges: picks the default PS1
30
+ # ('# ' instead of '$ '), as POSIX permits for privileged users.
31
+ sig { returns(T::Boolean) }
32
+ def privileged?
33
+ Process.euid.zero?
34
+ end
35
+
36
+ # How the process was invoked ($0): a leading '-' marks a login shell.
37
+ sig { returns(String) }
38
+ def program_name
39
+ $PROGRAM_NAME
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,51 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class SystemCalls
6
+ # Resource-limit wrappers for the `ulimit` builtin. The mapping lives outside
7
+ # SystemCalls proper so the impure syscall port stays small as it grows.
8
+ module ResourceLimits
9
+ RLIMITS = {
10
+ cpu: Process::RLIMIT_CPU,
11
+ fsize: Process::RLIMIT_FSIZE,
12
+ data: Process::RLIMIT_DATA,
13
+ stack: Process::RLIMIT_STACK,
14
+ core: Process::RLIMIT_CORE,
15
+ rss: Process::RLIMIT_RSS,
16
+ memlock: Process::RLIMIT_MEMLOCK,
17
+ nproc: Process::RLIMIT_NPROC,
18
+ nofile: Process::RLIMIT_NOFILE,
19
+ as: Process::RLIMIT_AS,
20
+ locks: (Process.const_get(:RLIMIT_LOCKS) if Process.const_defined?(:RLIMIT_LOCKS)),
21
+ rtprio: Process::RLIMIT_RTPRIO
22
+ }.freeze
23
+
24
+ def current_umask
25
+ File.umask
26
+ end
27
+
28
+ def change_umask(mask)
29
+ File.umask(mask)
30
+ end
31
+
32
+ def infinity_limit
33
+ Process::RLIM_INFINITY
34
+ end
35
+
36
+ def getrlimit(resource)
37
+ limit = RLIMITS.fetch(resource)
38
+ return [infinity_limit, infinity_limit] unless limit
39
+
40
+ Process.getrlimit(limit)
41
+ end
42
+
43
+ def setrlimit(resource, soft, hard)
44
+ limit = RLIMITS.fetch(resource)
45
+ raise Errno::EINVAL unless limit
46
+
47
+ Process.setrlimit(limit, soft, hard)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,206 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'etc'
5
+ require 'reline'
6
+ require 'tempfile'
7
+ require_relative 'system_calls/file_tests'
8
+ require_relative 'system_calls/collation'
9
+ require_relative 'system_calls/process_identity'
10
+ require_relative 'system_calls/process_control'
11
+ require_relative 'system_calls/resource_limits'
12
+
13
+ module Rush
14
+ # The sole impure class: every syscall rush makes is a thin wrapper here, so
15
+ # specs inject a fake (spec/support/fake_system_calls.rb) and reach every
16
+ # error branch without touching the real OS. Grows one wrapper per slice.
17
+ class SystemCalls
18
+ include FileTests
19
+ include ProcessIdentity
20
+ include ProcessControl
21
+ include ResourceLimits
22
+
23
+ COLLATION = Collation.new
24
+
25
+ # Run `file` as an external program with argv.first as the child's argv[0]
26
+ # — for the ordinary $PATH search the caller passes the bare command name
27
+ # as `file`; `command -p` passes its default-PATH resolution instead,
28
+ # without renaming the command (dash-probed via `$0`). The [cmd, argv0]
29
+ # form forbids the shell path even for a single-word command, so `spawn`
30
+ # never re-interprets words.
31
+ def spawn(file, env, argv, options)
32
+ Process.spawn(env, [file, argv.first], *argv.drop(1), options)
33
+ end
34
+
35
+ # The system default PATH (confstr _CS_PATH): the search path `command -p`
36
+ # uses, guaranteed to find the standard utilities regardless of $PATH.
37
+ # POSIX defines _CS_PATH on every system; the coalesce only satisfies
38
+ # confstr's nilable signature.
39
+ def default_path
40
+ Etc.confstr(Etc::CS_PATH) || ''
41
+ end
42
+
43
+ # Replace the current process image (the `exec` builtin); the [cmd, argv0]
44
+ # form forbids the shell path, like #spawn. Returns only if the exec fails.
45
+ def exec(env, argv, options)
46
+ Process.exec(env, [argv.first, argv.first], *argv.drop(1), options)
47
+ end
48
+
49
+ # Accumulated CPU times for the `times` builtin: a Process::Tms with utime /
50
+ # stime for the shell and cutime / cstime for its children. Non-deterministic,
51
+ # so the builtin's output is verified by format rather than differentially.
52
+ def times
53
+ Process.times
54
+ end
55
+
56
+ # Send a signal to a process (the `kill` builtin); signal 0 only probes that
57
+ # the target exists. Real signal delivery cannot run under the test harness.
58
+ # :nocov:
59
+ def kill(signal, pid)
60
+ Process.kill(signal, pid)
61
+ end
62
+
63
+ # Install a signal disposition for `trap`: a command string ('IGNORE' /
64
+ # 'SYSTEM_DEFAULT') or, when nil, the block to run when the signal arrives. Mutating
65
+ # the process's real signal handlers cannot run under the test harness.
66
+ def trap_signal(name, command, &block)
67
+ Signal.trap(name, command || block)
68
+ end
69
+ # :nocov:
70
+
71
+ def pipe
72
+ IO.pipe
73
+ end
74
+
75
+ # Return a non-owning IO wrapper for an fd that is already open in the rush
76
+ # process (typically inherited from the parent) but not tracked by IoTable.
77
+ # A closed fd returns nil so redirection code can report "fd not open".
78
+ # Unbuffered: every `n>&9` evaluation wraps the fd anew, and buffered
79
+ # wrappers would flush at exit in GC order — reordering or hiding writes
80
+ # (rush-erq: container-reversed lines; a `cat` mid-script saw nothing
81
+ # where dash, which writes straight to the fd, showed the line).
82
+ def inherited_fd(fd)
83
+ return if fd.negative?
84
+
85
+ IO.for_fd(fd, autoclose: false).tap { |stream| stream.sync = true }
86
+ rescue Errno::EBADF
87
+ nil
88
+ end
89
+
90
+ # fork/exit! replace or split the process and so cannot run in-process under
91
+ # the test harness; the child-side logic they drive is extracted into pure
92
+ # methods that ARE tested, and real behaviour is covered by subprocess specs.
93
+ # exit! flushes the standard streams first: $stdout is unbuffered only when a
94
+ # tty, so a forked child running a builtin would otherwise lose its output.
95
+ # :nocov:
96
+ def fork(&blk)
97
+ Process.fork(&blk)
98
+ end
99
+
100
+ def exit!(code)
101
+ stdout.flush
102
+ stderr.flush
103
+ Process.exit!(code)
104
+ end
105
+ # :nocov:
106
+
107
+ def chdir(path)
108
+ Dir.chdir(path)
109
+ end
110
+
111
+ def pwd
112
+ Dir.pwd
113
+ end
114
+
115
+ def expand_path(path, base)
116
+ File.expand_path(path, base)
117
+ end
118
+
119
+ def fnmatch?(pattern, str, locale: COLLATION.default_settings)
120
+ COLLATION.match_shell?(pattern, str, locale) { ShellPattern.new(pattern).match?(str) }
121
+ end
122
+
123
+ # Pathname expansion: libc filters widened Dir.glob candidates and orders
124
+ # exact matches by LC_COLLATE; unsupported libcs retain the Ruby fallback.
125
+ def glob(pattern, locale: COLLATION.default_settings)
126
+ COLLATION.glob(pattern, locale) do
127
+ ShellPattern.new(pattern).then { |shell_pattern| Dir.glob(shell_pattern.glob_source).grep(shell_pattern) }
128
+ end
129
+ end
130
+
131
+ # Sync so a builtin's write reaches the file immediately — like a pipe write
132
+ # end (sync by default), this lets a forked subshell's output survive its
133
+ # exit! and be visible to a later command. File.new, not File.open: the
134
+ # redirection keeps the file open past this call — the caller owns the
135
+ # handle until close_redirect releases it, so the auto-closing block form
136
+ # would be wrong here.
137
+ def open_file(path, mode)
138
+ File.new(path, mode).tap { |io| io.sync = true }
139
+ end
140
+
141
+ # Flush and release a file a redirection opened, so a later command in the
142
+ # same shell sees the data and the fd does not leak.
143
+ def close_redirect(io)
144
+ io.close
145
+ end
146
+
147
+ def read_file(path)
148
+ File.read(path)
149
+ end
150
+
151
+ # A readable stream carrying a here-document body (a real fd, via a tempfile,
152
+ # so spawned children can read it).
153
+ def here_doc(body)
154
+ Tempfile.new('rush-heredoc').tap do |file|
155
+ file.write(body)
156
+ file.rewind
157
+ end
158
+ end
159
+
160
+ def stdin
161
+ $stdin
162
+ end
163
+
164
+ def stdout
165
+ $stdout
166
+ end
167
+
168
+ def stderr
169
+ $stderr
170
+ end
171
+
172
+ # Interactive-REPL support: read one line of input (nil at EOF) and report
173
+ # whether standard input / standard error are terminals (POSIX interactivity
174
+ # requires both).
175
+ def read_line
176
+ stdin.gets
177
+ end
178
+
179
+ # Interactive line editing: Reline draws the prompt (on stderr, like the
180
+ # plain path), records the line into its in-memory history, and returns nil
181
+ # at EOF. A real-terminal path the test harness cannot drive; the Docker
182
+ # gate's pty smoke exercises it.
183
+ # :nocov:
184
+ def edit_line(prompt)
185
+ Reline.output = stderr
186
+ Reline.readline(prompt, true)
187
+ end
188
+ # :nocov:
189
+
190
+ def tty?
191
+ stdin.tty?
192
+ end
193
+
194
+ def stderr_tty?
195
+ stderr.tty?
196
+ end
197
+
198
+ # Home directory of a named user for ~user tilde expansion, or nil if there
199
+ # is no such user.
200
+ def home_dir(name)
201
+ Etc.getpwnam(name).dir
202
+ rescue ArgumentError
203
+ nil
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,126 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # The shell's grip on its controlling terminal under job control
6
+ # (rush-mv8.3): who to hand it to, how to take it back, and what to restore
7
+ # when monitor turns off. Acquired by JobControl#enable, dash's setjobctl:
8
+ # `home` is the shell's own process group once it has made itself a leader,
9
+ # `initial` the foreground group found at acquisition — where the terminal
10
+ # returns on `set +m`. Durable job-control state, so it lives on the
11
+ # JobTable beside the root-shell bit; forked children drop it with the rest
12
+ # of the table.
13
+ class Terminal
14
+ extend T::Sig
15
+
16
+ # dash setjobctl(1): find the tty, wait until the shell is in the
17
+ # foreground, remember the terminal's owner, then become a process-group
18
+ # leader and take the terminal. Probed: the dance runs whenever the tty
19
+ # is reachable — interactive or not — and only an interactive shell (the
20
+ # caller's concern) treats its failure as an error.
21
+ sig { params(system: SystemCalls).returns(T.nilable(Terminal)) }
22
+ def self.acquire(system)
23
+ tty = system.open_tty
24
+ return unless tty
25
+
26
+ initial = wait_foreground(system, tty)
27
+ initial ? take(system, tty, initial) : abandon(tty)
28
+ end
29
+
30
+ # Wait for the foreground: a shell started in the background must not
31
+ # steal the terminal, so it stops itself (SIGTTIN to its own group, the
32
+ # OS default for a background tty read) until a job-control parent
33
+ # brings it to the front — dash's killpg(0, SIGTTIN) loop. An unreadable
34
+ # foreground group (tcgetpgrp answering nothing) falls out as nil.
35
+ sig { params(system: SystemCalls, tty: T.untyped).returns(T.nilable(Integer)) }
36
+ def self.wait_foreground(system, tty)
37
+ while (pgrp = system.tcgetpgrp(tty))
38
+ return pgrp if pgrp == system.pgrp
39
+
40
+ system.kill('TTIN', 0)
41
+ end
42
+ end
43
+
44
+ # Become a process-group leader and take the tty (dash: setpgid(0,
45
+ # rootpid); xtcsetpgrp): `home` — where foreground jobs return the
46
+ # terminal — is the shell's own group, its pid, from here on.
47
+ sig { params(system: SystemCalls, tty: T.untyped, initial: Integer).returns(Terminal) }
48
+ def self.take(system, tty, initial)
49
+ system.setpgid(0, 0)
50
+ new(system: system, tty: tty, home: system.pid, initial: initial).tap(&:reclaim)
51
+ end
52
+
53
+ # A tty whose foreground group cannot be read is no terminal at all
54
+ # (dash: tcgetpgrp failing is the "can't access tty" case).
55
+ sig { params(tty: T.untyped).returns(NilClass) }
56
+ def self.abandon(tty)
57
+ tty.close
58
+ nil
59
+ end
60
+
61
+ # The parent-side handover around a foreground wait: give for the
62
+ # block, reclaim after — bare when no terminal is held (or for a fake
63
+ # fork's pid-0 launch).
64
+ sig do
65
+ type_parameters(:U)
66
+ .params(terminal: T.nilable(Terminal), leader: Integer, blk: T.proc.returns(T.type_parameter(:U)))
67
+ .returns(T.type_parameter(:U))
68
+ end
69
+ def self.while_held(terminal, leader, &blk)
70
+ return yield unless terminal && leader.positive?
71
+
72
+ terminal.while_given(leader, &blk)
73
+ end
74
+
75
+ private_class_method :wait_foreground, :take, :abandon
76
+
77
+ sig { returns(T.untyped) }
78
+ attr_reader :tty
79
+
80
+ sig { params(system: SystemCalls, tty: T.untyped, home: Integer, initial: Integer).void }
81
+ def initialize(system:, tty:, home:, initial:)
82
+ @system = system
83
+ @tty = tty
84
+ @home = home
85
+ @initial = initial
86
+ end
87
+
88
+ # Hand the terminal to a foreground job's process group (dash forkchild).
89
+ sig { params(pgid: Integer).void }
90
+ def give(pgid)
91
+ @system.tcsetpgrp(@tty, pgid)
92
+ end
93
+
94
+ # Take the terminal back once the foreground job settles (dash
95
+ # waitforjob).
96
+ sig { void }
97
+ def reclaim
98
+ give(@home)
99
+ end
100
+
101
+ # A foreground job's whole run: the job's group holds the terminal for
102
+ # the duration of the block (the wait), and the shell takes it back
103
+ # however the wait ends.
104
+ sig do
105
+ type_parameters(:U)
106
+ .params(pgid: Integer, blk: T.proc.returns(T.type_parameter(:U)))
107
+ .returns(T.type_parameter(:U))
108
+ end
109
+ def while_given(pgid, &blk)
110
+ give(pgid)
111
+ yield
112
+ ensure
113
+ reclaim
114
+ end
115
+
116
+ # `set +m`: the terminal returns to whoever owned it at acquisition, the
117
+ # shell rejoins that group, and the tty handle is released (dash
118
+ # setjobctl(0)).
119
+ sig { void }
120
+ def restore
121
+ give(@initial)
122
+ @system.setpgid(0, @initial)
123
+ @tty.close
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,169 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Owns the executor's signal/trap handling: it records a trap, installs or
6
+ # clears the matching OS disposition, runs a delivered signal's action (with $?
7
+ # preserved, POSIX 2.14), and runs the EXIT trap as the shell terminates. Trap
8
+ # bodies are parsed and run back through the executor, so this collaborates with
9
+ # it rather than re-implementing execution.
10
+ class TrapRunner
11
+ extend T::Sig
12
+
13
+ sig { params(executor: Executor).void }
14
+ def initialize(executor)
15
+ @executor = executor
16
+ @exit_trap = ExitTrap.new(executor)
17
+ @pending = PendingSignals.new
18
+ @base = T.let(
19
+ {}, #: Hash[String, Proc]
20
+ T::Hash[String, Proc]
21
+ )
22
+ end
23
+
24
+ # The session's base dispositions (the interactive INT/QUIT/TERM handlers):
25
+ # what `trap - SIG` and an untouched signal fall back to instead of the OS
26
+ # default. Subshells drop them (POSIX: caught signals reset in subshells).
27
+ sig { params(handlers: T::Hash[String, Proc]).void }
28
+ def install_base(handlers)
29
+ @base = handlers
30
+ handlers.each_key { |name| install_signal(name, :default) }
31
+ end
32
+
33
+ # Flip one base disposition without disturbing the rest — `set -m` adds and
34
+ # removes SIGTSTP's this way mid-session (nil removes). A user trap on the
35
+ # signal keeps the disposition it installed (dash-verified in both orders);
36
+ # the base table still updates underneath, so a later `trap -` falls back
37
+ # correctly.
38
+ sig { params(name: String, handler: T.nilable(Proc)).void }
39
+ def set_base(name, handler)
40
+ @base.delete(name)
41
+ @base[name] = handler if handler
42
+ install_signal(name, :default) unless state.traps.action(name)
43
+ end
44
+
45
+ # Run the EXIT trap (if any) as the shell terminates, returning the status the
46
+ # shell exits with: the given code, unless the trap itself runs `exit`. $?
47
+ # inside the trap is that same code (POSIX 2.14), so it is published first.
48
+ sig { params(code: Integer).returns(Integer) }
49
+ def run_exit_trap(code)
50
+ run_pending
51
+ @exit_trap.run(code)
52
+ end
53
+
54
+ # Evaluate caught traps only at explicit shell checkpoints, never from the
55
+ # Ruby Signal.trap callback. No delivery guard: dash permits nested and
56
+ # same-signal traps to run at command boundaries inside an action.
57
+ sig { void }
58
+ def run_pending
59
+ @pending.drain { |name| deliver(name) }
60
+ end
61
+
62
+ sig { params(status: Status).returns(Status) }
63
+ def complete(status)
64
+ state.record_status(status).tap { run_pending }
65
+ end
66
+
67
+ sig { returns(T::Boolean) }
68
+ def pending?
69
+ @pending.any?
70
+ end
71
+
72
+ sig { returns(T.nilable(Integer)) }
73
+ def pending_exitstatus
74
+ @pending.first&.then { |name| Signals.number(name) + 128 }
75
+ end
76
+
77
+ # The status a bare `exit` reports: while the EXIT trap runs, the status the
78
+ # shell is terminating with (POSIX), not the trap body's last $?; otherwise
79
+ # the last command's status.
80
+ sig { returns(Integer) }
81
+ def exiting_status
82
+ @exit_trap.status
83
+ end
84
+
85
+ # Record a trap and (for real signals, not EXIT) install its disposition so a
86
+ # delivered signal runs the action / is ignored / restores the default.
87
+ sig { params(name: String, action: String).void }
88
+ def set(name, action)
89
+ state.traps.set(name, action)
90
+ install_signal(name, action)
91
+ end
92
+
93
+ sig { params(name: String).void }
94
+ def reset(name)
95
+ state.traps.clear(name)
96
+ install_signal(name, :default)
97
+ end
98
+
99
+ sig { void }
100
+ def reset_caught_for_subshell
101
+ @exit_trap = ExitTrap.new(@executor)
102
+ @pending.clear
103
+ drop_base
104
+ state.traps.reset_caught.each { |name| install_signal(name, :default) }
105
+ end
106
+
107
+ private
108
+
109
+ sig { returns(ShellState) }
110
+ def state
111
+ @executor.state
112
+ end
113
+
114
+ sig { params(action: String).void }
115
+ def fire(action)
116
+ @executor.run(Parser.new(Lexer.new(action, aliases: state.aliases)).parse)
117
+ rescue Error => e
118
+ raise unless ErrorPolicy.decision(:signal_trap, e) == :ignore
119
+ end
120
+
121
+ # An untrappable signal (KILL/STOP) raises; keep the table entry like dash.
122
+ sig { params(name: String, action: T.any(String, Symbol)).void }
123
+ def install_signal(name, action)
124
+ return if name == Signals::EXIT
125
+
126
+ install(name, disposition(action))
127
+ rescue ArgumentError, SystemCallError
128
+ nil
129
+ end
130
+
131
+ # :default falls back to the session's base handler when one is installed
132
+ # (an interactive shell survives INT/QUIT/TERM); otherwise the OS default.
133
+ sig { params(name: String, disposition: T.nilable(String)).void }
134
+ def install(name, disposition)
135
+ base = @base.fetch(name, nil)
136
+ return @executor.system.trap_signal(name, nil) { base.call } if disposition == 'SYSTEM_DEFAULT' && base
137
+
138
+ @executor.system.trap_signal(name, disposition) { @pending.record(name) }
139
+ end
140
+
141
+ # Subshells reset the base handlers to the true OS default before the
142
+ # per-trap reset, so a forked child dies on ^C like any non-interactive
143
+ # process.
144
+ sig { void }
145
+ def drop_base
146
+ dropped = @base
147
+ @base = {}
148
+ dropped.each_key { |name| install_signal(name, :default) }
149
+ end
150
+
151
+ # '' ignores the signal; :default restores the true OS disposition
152
+ # (SYSTEM_DEFAULT = SIG_DFL — Ruby's plain 'DEFAULT' instead simulates the
153
+ # signal by raising, which dies as an uncaught-exception traceback where
154
+ # dash dies silently by signal); a command string installs the handler
155
+ # block (nil disposition), matching SystemCalls#trap_signal.
156
+ sig { params(action: T.any(String, Symbol)).returns(T.nilable(String)) }
157
+ def disposition(action)
158
+ { '' => 'IGNORE', :default => 'SYSTEM_DEFAULT' }.fetch(action, nil)
159
+ end
160
+
161
+ # Run a delivered signal's action, restoring $? so the interrupted code is
162
+ # unaffected (POSIX 2.14); an `exit` in the action propagates and terminates.
163
+ sig { params(name: String).void }
164
+ def deliver(name)
165
+ action = state.traps.action(name)
166
+ state.preserve_status { fire(action) } if action
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,49 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # The shell's trap actions, keyed by canonical signal name (see Rush::Signals).
6
+ # An action is the command string to run when the signal fires; "" means the
7
+ # signal is ignored. Resetting a signal drops its entry, restoring the default.
8
+ class TrapTable
9
+ extend T::Sig
10
+
11
+ sig { void }
12
+ def initialize
13
+ @actions = {}
14
+ end
15
+
16
+ sig { params(name: String, action: String).returns(String) }
17
+ def set(name, action)
18
+ @actions[name] = action
19
+ end
20
+
21
+ sig { params(name: String).returns(T.nilable(String)) }
22
+ def clear(name)
23
+ @actions.delete(name)
24
+ end
25
+
26
+ sig { params(name: String).returns(T.nilable(String)) }
27
+ def action(name)
28
+ @actions.fetch(name, nil)
29
+ end
30
+
31
+ sig { returns(T::Array[String]) }
32
+ def reset_caught
33
+ caught.each { |name| @actions.delete(name) }
34
+ end
35
+
36
+ # [name, action] pairs ordered by signal number, for `trap` with no operands.
37
+ sig { returns(T::Array[[String, String]]) }
38
+ def listing
39
+ @actions.sort_by { |name, _action| Signals.number(name) }
40
+ end
41
+
42
+ private
43
+
44
+ sig { returns(T::Array[String]) }
45
+ def caught
46
+ @actions.filter_map { |name, action| name unless action.empty? }
47
+ end
48
+ end
49
+ end