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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/exe/rush +10 -0
- data/grammar/shell.y +272 -0
- data/grammar/shell.y.output +2354 -0
- data/lib/rush/alias_table.rb +48 -0
- data/lib/rush/assignments.rb +48 -0
- data/lib/rush/ast/and_or.rb +37 -0
- data/lib/rush/ast/assignment.rb +20 -0
- data/lib/rush/ast/brace_group.rb +25 -0
- data/lib/rush/ast/case_node.rb +41 -0
- data/lib/rush/ast/condition_loop.rb +34 -0
- data/lib/rush/ast/for_node.rb +34 -0
- data/lib/rush/ast/function_def.rb +26 -0
- data/lib/rush/ast/if_node.rb +29 -0
- data/lib/rush/ast/list.rb +47 -0
- data/lib/rush/ast/node.rb +17 -0
- data/lib/rush/ast/param_ref.rb +59 -0
- data/lib/rush/ast/pipeline.rb +45 -0
- data/lib/rush/ast/redirect.rb +21 -0
- data/lib/rush/ast/redirected.rb +26 -0
- data/lib/rush/ast/simple_command.rb +62 -0
- data/lib/rush/ast/subshell.rb +24 -0
- data/lib/rush/ast/until_node.rb +18 -0
- data/lib/rush/ast/while_node.rb +18 -0
- data/lib/rush/ast/word.rb +44 -0
- data/lib/rush/ast/word_segment.rb +203 -0
- data/lib/rush/background_runner.rb +68 -0
- data/lib/rush/bracket_expression.rb +107 -0
- data/lib/rush/bracket_scanner.rb +64 -0
- data/lib/rush/builtins/alias_.rb +74 -0
- data/lib/rush/builtins/base.rb +96 -0
- data/lib/rush/builtins/break_.rb +19 -0
- data/lib/rush/builtins/cd.rb +127 -0
- data/lib/rush/builtins/colon.rb +16 -0
- data/lib/rush/builtins/command.rb +137 -0
- data/lib/rush/builtins/command_options.rb +103 -0
- data/lib/rush/builtins/continue_.rb +19 -0
- data/lib/rush/builtins/declare.rb +27 -0
- data/lib/rush/builtins/defaults.rb +28 -0
- data/lib/rush/builtins/dot.rb +52 -0
- data/lib/rush/builtins/echo.rb +37 -0
- data/lib/rush/builtins/eval.rb +25 -0
- data/lib/rush/builtins/exec.rb +54 -0
- data/lib/rush/builtins/exit.rb +45 -0
- data/lib/rush/builtins/export.rb +20 -0
- data/lib/rush/builtins/false_.rb +16 -0
- data/lib/rush/builtins/fd_operand.rb +87 -0
- data/lib/rush/builtins/getopts.rb +96 -0
- data/lib/rush/builtins/hash.rb +64 -0
- data/lib/rush/builtins/job_resume.rb +107 -0
- data/lib/rush/builtins/jobs.rb +86 -0
- data/lib/rush/builtins/kill.rb +152 -0
- data/lib/rush/builtins/local.rb +40 -0
- data/lib/rush/builtins/loop_jump.rb +45 -0
- data/lib/rush/builtins/printf.rb +39 -0
- data/lib/rush/builtins/printf_formatter.rb +111 -0
- data/lib/rush/builtins/pwd.rb +17 -0
- data/lib/rush/builtins/read.rb +93 -0
- data/lib/rush/builtins/read_input.rb +146 -0
- data/lib/rush/builtins/readonly.rb +20 -0
- data/lib/rush/builtins/registry.rb +36 -0
- data/lib/rush/builtins/return_.rb +24 -0
- data/lib/rush/builtins/set.rb +101 -0
- data/lib/rush/builtins/shift.rb +35 -0
- data/lib/rush/builtins/test_.rb +48 -0
- data/lib/rush/builtins/test_context.rb +24 -0
- data/lib/rush/builtins/test_expr.rb +74 -0
- data/lib/rush/builtins/test_grammar.rb +117 -0
- data/lib/rush/builtins/test_operators.rb +94 -0
- data/lib/rush/builtins/test_tokens.rb +69 -0
- data/lib/rush/builtins/times.rb +35 -0
- data/lib/rush/builtins/trap.rb +85 -0
- data/lib/rush/builtins/true_.rb +16 -0
- data/lib/rush/builtins/type_.rb +28 -0
- data/lib/rush/builtins/ulimit.rb +258 -0
- data/lib/rush/builtins/umask.rb +81 -0
- data/lib/rush/builtins/unalias.rb +46 -0
- data/lib/rush/builtins/unset.rb +40 -0
- data/lib/rush/builtins/wait.rb +127 -0
- data/lib/rush/cli.rb +31 -0
- data/lib/rush/command_assignments.rb +56 -0
- data/lib/rush/command_lookup.rb +228 -0
- data/lib/rush/command_resolution.rb +115 -0
- data/lib/rush/command_runner.rb +152 -0
- data/lib/rush/command_text.rb +140 -0
- data/lib/rush/environment.rb +134 -0
- data/lib/rush/errexit_context.rb +68 -0
- data/lib/rush/error_policy.rb +64 -0
- data/lib/rush/errors.rb +100 -0
- data/lib/rush/escape_table.rb +22 -0
- data/lib/rush/escaped_bracket.rb +49 -0
- data/lib/rush/executor.rb +164 -0
- data/lib/rush/exit_trap.rb +109 -0
- data/lib/rush/expansion/arithmetic/evaluator.rb +43 -0
- data/lib/rush/expansion/arithmetic/nodes.rb +180 -0
- data/lib/rush/expansion/arithmetic/number.rb +104 -0
- data/lib/rush/expansion/arithmetic/parser.rb +155 -0
- data/lib/rush/expansion/arithmetic/tokenizer.rb +56 -0
- data/lib/rush/expansion/arithmetic_expander.rb +38 -0
- data/lib/rush/expansion/command_substitution.rb +87 -0
- data/lib/rush/expansion/field_part.rb +12 -0
- data/lib/rush/expansion/field_splitter.rb +23 -0
- data/lib/rush/expansion/glob_expander.rb +48 -0
- data/lib/rush/expansion/ifs.rb +62 -0
- data/lib/rush/expansion/ifs_scanner.rb +168 -0
- data/lib/rush/expansion/parameter_expander.rb +204 -0
- data/lib/rush/expansion/parameter_forms.rb +22 -0
- data/lib/rush/expansion/pattern_removal.rb +63 -0
- data/lib/rush/expansion/pipeline.rb +124 -0
- data/lib/rush/expansion/read_char.rb +13 -0
- data/lib/rush/expansion/read_field_scanner.rb +159 -0
- data/lib/rush/expansion/read_splitter.rb +40 -0
- data/lib/rush/expansion/resolver.rb +23 -0
- data/lib/rush/expansion/tilde_expander.rb +102 -0
- data/lib/rush/external.rb +75 -0
- data/lib/rush/fd_entry.rb +60 -0
- data/lib/rush/for_runner.rb +50 -0
- data/lib/rush/function_frame.rb +30 -0
- data/lib/rush/function_runner.rb +37 -0
- data/lib/rush/function_table.rb +35 -0
- data/lib/rush/getopts_parser.rb +195 -0
- data/lib/rush/getopts_state.rb +104 -0
- data/lib/rush/here_doc.rb +57 -0
- data/lib/rush/interactive_signals.rb +26 -0
- data/lib/rush/invocation.rb +190 -0
- data/lib/rush/io_table.rb +94 -0
- data/lib/rush/job_control.rb +197 -0
- data/lib/rush/job_report.rb +45 -0
- data/lib/rush/job_spec.rb +51 -0
- data/lib/rush/job_table/control.rb +103 -0
- data/lib/rush/job_table/interruptible_wait.rb +70 -0
- data/lib/rush/job_table/job.rb +181 -0
- data/lib/rush/job_table.rb +194 -0
- data/lib/rush/lexer/alias_expander.rb +129 -0
- data/lib/rush/lexer/braced_reader.rb +86 -0
- data/lib/rush/lexer/case_frame.rb +114 -0
- data/lib/rush/lexer/case_tracker.rb +146 -0
- data/lib/rush/lexer/dollar_scanner.rb +53 -0
- data/lib/rush/lexer/double_quote_scanner.rb +71 -0
- data/lib/rush/lexer/heredoc_body.rb +91 -0
- data/lib/rush/lexer/heredoc_reader.rb +93 -0
- data/lib/rush/lexer/lex_state.rb +128 -0
- data/lib/rush/lexer/operator_table.rb +32 -0
- data/lib/rush/lexer/param_scanner.rb +43 -0
- data/lib/rush/lexer/paren_reader.rb +113 -0
- data/lib/rush/lexer/paren_regions.rb +74 -0
- data/lib/rush/lexer/quote_skips.rb +77 -0
- data/lib/rush/lexer/quoted_word.rb +67 -0
- data/lib/rush/lexer/scanner_predicates.rb +20 -0
- data/lib/rush/lexer/source_lines.rb +43 -0
- data/lib/rush/lexer/substitution_reader.rb +77 -0
- data/lib/rush/lexer/token_classifier.rb +144 -0
- data/lib/rush/lexer/token_predicates.rb +23 -0
- data/lib/rush/lexer/word_scanner.rb +134 -0
- data/lib/rush/lexer.rb +174 -0
- data/lib/rush/loop_control_handling.rb +29 -0
- data/lib/rush/loop_nesting.rb +50 -0
- data/lib/rush/loop_runner.rb +55 -0
- data/lib/rush/option_cluster.rb +39 -0
- data/lib/rush/options.rb +76 -0
- data/lib/rush/param_text.rb +39 -0
- data/lib/rush/parser.rb +1213 -0
- data/lib/rush/parser_support.rb +112 -0
- data/lib/rush/pattern_scanner.rb +35 -0
- data/lib/rush/pending_signals.rb +57 -0
- data/lib/rush/pipeline_runner.rb +173 -0
- data/lib/rush/pipeline_statuses.rb +45 -0
- data/lib/rush/positional.rb +45 -0
- data/lib/rush/posix_pattern.rb +78 -0
- data/lib/rush/program_input.rb +89 -0
- data/lib/rush/program_reader.rb +69 -0
- data/lib/rush/program_session.rb +83 -0
- data/lib/rush/prompt.rb +52 -0
- data/lib/rush/redirect_scope.rb +64 -0
- data/lib/rush/redirection/dup_redirect.rb +58 -0
- data/lib/rush/redirection/file_redirect.rb +50 -0
- data/lib/rush/redirection/here_doc_redirect.rb +17 -0
- data/lib/rush/redirection/registry.rb +53 -0
- data/lib/rush/repl.rb +133 -0
- data/lib/rush/runtime_type_checks.rb +16 -0
- data/lib/rush/scope.rb +79 -0
- data/lib/rush/segment_buffer.rb +47 -0
- data/lib/rush/shell_parameters.rb +77 -0
- data/lib/rush/shell_pattern.rb +80 -0
- data/lib/rush/shell_state.rb +163 -0
- data/lib/rush/shell_variables.rb +67 -0
- data/lib/rush/signal_report.rb +41 -0
- data/lib/rush/signals.rb +68 -0
- data/lib/rush/source.rb +82 -0
- data/lib/rush/source_line_counter.rb +41 -0
- data/lib/rush/source_runner.rb +47 -0
- data/lib/rush/startup.rb +75 -0
- data/lib/rush/status.rb +93 -0
- data/lib/rush/stop_relay.rb +32 -0
- data/lib/rush/subshell_runner.rb +70 -0
- data/lib/rush/system_calls/collation.rb +155 -0
- data/lib/rush/system_calls/file_tests.rb +95 -0
- data/lib/rush/system_calls/process_control.rb +165 -0
- data/lib/rush/system_calls/process_identity.rb +43 -0
- data/lib/rush/system_calls/resource_limits.rb +51 -0
- data/lib/rush/system_calls.rb +206 -0
- data/lib/rush/terminal.rb +126 -0
- data/lib/rush/trap_runner.rb +169 -0
- data/lib/rush/trap_table.rb +49 -0
- data/lib/rush/umask_mode.rb +133 -0
- data/lib/rush/version.rb +6 -0
- data/lib/rush.rb +190 -0
- 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
|