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,94 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Maps file descriptors to fd entries for a single command. Redirections fold
6
+ # over a base table to produce a new one (`with`), so the shell's own streams
7
+ # are never mutated and a command's redirections are scoped to that command.
8
+ class IoTable
9
+ extend T::Sig
10
+
11
+ sig { params(entries: T::Hash[Integer, T.untyped]).void }
12
+ def initialize(entries)
13
+ @entries = entries.transform_values { |entry| fd_entry(entry) }
14
+ end
15
+
16
+ sig { params(system: SystemCalls).returns(IoTable) }
17
+ def self.standard(system)
18
+ new(0 => FdEntry.borrowed(system.stdin),
19
+ 1 => FdEntry.borrowed(system.stdout),
20
+ 2 => FdEntry.borrowed(system.stderr))
21
+ end
22
+
23
+ sig { params(fd: Integer).returns(T.untyped) }
24
+ def get(fd)
25
+ entry(fd)&.stream
26
+ end
27
+
28
+ sig { params(fd: Integer).returns(T.nilable(FdEntry)) }
29
+ def entry(fd)
30
+ @entries.fetch(fd, nil)
31
+ end
32
+
33
+ sig { params(fd: Integer, io: T.untyped).returns(IoTable) }
34
+ def with(fd, io)
35
+ with_entry(fd, FdEntry.borrowed(io))
36
+ end
37
+
38
+ sig { params(fd: Integer, io: T.untyped).returns(IoTable) }
39
+ def with_owned(fd, io)
40
+ with_entry(fd, FdEntry.owned(io))
41
+ end
42
+
43
+ sig { params(fd: Integer).returns(IoTable) }
44
+ def with_closed(fd)
45
+ with_entry(fd, FdEntry.closed)
46
+ end
47
+
48
+ sig { params(fd: Integer, entry: FdEntry).returns(IoTable) }
49
+ def with_entry(fd, entry)
50
+ self.class.new(@entries.merge(fd => entry))
51
+ end
52
+
53
+ # The bound entries, for diffing which redirections freshly opened owned
54
+ # streams over the base table. Borrowed entries (stdio, pipes and inherited
55
+ # fds) are intentionally not closed by redirect cleanup.
56
+ sig { returns(T::Array[FdEntry]) }
57
+ def entries
58
+ @entries.values
59
+ end
60
+
61
+ # Owned entries that appeared while moving from `base` to this table. This
62
+ # includes newly opened redirect files/heredocs, but not borrowed stdio,
63
+ # pipes, inherited fds or dup aliases of entries already present in `base`.
64
+ sig { params(base: IoTable).returns(T::Array[FdEntry]) }
65
+ def opened_over(base)
66
+ (entries - base.entries).select(&:owned?)
67
+ end
68
+
69
+ # Flush+close the streams this table opened over `base` (the ones a command's
70
+ # redirects added), leaving inherited streams and pipe ends untouched.
71
+ sig { params(base: IoTable, system: SystemCalls).void }
72
+ def close_opened_over(base, system)
73
+ self.class.close_entries(opened_over(base), system)
74
+ end
75
+
76
+ sig { params(entries: T::Array[FdEntry], system: SystemCalls).void }
77
+ def self.close_entries(entries, system)
78
+ entries.uniq.each { |entry| entry.close_redirect(system) }
79
+ end
80
+
81
+ # A closed fd becomes :close so a spawned child closes it.
82
+ sig { returns(T::Hash[Integer, T.untyped]) }
83
+ def to_spawn_options
84
+ @entries.transform_values(&:to_spawn_option)
85
+ end
86
+
87
+ private
88
+
89
+ sig { params(entry: T.untyped).returns(FdEntry) }
90
+ def fd_entry(entry)
91
+ entry.is_a?(FdEntry) ? entry : FdEntry.borrowed(entry)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,197 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # The monitor-mode (`set -m`) policy: whether job control is active here,
6
+ # and the side effects of flipping the flag. Stateless — Executor builds one
7
+ # on demand; the durable bits (root shell, acquired terminal) live on the
8
+ # JobTable's Control. dash 0.5.13 is the oracle throughout (probed on and
9
+ # off a tty, journal): enabling monitor ignores SIGTSTP in the shell — as a
10
+ # base disposition, so a user trap overrides it and `trap -` restores it —
11
+ # and puts every forked job (background list, pipeline, subshell, external
12
+ # command) into its own process group, first process as leader; command
13
+ # substitution stays in the shell's group. Whenever the controlling
14
+ # terminal is reachable — interactive or not — the shell additionally does
15
+ # dash's setjobctl dance: wait until it is in the foreground, remember the
16
+ # terminal's owner, make itself a process-group leader, take the terminal,
17
+ # and ignore SIGTTOU too; foreground jobs then own the tty for the length
18
+ # of their run. Only an interactive shell treats a missing tty as an error
19
+ # (warn, flag off). The machinery is root-shell-only: a forked child keeps
20
+ # `m` in $- but re-enabling there is flag-only, exactly like dash's
21
+ # rootshell guard. Its raw Options#set(:monitor, ...) calls are deliberate:
22
+ # this policy is the sole live-executor owner of both that flag and its side
23
+ # effects; all ordinary option writes route through ShellState#set_option.
24
+ class JobControl
25
+ extend T::Sig
26
+
27
+ IGNORE = T.let(proc {}, Proc)
28
+
29
+ sig { params(executor: Executor).void }
30
+ def initialize(executor)
31
+ @executor = executor
32
+ end
33
+
34
+ # `set -m` (and -o monitor, and invocation-time -m via #startup). Can
35
+ # refuse — no platform support, or an interactive shell without a tty —
36
+ # in which case the flag stays off and the failure is only a warning, as
37
+ # dash's setjobctl does. Re-enabling while on is a no-op (dash: on ==
38
+ # jobctl), preserving the remembered terminal owner.
39
+ sig { params(stderr: T.untyped).void }
40
+ def enable(stderr)
41
+ return refuse(stderr, 'job control not supported') unless @executor.system.job_control_supported?
42
+ return if options.on?(:monitor)
43
+ return options.set(:monitor, true) unless control.root
44
+
45
+ enable_root(stderr)
46
+ end
47
+
48
+ # `set +m`: the flag drops, SIGTSTP and SIGTTOU return to the OS default
49
+ # (dash setjobctl(0) runs both setsignals unconditionally), and the
50
+ # terminal goes back to whoever owned it at acquisition.
51
+ sig { void }
52
+ def disable
53
+ options.set(:monitor, false)
54
+ drop_monitor if control.root
55
+ end
56
+
57
+ # Invocation-time -m (explicit, or defaulted on for an interactive
58
+ # shell like dash's mflag = iflag): the flag was set while building the
59
+ # shell state, before any executor existed to carry the side effects.
60
+ # Re-run it through #enable so an interactive shell without a tty drops
61
+ # the flag (dash: "can't access tty" at startup), and a permitted one
62
+ # acquires the terminal and its base dispositions.
63
+ sig { void }
64
+ def startup
65
+ return unless options.on?(:monitor)
66
+
67
+ options.set(:monitor, false)
68
+ enable(@executor.system.stderr)
69
+ end
70
+
71
+ # Process grouping is active: monitor is on and this is the root shell.
72
+ # Forked children inherit `m` in $- but none of the machinery (dash's
73
+ # rootshell guard, probed via nested groups and subshell `set -m`).
74
+ sig { returns(T::Boolean) }
75
+ def monitored?
76
+ control.monitor?
77
+ end
78
+
79
+ # Fork one member of a foreground job, into `leader`'s process group
80
+ # (its own when none) under job control; a group leader also takes the
81
+ # terminal, child-side, when the shell holds one. Grouping is decided
82
+ # before the fork, so a child entering its subshell environment cannot
83
+ # flip it.
84
+ sig { params(leader: T.nilable(Integer), child_main: T.proc.returns(T.untyped)).returns(Integer) }
85
+ def launch(leader: nil, &child_main)
86
+ return @executor.system.fork(&child_main) || 0 unless monitored?
87
+
88
+ group = leader&.positive? ? leader : 0
89
+ @executor.system.fork_grouped(group, handover_tty(group), &child_main) || 0
90
+ end
91
+
92
+ # An asynchronous (&) job: its own group under job control, but never
93
+ # the terminal — probed: the tty stays with the shell.
94
+ sig { params(child_main: T.proc.returns(T.untyped)).returns(Integer) }
95
+ def launch_background(&child_main)
96
+ return @executor.system.fork(&child_main) || 0 unless monitored?
97
+
98
+ @executor.system.fork_grouped(0, &child_main) || 0
99
+ end
100
+
101
+ # Wait for a foreground job with the terminal handed to its process
102
+ # group, taking it back once the job settles, however the wait ends
103
+ # (dash's waitforjob). The give here is the parent-side settle — it also
104
+ # covers the spawn path, which has no child-side hook — and without a
105
+ # held terminal the wait runs bare. A wait that answers "stopped" (^Z
106
+ # under the WUNTRACED waits) parks the whole job in the table on the
107
+ # way out, where jobs/wait/kill find it.
108
+ sig { params(pids: T::Array[Integer], text: T.nilable(String), blk: T.proc.returns(Status)).returns(Status) }
109
+ def foreground(pids, text: nil, &blk)
110
+ status = Terminal.while_held(control.terminal, pids.fetch(0), &blk)
111
+ @executor.jobs.adopt_stopped(pids, T.must(status.stopsig), text) if status.stopped?
112
+ status
113
+ end
114
+
115
+ # The command text a job-table entry keeps: rendered only under job
116
+ # control — dash stores cmdtext for jobctl jobs alone, and that absence
117
+ # is fg/bg's refusal bit.
118
+ sig { params(node: AST::Node).returns(T.nilable(String)) }
119
+ def job_text(node)
120
+ CommandText.render(node) if control.monitor?
121
+ end
122
+
123
+ private
124
+
125
+ # The root-shell side of `set -m`: with a reachable tty, the full dash
126
+ # setjobctl dance; without one, grouping only — an error only for an
127
+ # interactive shell.
128
+ sig { params(stderr: T.untyped).void }
129
+ def enable_root(stderr)
130
+ terminal = Terminal.acquire(@executor.system)
131
+ terminal ? enable_with_terminal(terminal) : enable_off_tty(stderr)
132
+ end
133
+
134
+ # SIGTSTP and SIGTTOU ignored (probed under a pty; SIGTTIN keeps the OS
135
+ # default — that is what parks a background-started shell until fg), as
136
+ # base dispositions: user traps win in either order and `trap -` falls
137
+ # back here.
138
+ sig { params(terminal: Terminal).void }
139
+ def enable_with_terminal(terminal)
140
+ control.engage(terminal)
141
+ @executor.trap_runner.set_base('TSTP', IGNORE)
142
+ @executor.trap_runner.set_base('TTOU', IGNORE)
143
+ options.set(:monitor, true)
144
+ end
145
+
146
+ # Off-tty monitor is grouping, WUNTRACED waits and the SIGTSTP ignore
147
+ # alone (probed: dash leaves TTOU stopping the shell there).
148
+ sig { params(stderr: T.untyped).void }
149
+ def enable_off_tty(stderr)
150
+ return refuse(stderr, "can't access tty; job control turned off") if interactive?
151
+
152
+ control.engage(nil)
153
+ @executor.trap_runner.set_base('TSTP', IGNORE)
154
+ options.set(:monitor, true)
155
+ end
156
+
157
+ # The root-shell side of `set +m`: both stop-signal bases return to the
158
+ # OS default and the terminal, when held, goes home.
159
+ sig { void }
160
+ def drop_monitor
161
+ @executor.trap_runner.set_base('TSTP', nil)
162
+ @executor.trap_runner.set_base('TTOU', nil)
163
+ control.terminal&.restore
164
+ control.release
165
+ end
166
+
167
+ # The tty a freshly forked job leader should take with it (dash
168
+ # forkchild's FORK_FG xtcsetpgrp): only a group leader, only while the
169
+ # shell holds the terminal. Joiners find the group already in front.
170
+ sig { params(group: Integer).returns(T.untyped) }
171
+ def handover_tty(group)
172
+ return if group.nonzero?
173
+
174
+ control.terminal&.tty
175
+ end
176
+
177
+ sig { params(stderr: T.untyped, message: String).void }
178
+ def refuse(stderr, message)
179
+ stderr.puts("rush: #{message}")
180
+ end
181
+
182
+ sig { returns(JobTable::Control) }
183
+ def control
184
+ @executor.jobs.control
185
+ end
186
+
187
+ sig { returns(T::Boolean) }
188
+ def interactive?
189
+ options.on?(:interactive)
190
+ end
191
+
192
+ sig { returns(Options) }
193
+ def options
194
+ @executor.state.options
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,45 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # One line of the jobs listing — and, verbatim, of a pre-prompt
6
+ # notification: "[n] mark " (plus "pid " under jobs -l), the state, padding
7
+ # so the command text starts at column 34, then the text itself (empty
8
+ # outside job control, where dash keeps none either). dash's showjob
9
+ # format, shared by the jobs builtin and the Repl's notifier.
10
+ module JobReport
11
+ extend T::Sig
12
+
13
+ sig { params(table: JobTable, job: JobTable::Job, pid_field: String).returns(String) }
14
+ def self.line(table, job, pid_field = '')
15
+ head = "[#{job.number}] #{mark(table, job)} #{pid_field}#{state(job)}"
16
+ "#{head.ljust(33)}#{job.text}"
17
+ end
18
+
19
+ # + marks the current job, - the previous one (newest-first order).
20
+ sig { params(table: JobTable, job: JobTable::Job).returns(String) }
21
+ def self.mark(table, job)
22
+ index = T.must(table.ordered.index(job))
23
+ ['+', '-'].fetch(index, ' ')
24
+ end
25
+
26
+ # The state column (dash's statusfmt vocabulary): Running, a strsignal
27
+ # Stopped flavour, Done/Done(n), or the killing signal's description.
28
+ sig { params(job: JobTable::Job).returns(String) }
29
+ def self.state(job)
30
+ return 'Running' if job.running?
31
+ return Signals.stop_description(T.must(job.stopsig)) if job.stopped?
32
+
33
+ settled(job.status)
34
+ end
35
+
36
+ sig { params(status: Status).returns(String) }
37
+ def self.settled(status)
38
+ signal = status.termsig
39
+ return Signals.description(signal) if signal
40
+
41
+ code = status.exitstatus
42
+ code.zero? ? 'Done' : "Done(#{code})"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,51 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Resolves a %job_id operand (POSIX 2.12 Job Control Job ID) against the
6
+ # job table: % / %% / %+ name the current job, %- the previous one, %n job
7
+ # number n. dash keeps no command text away from a tty, so %string and
8
+ # %?string prefixes match nothing here — No such job, exactly like the
9
+ # oracle. A failed resolution raises JobError carrying dash's message; the
10
+ # consuming builtin prefixes its name and fails with status 2.
11
+ class JobSpec
12
+ extend T::Sig
13
+
14
+ NUMBER = /\A%\d+\z/
15
+
16
+ # Fork-inherited display copies resolve for nobody: the jobs listing
17
+ # shows them (POSIX 2.12 duplicate), but wait/kill/fg/bg in a forked
18
+ # child answer No such job, exactly like dash (probed: a pipeline
19
+ # stage lists [1] while `wait %1` in the same stage reports rc=2).
20
+ sig { params(jobs: JobTable, spec: String).returns(JobTable::Job) }
21
+ def self.resolve(jobs, spec)
22
+ case spec
23
+ when '%', '%%', '%+' then live(jobs.current) || missing('current')
24
+ when '%-' then live(jobs.previous) || missing('previous')
25
+ else by_number(jobs, spec)
26
+ end
27
+ end
28
+
29
+ sig { params(jobs: JobTable, spec: String).returns(JobTable::Job) }
30
+ def self.by_number(jobs, spec)
31
+ unknown(spec) unless spec.match?(NUMBER)
32
+
33
+ live(jobs.numbered(Integer(spec.delete_prefix('%'), 10))) || unknown(spec)
34
+ end
35
+
36
+ sig { params(job: T.nilable(JobTable::Job)).returns(T.nilable(JobTable::Job)) }
37
+ def self.live(job)
38
+ job if job && !job.inherited?
39
+ end
40
+
41
+ sig { params(which: String).returns(T.noreturn) }
42
+ def self.missing(which)
43
+ raise JobError, "No #{which} job"
44
+ end
45
+
46
+ sig { params(spec: String).returns(T.noreturn) }
47
+ def self.unknown(spec)
48
+ raise JobError, "No such job: #{spec}"
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,103 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class JobTable
6
+ # The job-control environment of this shell: the root-shell bit, how
7
+ # this shell treats stopped children (bare waits, engaged monitor
8
+ # machinery, or a stage's stop relay), and — with a tty — the acquired
9
+ # controlling terminal. dash's rootshell/jobctl guards and
10
+ # ttyfd/initialpgrp globals; monitor and terminal drop together when a
11
+ # forked child clears its job table, while an armed relay survives the
12
+ # fork so stops keep bubbling (rush-l4o).
13
+ class Control
14
+ extend T::Sig
15
+
16
+ sig { returns(T::Boolean) }
17
+ attr_reader :root
18
+
19
+ sig { returns(T.nilable(Terminal)) }
20
+ attr_reader :terminal
21
+
22
+ sig { void }
23
+ def initialize
24
+ @root = T.let(true, T::Boolean)
25
+ @stops = T.let(:default, Symbol)
26
+ @terminal = T.let(nil, T.nilable(Terminal))
27
+ @warning = T.let(0, Integer)
28
+ end
29
+
30
+ # Monitor machinery engaged in this shell (grouping, WUNTRACED waits,
31
+ # stops adopted into the job table).
32
+ sig { returns(T::Boolean) }
33
+ def monitor?
34
+ @stops == :monitor
35
+ end
36
+
37
+ # A pipeline stage forked from a monitor-mode shell is a transparent
38
+ # member of its job: once armed, a stop this process's own foreground
39
+ # wait reaps is re-raised onto the process itself, propagating the
40
+ # stop to the job's owner (rush-l4o; dash reaches the same picture by
41
+ # exec-ing simple stages in place). Armed child-side before the
42
+ # subshell reset, while the parent's monitor bit is still readable.
43
+ sig { void }
44
+ def arm_stage_relay
45
+ @stops = :relay if monitor?
46
+ end
47
+
48
+ sig { returns(T::Boolean) }
49
+ def relay?
50
+ @stops == :relay
51
+ end
52
+
53
+ # Stops are visible to this shell's waits: the monitor adopts them,
54
+ # the relay re-raises them.
55
+ sig { returns(T::Boolean) }
56
+ def stoppable_waits?
57
+ monitor? || relay?
58
+ end
59
+
60
+ # `set -m` accepted in the root shell: machinery on, holding the
61
+ # terminal when one was acquired (nil off-tty — grouping still runs).
62
+ sig { params(terminal: T.nilable(Terminal)).void }
63
+ def engage(terminal)
64
+ @stops = :monitor
65
+ @terminal = terminal
66
+ end
67
+
68
+ sig { void }
69
+ def release
70
+ @stops = :default
71
+ @terminal = nil
72
+ end
73
+
74
+ # A fork drops root, terminal and monitor together — but keeps an
75
+ # armed relay, so stops bubble out of nested forks within a stage.
76
+ sig { void }
77
+ def fork_child
78
+ @root = false
79
+ @terminal = nil
80
+ @stops = :default if monitor?
81
+ end
82
+
83
+ # dash's job_warning: the first exit with a stopped job warns and is
84
+ # refused; the window it opens lets the very next command's exit
85
+ # through, and two interactive ticks re-arm it (a batch shell never
86
+ # ticks, so it warns exactly once).
87
+ sig { returns(T::Boolean) }
88
+ def warn_exit?
89
+ return false if @warning.positive?
90
+
91
+ @warning = 2
92
+ true
93
+ end
94
+
95
+ # One interactive turn passing (the Repl, as dash's cmdloop decrements
96
+ # job_warning per iteration).
97
+ sig { void }
98
+ def tick_warning
99
+ @warning -= 1 if @warning.positive?
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,70 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Reopens the table only to keep its interruptible-wait implementation next
6
+ # to the polling collaborator; the core initializer owns these ivars.
7
+ # :reek:InstanceVariableAssumption
8
+ class JobTable
9
+ # WNOHANG polling used only by the wait builtin. Foreground awaits remain
10
+ # blocking, while a caught trap can stop this wait without reaping its job.
11
+ class InterruptibleWait
12
+ extend T::Sig
13
+
14
+ POLL_INTERVAL = 0.01
15
+
16
+ sig do
17
+ params(system: SystemCalls, control: Control, target: Integer,
18
+ pending: T.proc.returns(T.nilable(Integer))).void
19
+ end
20
+ def initialize(system, control, target, pending)
21
+ @system = system
22
+ @control = control
23
+ @target = target
24
+ @pending = pending
25
+ end
26
+
27
+ sig { returns(T.any(Process::Status, Status)) }
28
+ def call
29
+ loop do
30
+ result = interrupted || poll
31
+ return result if result
32
+
33
+ sleep(POLL_INTERVAL)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ sig { returns(T.nilable(Status)) }
40
+ def interrupted
41
+ code = @pending.call
42
+ Status.new(code) if code
43
+ end
44
+
45
+ sig { returns(T.nilable(Process::Status)) }
46
+ def poll
47
+ pair = @control.stoppable_waits? ? @system.poll_pid_stopped(@target) : @system.poll_pid(@target)
48
+ pair&.fetch(1)
49
+ end
50
+ end
51
+
52
+ # The wait builtin alone is interruptible by a caught signal. Polling keeps
53
+ # JobTable the sole reaper while foreground await remains a blocking wait.
54
+ sig { params(pid: Integer, pending: T.proc.returns(T.nilable(Integer))).returns(T.nilable(Status)) }
55
+ def wait_for_interruptibly(pid, &pending)
56
+ @jobs[pid]&.harvest { interruptible_settle(pid, pending) }
57
+ rescue Errno::ECHILD
58
+ Status.success
59
+ end
60
+
61
+ private
62
+
63
+ sig do
64
+ params(pid: Integer, pending: T.proc.returns(T.nilable(Integer))).returns(T.any(Process::Status, Status))
65
+ end
66
+ def interruptible_settle(pid, pending)
67
+ @stash.delete(pid) || InterruptibleWait.new(@system, @control, pid, pending).call
68
+ end
69
+ end
70
+ end