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,181 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class JobTable
6
+ # One job. Identity holds what never changes: the [n] number, the member
7
+ # pids (a pipeline's group leader first — fg/bg and group kills need them
8
+ # all), and the rendered command text — nil outside job control, exactly
9
+ # dash's model, where cmdtext is kept only for jobctl jobs and that
10
+ # absence IS the fg/bg "not created under job control" refusal.
11
+ # State is the Status of the last decisive wait: nothing while running, a
12
+ # stop-carrying Status while parked (^Z — re-waitable, answering
13
+ # 128+stopsig until fg/bg's SIGCONT), a final one once done; `changed`
14
+ # tracks whether the latest transition has been reported (the pre-prompt
15
+ # notification, or the jobs listing itself).
16
+ class Job
17
+ extend T::Sig
18
+
19
+ # The creation-time facts, bundled so the mutable state stays apart.
20
+ class Identity
21
+ extend T::Sig
22
+
23
+ sig { returns(Integer) }
24
+ attr_reader :number
25
+
26
+ sig { returns(T::Array[Integer]) }
27
+ attr_reader :members
28
+
29
+ sig { returns(T.nilable(String)) }
30
+ attr_reader :text
31
+
32
+ sig { params(number: Integer, members: T::Array[Integer], text: T.nilable(String)).void }
33
+ def initialize(number, members, text)
34
+ @number = number
35
+ @members = members
36
+ @text = text
37
+ end
38
+ end
39
+
40
+ sig { returns(T::Boolean) }
41
+ attr_reader :changed
42
+
43
+ sig { params(number: Integer, pid: Integer, members: T::Array[Integer], text: T.nilable(String)).void }
44
+ extend Forwardable
45
+
46
+ def initialize(number, pid, members: [pid], text: nil)
47
+ @identity = T.let(Identity.new(number, members, text), Identity)
48
+ @result = T.let(nil, T.nilable(Status))
49
+ @changed = T.let(false, T::Boolean)
50
+ @inherited = T.let(false, T::Boolean)
51
+ end
52
+
53
+ def_delegators :@identity, :number, :members
54
+
55
+ sig { returns(Integer) }
56
+ def pid
57
+ members.fetch(0)
58
+ end
59
+
60
+ # The rendered command line ('' outside job control, where dash keeps
61
+ # no text either).
62
+ sig { returns(String) }
63
+ def text
64
+ @identity.text.to_s
65
+ end
66
+
67
+ # Created under monitor mode, so fg/bg may resume it (dash refuses
68
+ # "not created under job control" for the rest, whatever mflag says
69
+ # now — probed): the kept command text is the stamp.
70
+ sig { returns(T::Boolean) }
71
+ def controlled?
72
+ !!@identity.text
73
+ end
74
+
75
+ # A fork demotes the entry to a POSIX 2.12 display copy: the subshell
76
+ # environment duplicates the async-pid knowledge (jobs listing,
77
+ # jobs -p), but the process is no child of this environment — wait
78
+ # and %id resolution refuse it (dash-probed in real forked children).
79
+ sig { void }
80
+ def inherit
81
+ @inherited = true
82
+ end
83
+
84
+ sig { returns(T::Boolean) }
85
+ def inherited?
86
+ @inherited
87
+ end
88
+
89
+ # File a reaped wait result: a stop parks the job (still alive, still
90
+ # re-waitable), anything else settles it for good; answers the filed
91
+ # Status (never nil — JobTable#store leans on that).
92
+ sig { params(raw: Process::Status).returns(Status) }
93
+ def finish(raw)
94
+ @changed = true
95
+ @result = Status.of(raw)
96
+ end
97
+
98
+ sig { params(stopsig: Integer).void }
99
+ def stop(stopsig)
100
+ @changed = true
101
+ @result = Status.stopped(stopsig)
102
+ end
103
+
104
+ # fg/bg's SIGCONT: a stopped job runs again — its own announcement, no
105
+ # notification owed.
106
+ sig { void }
107
+ def resume
108
+ return unless stopped?
109
+
110
+ @result = nil
111
+ @changed = false
112
+ end
113
+
114
+ # The whole fg/bg resume move: mark the job running again and SIGCONT
115
+ # its process group (the leader pid is the group under job control); a
116
+ # group already gone is settled — the memory answers.
117
+ sig { params(system: SystemCalls).void }
118
+ def continue(system)
119
+ resume
120
+ system.kill('CONT', -pid)
121
+ rescue Errno::ESRCH
122
+ nil
123
+ end
124
+
125
+ # A state change was displayed (jobs listing or pre-prompt
126
+ # notification): nothing further to announce.
127
+ sig { void }
128
+ def reported
129
+ @changed = false
130
+ end
131
+
132
+ # One pre-prompt notification: print this job's change and settle the
133
+ # report — a finished entry leaves the table (dash's showjob + free).
134
+ sig { params(table: JobTable, out: T.untyped).void }
135
+ def report(table, out)
136
+ out.puts(JobReport.line(table, self))
137
+ reported
138
+ table.forget(self) if finished?
139
+ end
140
+
141
+ # Reap once: a settled job answers from memory (dash never frees an
142
+ # entry on wait), a stopped one answers 128+stopsig immediately and
143
+ # repeatably (dash-probed), and a running one blocks in the supplied
144
+ # wait — which may itself park the job. A fork-inherited display copy
145
+ # is never reapable: it answers nil, the callers' unknown-job path.
146
+ sig { params(blk: T.proc.returns(T.any(Process::Status, Status))).returns(T.nilable(Status)) }
147
+ def harvest(&blk)
148
+ return if inherited?
149
+ return status unless running?
150
+
151
+ result = yield
152
+ result.is_a?(Status) ? result : finish(result)
153
+ end
154
+
155
+ sig { returns(T::Boolean) }
156
+ def running?
157
+ !@result
158
+ end
159
+
160
+ sig { returns(T::Boolean) }
161
+ def stopped?
162
+ !!@result&.stopped?
163
+ end
164
+
165
+ sig { returns(T::Boolean) }
166
+ def finished?
167
+ !running? && !stopped?
168
+ end
169
+
170
+ sig { returns(T.nilable(Integer)) }
171
+ def stopsig
172
+ @result&.stopsig
173
+ end
174
+
175
+ sig { returns(Status) }
176
+ def status
177
+ T.must(@result)
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,194 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # The shell's asynchronous jobs, and the single owner of child reaping (the
6
+ # design risk epic rush-mv8 recorded): every synchronous wait routes through
7
+ # #await, so no two waits can steal each other's statuses. While no
8
+ # background job is running an await targets its pid directly; once an
9
+ # asynchronous list has launched, it reaps whichever child exits next and
10
+ # files foreign statuses — a background job's on its entry (where the wait
11
+ # builtin finds it), a sibling foreground child's in a stash its own await
12
+ # consults first. A reaped job's status stays remembered until the jobs
13
+ # builtin displays it (dash frees reported entries); forked child
14
+ # environments keep the entries only as unwaitable display copies
15
+ # (POSIX 2.12 — see #enter_subshell).
16
+ class JobTable
17
+ extend T::Sig
18
+
19
+ sig { params(system: SystemCalls).void }
20
+ def initialize(system)
21
+ @system = system
22
+ @jobs = T.let(
23
+ {}, #: Hash[Integer, Job]
24
+ T::Hash[Integer, Job]
25
+ )
26
+ @stash = T.let(
27
+ {}, #: Hash[Integer, Process::Status]
28
+ T::Hash[Integer, Process::Status]
29
+ )
30
+ @control = T.let(Control.new, Control)
31
+ end
32
+
33
+ # The durable job-control environment (root-shell bit, acquired
34
+ # terminal): JobControl's state, kept here because it lives and dies
35
+ # with the table — a forked child drops both together.
36
+ sig { returns(Control) }
37
+ attr_reader :control
38
+
39
+ # A background launch: the pid becomes job [n] — the lowest free number,
40
+ # as dash numbers slots. Fake fork ports return no child pid (mapped to
41
+ # 0); a real parent always records a positive one.
42
+ sig { params(pid: Integer, text: T.nilable(String)).void }
43
+ def record(pid, text: nil)
44
+ @jobs[pid] = Job.new(free_number, pid, text: text) if pid.positive?
45
+ end
46
+
47
+ # A foreground job a WUNTRACED wait handed back as stopped (^Z): it
48
+ # becomes a job-table entry — number, the group leader as its pid, every
49
+ # pipeline member listed — parked Stopped, where jobs/wait/kill/%ids
50
+ # find it (dash keeps its jobtab entry; rush creates one on the spot).
51
+ sig { params(pids: T::Array[Integer], stopsig: Integer, text: T.nilable(String)).void }
52
+ def adopt_stopped(pids, stopsig, text = nil)
53
+ leader = pids.fetch(0)
54
+ (@jobs[leader] ||= Job.new(free_number, leader, members: pids, text: text)).stop(stopsig) if leader.positive?
55
+ end
56
+
57
+ # Newest first: the jobs builtin's display order, and what makes the
58
+ # first entry the current job (+) and the second the previous one (-).
59
+ sig { returns(T::Array[Job]) }
60
+ def ordered
61
+ @jobs.values.reverse
62
+ end
63
+
64
+ sig { returns(T.nilable(Job)) }
65
+ def current
66
+ ordered.first
67
+ end
68
+
69
+ sig { returns(T.nilable(Job)) }
70
+ def previous
71
+ ordered.fetch(1, nil)
72
+ end
73
+
74
+ sig { params(number: Integer).returns(T.nilable(Job)) }
75
+ def numbered(number)
76
+ @jobs.each_value.find { |job| job.number == number }
77
+ end
78
+
79
+ # Blocking wait for a foreground child's status. ECHILD is a defensive
80
+ # guard: entries never outlive their environment (forked children clear
81
+ # the table), so a wait on a vanished child answers success rather than
82
+ # crashing.
83
+ sig { params(pid: Integer).returns(Status) }
84
+ def await(pid)
85
+ Status.of(settle(pid))
86
+ rescue Errno::ECHILD
87
+ Status.success
88
+ end
89
+
90
+ # The wait builtin, one pid: a remembered status, a blocking wait on a
91
+ # live job, or nil when the pid is not a waitable job here — unknown,
92
+ # or a fork-inherited display copy (POSIX: 127).
93
+ sig { params(pid: Integer).returns(T.nilable(Status)) }
94
+ def wait_for(pid)
95
+ @jobs[pid]&.harvest { settle(pid) }
96
+ rescue Errno::ECHILD
97
+ Status.success
98
+ end
99
+
100
+ # A forked child environment is a duplicate of the shell environment
101
+ # (POSIX 2.12), async pids included: the entries stay for display —
102
+ # jobs, jobs -p, and through them the standard-blessed `wait $(jobs -p)`
103
+ # in the parent — but demoted to inherited copies, since the parent's
104
+ # children are not waitable here: wait by pid reports 127 and a %id
105
+ # reports No such job, as dash does in a real (non-tail-optimized)
106
+ # forked child. It is no root shell either (see #root?).
107
+ sig { void }
108
+ def enter_subshell
109
+ @jobs.each_value(&:inherit)
110
+ @stash.clear
111
+ @control.fork_child
112
+ end
113
+
114
+ # The jobs builtin, after displaying a finished entry: dash frees
115
+ # reported jobs, so a later wait or %id no longer knows them.
116
+ sig { params(job: Job).void }
117
+ def forget(job)
118
+ @jobs.delete(job.pid)
119
+ end
120
+
121
+ # Opportunistic non-blocking reap, so the jobs builtin sees a child that
122
+ # finished — or, under monitor mode, stopped — since the last
123
+ # synchronous wait.
124
+ sig { void }
125
+ def poll
126
+ while (reaped = @control.monitor? ? @system.poll_stopped : @system.poll_child)
127
+ store(reaped.fetch(0), reaped.fetch(1))
128
+ end
129
+ rescue Errno::ECHILD
130
+ nil
131
+ end
132
+
133
+ # fg's wait (rush-mv8.5): every member — the leader through its entry,
134
+ # the rest through the stash-aware await — the verdict carrying any
135
+ # stop, exactly like a fresh foreground pipeline's.
136
+ sig { params(job: Job).returns(Status) }
137
+ def settle_members(job)
138
+ PipelineStatuses.new(job.members.map { |pid| wait_for(pid) || await(pid) }).verdict
139
+ end
140
+
141
+ # dash's cmdloop showjobs(SHOW_CHANGED): the Repl calls this before
142
+ # each PS1 under monitor mode.
143
+ sig { params(out: T.untyped).void }
144
+ def announce_changed(out)
145
+ poll
146
+ ordered.select(&:changed).each { |job| job.report(self, out) }
147
+ end
148
+
149
+ private
150
+
151
+ sig { params(pid: Integer).returns(Process::Status) }
152
+ def settle(pid)
153
+ @stash.delete(pid) || reap_raw(pid)
154
+ end
155
+
156
+ # The lowest free job number, as dash fills slots: the range up to
157
+ # size+1 always contains one.
158
+ sig { returns(Integer) }
159
+ def free_number
160
+ ((1..(@jobs.size + 1)).to_a - @jobs.each_value.map(&:number)).fetch(0)
161
+ end
162
+
163
+ # A relayed stop (StopRelay: armed stage, stopped target) re-raises
164
+ # onto this process and, after SIGCONT, re-waits the same target.
165
+ sig { params(target: Integer).returns(Process::Status) }
166
+ def reap_raw(target)
167
+ loop do
168
+ pid, status = reap_one(target)
169
+ return status if pid == target && !StopRelay.relay?(@control, status)
170
+
171
+ pid == target ? StopRelay.raise_onto_self(@system, status) : store(pid, status)
172
+ end
173
+ end
174
+
175
+ # waitpid(-1) only when a background job could change state meanwhile —
176
+ # a stopped job still counts, its death arrives asynchronously, but an
177
+ # inherited display copy never does (not this environment's child) —
178
+ # since the direct form cannot reap a foreign child; the plain
179
+ # foreground path stays byte-identical to the pre-table behaviour.
180
+ # Monitor mode and an armed stage relay wait WUNTRACED (dash: mflag),
181
+ # so a stop answers instead of hanging the shell.
182
+ sig { params(target: Integer).returns([Integer, Process::Status]) }
183
+ def reap_one(target)
184
+ pid = @jobs.each_value.any? { |job| !job.finished? && !job.inherited? } ? -1 : target
185
+ @control.stoppable_waits? ? @system.wait_stoppable(pid) : @system.waitpid2(pid)
186
+ end
187
+
188
+ # Job#finish answers non-nil, so a known pid never falls to the stash.
189
+ sig { params(pid: Integer, status: Process::Status).void }
190
+ def store(pid, status)
191
+ @jobs[pid]&.finish(status) || (@stash[pid] = status)
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,129 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # POSIX 2.3.1 alias substitution at lex time. Decides whether a word scanned
7
+ # in command position is an alias to splice, guarding against re-expanding an
8
+ # alias within its own replacement (recursion), and tracking the trailing
9
+ # <blank> rule: a replacement ending in a blank makes the *next* word eligible
10
+ # too, even though it sits in argument position. Eligibility carries into the
11
+ # first word of a replacement, so `b` -> `hello` -> `world` chains. It also
12
+ # owns the stack of input scanners pushed beneath the replacements being read.
13
+ class AliasExpander
14
+ extend T::Sig
15
+
16
+ # A resolved alias replacement ready to splice into the scanner.
17
+ class Replacement
18
+ extend T::Sig
19
+
20
+ sig { returns(String) }
21
+ attr_reader :name, :value
22
+
23
+ sig { params(name: String, value: String).void }
24
+ def initialize(name, value)
25
+ @name = name
26
+ @value = value
27
+ end
28
+
29
+ sig { params(parent: StringScanner).returns(Frame) }
30
+ def frame(parent)
31
+ Frame.new(name, value, parent)
32
+ end
33
+ end
34
+
35
+ # One active replacement and the scanner it temporarily covers.
36
+ class Frame
37
+ extend T::Sig
38
+
39
+ sig { returns(String) }
40
+ attr_reader :name, :value
41
+
42
+ sig { returns(StringScanner) }
43
+ attr_reader :parent
44
+
45
+ sig { params(name: String, value: String, parent: StringScanner).void }
46
+ def initialize(name, value, parent)
47
+ @name = name
48
+ @value = value
49
+ @parent = parent
50
+ end
51
+
52
+ sig { returns(T::Boolean) }
53
+ def trailing_blank?
54
+ value.end_with?(' ', "\t")
55
+ end
56
+ end
57
+
58
+ sig { params(table: T.nilable(AliasTable)).void }
59
+ def initialize(table)
60
+ @table = table
61
+ @frames = []
62
+ @check_next = false
63
+ end
64
+
65
+ # The replacement text when `word` is an alias eligible to expand here, else
66
+ # nil. Eligible means command position or a pending trailing-blank carry.
67
+ sig { params(word: AST::Word, command_position: T::Boolean).returns(T.nilable(Replacement)) }
68
+ def expand(word, command_position)
69
+ name = word.literal_name
70
+ return unless name && eligible?(command_position)
71
+
72
+ table = @table
73
+ return unless table
74
+
75
+ enter(table, name)
76
+ end
77
+
78
+ # Stash the scanner a replacement was spliced over; restore it once the
79
+ # replacement is fully read, dropping the alias from the active set and, if
80
+ # its value ended in a blank, marking the following word eligible too (OR so
81
+ # an inner blank-ending alias still chains past an outer one).
82
+ sig { params(replacement: Replacement, scanner: StringScanner).void }
83
+ def push(replacement, scanner)
84
+ @frames.push(replacement.frame(scanner))
85
+ end
86
+
87
+ sig { returns(T::Boolean) }
88
+ def nested?
89
+ @frames.any?
90
+ end
91
+
92
+ sig { returns(T.nilable(StringScanner)) }
93
+ def pop
94
+ frame = @frames.fetch(-1)
95
+ @frames.pop
96
+ @check_next = true if frame.trailing_blank?
97
+ frame.parent
98
+ end
99
+
100
+ # A real (non-spliced) token was emitted: spend the one-shot carry.
101
+ sig { void }
102
+ def spend
103
+ @check_next = false
104
+ end
105
+
106
+ private
107
+
108
+ # Eligible position: aliases are defined and the word sits in command
109
+ # position, or a pending trailing-blank carry makes it eligible here.
110
+ sig { params(command_position: T::Boolean).returns(T::Boolean) }
111
+ def eligible?(command_position)
112
+ command_position || @check_next
113
+ end
114
+
115
+ sig { params(table: AliasTable, name: String).returns(T.nilable(Replacement)) }
116
+ def enter(table, name)
117
+ value = table.value(name)
118
+ return if !value || active?(name)
119
+
120
+ Replacement.new(name, value)
121
+ end
122
+
123
+ sig { params(name: String).returns(T::Boolean) }
124
+ def active?(name)
125
+ @frames.any? { |frame| frame.name == name }
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,86 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # Reads the body of a ${...} after the consumed `${`, up to the matching
7
+ # `}` (POSIX 2.6.2): a nested `${` opens a level (read recursively), a
8
+ # backslash escapes the next character, and quoted strings / command and
9
+ # arithmetic substitutions are skipped whole. `quoted` follows dash: inside
10
+ # double quotes and here-doc bodies a single quote is an ordinary
11
+ # character; outside them it quotes a region. The body keeps its raw
12
+ # spelling — the operator word is re-scanned at expansion time.
13
+ class BracedReader
14
+ extend T::Sig
15
+ include QuoteSkips
16
+
17
+ # The literal run per context: a quoted ${...} keeps ' ordinary.
18
+ PLAIN = { false => /[^}$\\'"`]+/, true => /[^}$\\"`]+/ }.freeze
19
+
20
+ sig { params(scanner: StringScanner, error: T.class_of(ParseError), quoted: T::Boolean).void }
21
+ def initialize(scanner, error:, quoted:)
22
+ @scanner = scanner
23
+ @error = error
24
+ @quoted = quoted
25
+ end
26
+
27
+ sig { returns(String) }
28
+ def read
29
+ body = +''
30
+ body << step until peek?('}')
31
+ @scanner.getch
32
+ body
33
+ end
34
+
35
+ private
36
+
37
+ sig { returns(String) }
38
+ def step
39
+ @scanner.scan(PLAIN.fetch(@quoted)) || special
40
+ end
41
+
42
+ sig { returns(String) }
43
+ def special
44
+ return "\\#{must_char}" if @scanner.scan('\\')
45
+ return single if @scanner.scan("'")
46
+ return double if @scanner.scan('"')
47
+ return "`#{SubstitutionReader.new(@scanner).backticks}`" if @scanner.scan('`')
48
+
49
+ dollar
50
+ end
51
+
52
+ # The unterminated-input error the call site configured.
53
+ sig { override.returns(T.class_of(ParseError)) }
54
+ def error_class
55
+ @error
56
+ end
57
+
58
+ # The only characters that reach here are `$` and end of input. After a
59
+ # live `$`: `${` opens a nested reference, `$(` a substitution; a `$`
60
+ # that begins neither stays literal (its name is a plain run).
61
+ sig { returns(String) }
62
+ def dollar
63
+ raise @error, 'unterminated ${' unless @scanner.scan('$')
64
+ return "${#{nested}}" if @scanner.scan('{')
65
+ return '$' unless @scanner.scan('(')
66
+
67
+ substitution
68
+ end
69
+
70
+ sig { returns(String) }
71
+ def nested
72
+ BracedReader.new(@scanner, error: @error, quoted: @quoted).read
73
+ end
74
+
75
+ # `$((` is arithmetic, a lone `$(` is command substitution (as
76
+ # DollarScanner disambiguates).
77
+ sig { returns(String) }
78
+ def substitution
79
+ reader = SubstitutionReader.new(@scanner)
80
+ return "$(#{reader.parens})" unless @scanner.scan('(')
81
+
82
+ "$((#{reader.arithmetic}))"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,114 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # One open `case` construct inside a $( ... ) body: the paren depth it
7
+ # opened at, its position in the grammar — :case_word (subject expected),
8
+ # :await_in (the `in` word expected), :pattern, :body — and whether the
9
+ # current pattern opened with the optional `(` group, whose balanced
10
+ # close also enters the item's body.
11
+ class CaseFrame
12
+ extend T::Sig
13
+
14
+ # The per-state handler for a word before the construct's first body.
15
+ FRAME_WORDS = { case_word: :subject_word, await_in: :in_word, pattern: :pattern_word }.freeze
16
+ # Only the word `in` moves a subject to its first pattern.
17
+ AFTER_SUBJECT = { 'in' => :pattern }.freeze
18
+ # `esac` in pattern position asks the tracker to pop the construct.
19
+ POPS = { 'esac' => :pop }.freeze
20
+
21
+ sig { returns(Symbol) }
22
+ attr_reader :state
23
+
24
+ sig { params(base: Integer).void }
25
+ def initialize(base)
26
+ @base = base
27
+ @state = T.let(:case_word, Symbol)
28
+ @marked = T.let(false, T::Boolean)
29
+ @grouped = T.let(false, T::Boolean)
30
+ end
31
+
32
+ sig { params(depth: Integer).returns(T::Boolean) }
33
+ def at?(depth)
34
+ @base == depth
35
+ end
36
+
37
+ # The directive (:pop or :none) for a word seen before the body state.
38
+ sig { params(text: String).returns(Symbol) }
39
+ def word(text)
40
+ send(FRAME_WORDS.fetch(@state), text)
41
+ end
42
+
43
+ sig { void }
44
+ def body
45
+ @state = :body
46
+ end
47
+
48
+ # A `(` before any pattern word opens the grouped `(pattern)` form.
49
+ sig { params(depth: Integer).void }
50
+ def group(depth)
51
+ @grouped = true if pattern_start?(depth)
52
+ end
53
+
54
+ sig { params(depth: Integer).returns(T::Boolean) }
55
+ def pattern?(depth)
56
+ @state == :pattern && at?(depth)
57
+ end
58
+
59
+ # A pattern that already has words: a newline here is dash's parse error.
60
+ sig { params(depth: Integer).returns(T::Boolean) }
61
+ def marked_pattern?(depth)
62
+ @marked && pattern?(depth)
63
+ end
64
+
65
+ sig { params(depth: Integer).returns(T::Boolean) }
66
+ def grouped?(depth)
67
+ @grouped && pattern?(depth)
68
+ end
69
+
70
+ # `;;` reopens pattern position for the next case item.
71
+ sig { params(depth: Integer).void }
72
+ def reopen(depth)
73
+ return unless body_at?(depth)
74
+
75
+ @state = :pattern
76
+ @marked = false
77
+ @grouped = false
78
+ end
79
+
80
+ private
81
+
82
+ # The word after `case` is its subject, whatever it spells.
83
+ sig { params(_text: String).returns(Symbol) }
84
+ def subject_word(_text)
85
+ @state = :await_in
86
+ :none
87
+ end
88
+
89
+ sig { params(text: String).returns(Symbol) }
90
+ def in_word(text)
91
+ @state = AFTER_SUBJECT.fetch(text, :await_in)
92
+ :none
93
+ end
94
+
95
+ # A pattern word was seen: a following `(` is no longer the group form.
96
+ sig { params(text: String).returns(Symbol) }
97
+ def pattern_word(text)
98
+ action = POPS.fetch(text, :none)
99
+ @marked = true unless action == :pop
100
+ action
101
+ end
102
+
103
+ sig { params(depth: Integer).returns(T::Boolean) }
104
+ def body_at?(depth)
105
+ @state == :body && at?(depth)
106
+ end
107
+
108
+ sig { params(depth: Integer).returns(T::Boolean) }
109
+ def pattern_start?(depth)
110
+ pattern?(depth) && !@marked
111
+ end
112
+ end
113
+ end
114
+ end