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,134 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Shell variables and the subset marked for export. The exported slice is what
|
|
6
|
+
# external children receive (see SystemCalls#spawn).
|
|
7
|
+
class Environment
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
VariableState = T.type_alias do
|
|
11
|
+
[T::Boolean, T.nilable(String), T::Boolean, T::Boolean]
|
|
12
|
+
end
|
|
13
|
+
DynamicState = T.type_alias { T.any(T::Boolean, Symbol) }
|
|
14
|
+
Snapshot = T.type_alias { [T::Hash[String, VariableState], DynamicState] }
|
|
15
|
+
|
|
16
|
+
sig { params(source: T::Hash[String, String]).void }
|
|
17
|
+
def initialize(source = ENV.to_h)
|
|
18
|
+
@vars = source.dup
|
|
19
|
+
@exported = source.keys.to_set
|
|
20
|
+
@readonly = Set.new
|
|
21
|
+
@dynamic_lineno = !@vars.key?('LINENO')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
sig { params(name: String).returns(T.nilable(String)) }
|
|
25
|
+
def get(name)
|
|
26
|
+
@vars.fetch(name, nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { params(name: String, value: String).returns(String) }
|
|
30
|
+
def assign(name, value)
|
|
31
|
+
validate_assignment(name)
|
|
32
|
+
@dynamic_lineno &&= name != 'LINENO'
|
|
33
|
+
@vars[name] = value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(name: String).void }
|
|
37
|
+
def validate_assignment(name)
|
|
38
|
+
raise ReadonlyError, "#{name}: is read only" if @readonly.include?(name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(name: String).void }
|
|
42
|
+
def export(name)
|
|
43
|
+
@exported.add(name)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { params(name: String).void }
|
|
47
|
+
def readonly(name)
|
|
48
|
+
@readonly.add(name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { params(name: String).void }
|
|
52
|
+
def unset(name)
|
|
53
|
+
validate_assignment(name)
|
|
54
|
+
@dynamic_lineno &&= name != 'LINENO'
|
|
55
|
+
@vars.delete(name)
|
|
56
|
+
@exported.delete(name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { params(line: Integer).returns(T.nilable(String)) }
|
|
60
|
+
def update_lineno(line)
|
|
61
|
+
@vars['LINENO'] = line.to_s if @dynamic_lineno
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sig { returns(T::Hash[String, String]) }
|
|
65
|
+
def exported
|
|
66
|
+
# *@exported.to_a: splat needs an Array (Set splats via to_a at runtime, but
|
|
67
|
+
# the checker won't); slice keeps @vars's own order, so this is unchanged.
|
|
68
|
+
@vars.slice(*@exported.to_a)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Prefix assignments on a regular builtin are visible/exported only for the
|
|
72
|
+
# invocation. Writes to those same names stay temporary; changes to every
|
|
73
|
+
# other shell variable remain live, matching a builtin's in-process effects.
|
|
74
|
+
sig do
|
|
75
|
+
params(values: T::Hash[String, String], blk: T.proc.returns(T.untyped)).returns(T.untyped)
|
|
76
|
+
end
|
|
77
|
+
def with_temporary(values, &blk)
|
|
78
|
+
saved = snapshot(values)
|
|
79
|
+
restore_after(saved) do
|
|
80
|
+
apply_temporary(values)
|
|
81
|
+
yield
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
sig { params(saved: Snapshot, blk: T.proc.returns(T.untyped)).returns(T.untyped) }
|
|
88
|
+
def restore_after(saved, &blk)
|
|
89
|
+
yield
|
|
90
|
+
ensure
|
|
91
|
+
restore_snapshot(saved)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sig { params(values: T::Hash[String, String]).returns(Snapshot) }
|
|
95
|
+
def snapshot(values)
|
|
96
|
+
states = values.keys.to_h { |name| [name, variable_state(name)] }
|
|
97
|
+
dynamic_lineno = values.key?('LINENO') ? @dynamic_lineno : :keep #: dynamic_state
|
|
98
|
+
[states, dynamic_lineno]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
sig { params(name: String).returns(VariableState) }
|
|
102
|
+
def variable_state(name)
|
|
103
|
+
[@vars.key?(name), @vars.fetch(name, nil), @exported.include?(name), @readonly.include?(name)]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
sig { params(values: T::Hash[String, String]).void }
|
|
107
|
+
def apply_temporary(values)
|
|
108
|
+
values.each { |name, value| assign(name, value).tap { export(name) } }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
sig { params(saved: Snapshot).void }
|
|
112
|
+
def restore_snapshot(saved)
|
|
113
|
+
states, dynamic_lineno = saved
|
|
114
|
+
states.each { |name, state| restore_variable(name, state) }
|
|
115
|
+
restore_lineno(dynamic_lineno)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
sig { params(dynamic_lineno: DynamicState).void }
|
|
119
|
+
def restore_lineno(dynamic_lineno)
|
|
120
|
+
return if dynamic_lineno == :keep
|
|
121
|
+
|
|
122
|
+
value = T.cast(dynamic_lineno, T::Boolean)
|
|
123
|
+
@dynamic_lineno = value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
sig { params(name: String, state: VariableState).void }
|
|
127
|
+
def restore_variable(name, state)
|
|
128
|
+
present, value, exported, readonly = state
|
|
129
|
+
present ? @vars[name] = T.must(value) : @vars.delete(name)
|
|
130
|
+
exported ? @exported.add(name) : @exported.delete(name)
|
|
131
|
+
readonly ? @readonly.add(name) : @readonly.delete(name)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Tracks whether a command is executing in a POSIX "tested" context where
|
|
6
|
+
# errexit is suppressed, and performs the leaf `set -e` abort check.
|
|
7
|
+
class ErrexitContext
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { params(state: ShellState).void }
|
|
11
|
+
def initialize(state)
|
|
12
|
+
@state = state
|
|
13
|
+
@tested = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Run the block in a "tested" context (errexit suppressed): the condition of
|
|
17
|
+
# if/while/until, the non-final part of an && / || list, a negated pipeline,
|
|
18
|
+
# and an async (&) command. Restores on exit, so the flag follows the call tree.
|
|
19
|
+
sig do
|
|
20
|
+
type_parameters(:U)
|
|
21
|
+
.params(blk: T.proc.returns(T.type_parameter(:U)))
|
|
22
|
+
.returns(T.type_parameter(:U))
|
|
23
|
+
end
|
|
24
|
+
def tested(&blk)
|
|
25
|
+
scoped(true, &blk)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The inverse: a fresh untested context regardless of the caller's, the way
|
|
29
|
+
# command substitution starts one.
|
|
30
|
+
sig do
|
|
31
|
+
type_parameters(:U)
|
|
32
|
+
.params(blk: T.proc.returns(T.type_parameter(:U)))
|
|
33
|
+
.returns(T.type_parameter(:U))
|
|
34
|
+
end
|
|
35
|
+
def untested(&blk)
|
|
36
|
+
scoped(false, &blk)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The errexit leaf check (POSIX 2.8.1): under `set -e`, a command failing
|
|
40
|
+
# outside a tested context aborts the shell with that status.
|
|
41
|
+
sig { params(status: Status).returns(Status) }
|
|
42
|
+
def exit_on_error(status)
|
|
43
|
+
raise ExitSignal, status.exitstatus if abort_on?(status)
|
|
44
|
+
|
|
45
|
+
status
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
sig do
|
|
51
|
+
type_parameters(:U)
|
|
52
|
+
.params(value: T::Boolean, blk: T.proc.returns(T.type_parameter(:U)))
|
|
53
|
+
.returns(T.type_parameter(:U))
|
|
54
|
+
end
|
|
55
|
+
def scoped(value, &blk)
|
|
56
|
+
previous = @tested
|
|
57
|
+
@tested = value
|
|
58
|
+
yield
|
|
59
|
+
ensure
|
|
60
|
+
@tested = previous
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig { params(status: Status).returns(T::Boolean) }
|
|
64
|
+
def abort_on?(status)
|
|
65
|
+
!!(@state.options.on?(:errexit) && !@tested && !status.success?)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Pure classification table for Rush::Error at runtime boundaries. The table
|
|
6
|
+
# deliberately owns no reporting, status mutation, I/O, or EXIT lifecycle;
|
|
7
|
+
# each boundary applies the decision in its own environment.
|
|
8
|
+
class ErrorPolicy
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
CONTEXTS = %i[batch interactive subshell command signal_trap exit_trap].freeze
|
|
12
|
+
DIAGNOSTIC_DECISIONS = %i[abort2 recover2 demote2].freeze
|
|
13
|
+
|
|
14
|
+
OPERATIONAL = {
|
|
15
|
+
batch: :abort2, interactive: :recover2, subshell: :abort2,
|
|
16
|
+
command: :demote2, signal_trap: :ignore, exit_trap: :abort2
|
|
17
|
+
}.freeze
|
|
18
|
+
OWNED = {
|
|
19
|
+
batch: :propagate, interactive: :propagate, subshell: :abort2,
|
|
20
|
+
command: :propagate, signal_trap: :propagate, exit_trap: :propagate
|
|
21
|
+
}.freeze
|
|
22
|
+
INTERRUPT = {
|
|
23
|
+
batch: :interrupt130, interactive: :recover130, subshell: :abort2,
|
|
24
|
+
command: :propagate, signal_trap: :propagate, exit_trap: :propagate
|
|
25
|
+
}.freeze
|
|
26
|
+
EXIT = {
|
|
27
|
+
batch: :propagate, interactive: :propagate, subshell: :return_code,
|
|
28
|
+
command: :propagate, signal_trap: :propagate, exit_trap: :override_code
|
|
29
|
+
}.freeze
|
|
30
|
+
RETURN = {
|
|
31
|
+
batch: :propagate, interactive: :ignore, subshell: :return_code,
|
|
32
|
+
command: :propagate, signal_trap: :ignore, exit_trap: :preserve_code
|
|
33
|
+
}.freeze
|
|
34
|
+
LOOP = {
|
|
35
|
+
batch: :propagate, interactive: :propagate, subshell: :last_status,
|
|
36
|
+
command: :propagate, signal_trap: :ignore, exit_trap: :preserve_code
|
|
37
|
+
}.freeze
|
|
38
|
+
BUILTIN = OPERATIONAL.merge(signal_trap: :propagate).freeze
|
|
39
|
+
|
|
40
|
+
MATRIX = {
|
|
41
|
+
Error => OWNED,
|
|
42
|
+
ParseError => OPERATIONAL,
|
|
43
|
+
IncompleteInput => OPERATIONAL,
|
|
44
|
+
ExpansionError => OPERATIONAL,
|
|
45
|
+
InvocationError => OWNED,
|
|
46
|
+
Interrupted => INTERRUPT,
|
|
47
|
+
TestError => OWNED,
|
|
48
|
+
ReadonlyError => OPERATIONAL,
|
|
49
|
+
BuiltinError => BUILTIN,
|
|
50
|
+
RedirectError => OWNED,
|
|
51
|
+
JobError => OWNED,
|
|
52
|
+
ExitSignal => EXIT,
|
|
53
|
+
LoopControl => LOOP,
|
|
54
|
+
BreakSignal => LOOP,
|
|
55
|
+
ContinueSignal => LOOP,
|
|
56
|
+
ReturnSignal => RETURN
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
sig { params(context: Symbol, error: Error).returns(Symbol) }
|
|
60
|
+
def self.decision(context, error)
|
|
61
|
+
MATRIX.fetch(error.class).fetch(context)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/rush/errors.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Base class for every error rush raises.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised by the parser on a syntax error (carries a human-readable location).
|
|
9
|
+
class ParseError < Error; end
|
|
10
|
+
|
|
11
|
+
# Parsing hit end of input mid-construct (an unfinished quote, compound
|
|
12
|
+
# command or here-document). A ParseError subclass, so batch callers still
|
|
13
|
+
# treat it as a syntax error; the REPL catches it to read another line.
|
|
14
|
+
class IncompleteInput < ParseError; end
|
|
15
|
+
|
|
16
|
+
# Raised during word expansion (e.g. ${x:?msg}, bad substitution).
|
|
17
|
+
class ExpansionError < Error; end
|
|
18
|
+
|
|
19
|
+
# A malformed command line at shell startup (unknown option, a missing -c/-o
|
|
20
|
+
# argument, an unreadable script file): reported on stderr and the process
|
|
21
|
+
# exits with status 2, matching dash.
|
|
22
|
+
class InvocationError < Error; end
|
|
23
|
+
|
|
24
|
+
# SIGINT in an interactive shell: the installed handler raises this at the
|
|
25
|
+
# next safe point, unwinding the current line back to the session, which
|
|
26
|
+
# publishes 130 (128+SIGINT) as $? and re-prompts — dash behaviour. Batch
|
|
27
|
+
# shells never install the handler and die with the OS default.
|
|
28
|
+
class Interrupted < Error; end
|
|
29
|
+
|
|
30
|
+
# Raised by the `test`/`[` builtin on a malformed expression (mapped to exit 2).
|
|
31
|
+
class TestError < Error; end
|
|
32
|
+
|
|
33
|
+
# Raised when assigning to or unsetting a readonly variable; like dash, this
|
|
34
|
+
# aborts the script (or just the subshell) with exit status 2.
|
|
35
|
+
class ReadonlyError < Error; end
|
|
36
|
+
|
|
37
|
+
# A special builtin used incorrectly (e.g. a non-numeric operand to
|
|
38
|
+
# exit/return). POSIX 2.8.1: such an error aborts a non-interactive shell with
|
|
39
|
+
# status 2; interactively it is reported and the shell carries on.
|
|
40
|
+
class BuiltinError < Error; end
|
|
41
|
+
|
|
42
|
+
# A redirection that fails at runtime (e.g. `n>&m` duplicating a fd that is not
|
|
43
|
+
# open): the command is not run and fails with status 2, but — unlike a
|
|
44
|
+
# special-builtin error — the shell carries on.
|
|
45
|
+
class RedirectError < Error; end
|
|
46
|
+
|
|
47
|
+
# A %job_id operand that resolves to no job (No such job / No current job /
|
|
48
|
+
# No previous job, dash's messages). The consuming builtin — jobs, wait,
|
|
49
|
+
# kill, fg, bg — prefixes its name, reports on stderr and fails with 2; the
|
|
50
|
+
# shell carries on.
|
|
51
|
+
class JobError < Error; end
|
|
52
|
+
|
|
53
|
+
# Control-flow signal: `exit` unwinds to the top level carrying a status code.
|
|
54
|
+
class ExitSignal < Error
|
|
55
|
+
extend T::Sig
|
|
56
|
+
|
|
57
|
+
sig { returns(Integer) }
|
|
58
|
+
attr_reader :code
|
|
59
|
+
|
|
60
|
+
sig { params(code: Integer).void }
|
|
61
|
+
def initialize(code)
|
|
62
|
+
@code = code
|
|
63
|
+
super("exit #{code}")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Loop control: `break`/`continue` unwind to the enclosing loop, carrying the
|
|
68
|
+
# number of loop levels to act on.
|
|
69
|
+
class LoopControl < Error
|
|
70
|
+
extend T::Sig
|
|
71
|
+
|
|
72
|
+
sig { returns(Integer) }
|
|
73
|
+
attr_reader :count
|
|
74
|
+
|
|
75
|
+
sig { params(count: Integer).void }
|
|
76
|
+
def initialize(count)
|
|
77
|
+
@count = count
|
|
78
|
+
super('loop control')
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `break N`: unwind out of N enclosing loop levels.
|
|
83
|
+
class BreakSignal < LoopControl; end
|
|
84
|
+
# `continue N`: resume the Nth enclosing loop's next iteration.
|
|
85
|
+
class ContinueSignal < LoopControl; end
|
|
86
|
+
|
|
87
|
+
# Control-flow signal: `return` unwinds to the enclosing function call.
|
|
88
|
+
class ReturnSignal < Error
|
|
89
|
+
extend T::Sig
|
|
90
|
+
|
|
91
|
+
sig { returns(Integer) }
|
|
92
|
+
attr_reader :code
|
|
93
|
+
|
|
94
|
+
sig { params(code: Integer).void }
|
|
95
|
+
def initialize(code)
|
|
96
|
+
@code = code
|
|
97
|
+
super("return #{code}")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Renders backslash escapes from a small table. Missing trailing characters
|
|
6
|
+
# become a literal backslash; unknown escapes keep the backslash plus character.
|
|
7
|
+
class EscapeTable
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { params(table: T::Hash[String, String]).void }
|
|
11
|
+
def initialize(table)
|
|
12
|
+
@table = table
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
sig { params(char: T.nilable(String)).returns(String) }
|
|
16
|
+
def escape(char)
|
|
17
|
+
return '\\' unless char
|
|
18
|
+
|
|
19
|
+
@table.fetch(char) { "\\#{char}" }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Converts shell-backslash-quoted bracket members to positions where a POSIX
|
|
6
|
+
# ERE bracket treats them literally. Kept separate from PosixPattern because
|
|
7
|
+
# this is a small stateful bracket normalization, not scanner policy.
|
|
8
|
+
class EscapedBracket
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
SPECIAL = [']', '-', '^', '['].freeze
|
|
12
|
+
|
|
13
|
+
sig { params(source: String).void }
|
|
14
|
+
def initialize(source)
|
|
15
|
+
@negated = source.start_with?('[^')
|
|
16
|
+
@body = T.must(source[(@negated ? 2 : 1)...-1])
|
|
17
|
+
@literals = T.let(
|
|
18
|
+
{}, #: Hash[String, String]
|
|
19
|
+
T::Hash[String, String]
|
|
20
|
+
)
|
|
21
|
+
SPECIAL.each { |char| extract(char) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
sig { returns(String) }
|
|
25
|
+
def source
|
|
26
|
+
body = @body.gsub(/\\(.)/m, '\\1')
|
|
27
|
+
"#{prefix}#{literal(']')}#{body}#{literal('^')}#{literal('[')}#{literal('-')}]"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
sig { returns(String) }
|
|
33
|
+
def prefix
|
|
34
|
+
@negated ? '[^' : '['
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
sig { params(char: String).returns(String) }
|
|
38
|
+
def literal(char)
|
|
39
|
+
@literals.fetch(char, '')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { params(char: String).void }
|
|
43
|
+
def extract(char)
|
|
44
|
+
escaped = "\\#{char}"
|
|
45
|
+
@literals[char] = char if @body.include?(escaped)
|
|
46
|
+
@body = @body.gsub(escaped, '')
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Walks the AST by polymorphic dispatch (node.execute(self)) over shared shell
|
|
6
|
+
# state, with all OS access funneled through the injected SystemCalls port. The
|
|
7
|
+
# base IoTable, builtin registry, redirection registry and expander hang off it;
|
|
8
|
+
# redirect cleanup, errexit state, signal and trap handling live in collaborators.
|
|
9
|
+
class Executor
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { returns(SystemCalls) }
|
|
13
|
+
attr_reader :system
|
|
14
|
+
|
|
15
|
+
sig { returns(ShellState) }
|
|
16
|
+
attr_reader :state
|
|
17
|
+
|
|
18
|
+
sig { returns(Builtins::Registry) }
|
|
19
|
+
attr_reader :builtins
|
|
20
|
+
|
|
21
|
+
sig { returns(Redirection::Registry) }
|
|
22
|
+
attr_reader :redirections
|
|
23
|
+
|
|
24
|
+
sig { returns(Expansion::Pipeline) }
|
|
25
|
+
attr_reader :expander
|
|
26
|
+
|
|
27
|
+
sig { returns(IoTable) }
|
|
28
|
+
attr_reader :io
|
|
29
|
+
|
|
30
|
+
sig { returns(Status) }
|
|
31
|
+
attr_reader :cmd_sub_status
|
|
32
|
+
|
|
33
|
+
sig { returns(TrapRunner) }
|
|
34
|
+
attr_reader :trap_runner
|
|
35
|
+
|
|
36
|
+
sig { returns(JobTable) }
|
|
37
|
+
attr_reader :jobs
|
|
38
|
+
|
|
39
|
+
sig { returns(RedirectScope) }
|
|
40
|
+
attr_reader :redirect_scope
|
|
41
|
+
|
|
42
|
+
sig { returns(ErrexitContext) }
|
|
43
|
+
attr_reader :errexit
|
|
44
|
+
|
|
45
|
+
sig { params(system: SystemCalls, state: ShellState, builtins: Builtins::Registry).void }
|
|
46
|
+
def initialize(system:, state:, builtins: Builtins.default_registry)
|
|
47
|
+
@system = system
|
|
48
|
+
@state = state
|
|
49
|
+
@builtins = builtins
|
|
50
|
+
@redirections = Redirection.default_registry(@state.options)
|
|
51
|
+
@expander = Expansion::Pipeline.new(self)
|
|
52
|
+
@io = IoTable.standard(@system)
|
|
53
|
+
@redirect_scope = RedirectScope.new(self)
|
|
54
|
+
@errexit = ErrexitContext.new(@state)
|
|
55
|
+
@trap_runner = TrapRunner.new(self)
|
|
56
|
+
@jobs = JobTable.new(@system)
|
|
57
|
+
@state.variables.seed_pwd(@system.pwd)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The job-control policy over this executor: a stateless view, so it is
|
|
61
|
+
# built on demand — the one durable bit (root shell or forked child) lives
|
|
62
|
+
# with the job table, which already tracks subshell entry.
|
|
63
|
+
sig { returns(JobControl) }
|
|
64
|
+
def job_control
|
|
65
|
+
JobControl.new(self)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A redirect that fails at runtime (n>&m to a fd that is not open) leaves the
|
|
69
|
+
# command unrun with status 2; the shell carries on (RedirectError). Noexec
|
|
70
|
+
# still parses every program but suppresses its execution in batch shells;
|
|
71
|
+
# POSIX permits an interactive shell to ignore -n, which rush does.
|
|
72
|
+
sig { params(node: AST::Node).returns(Status) }
|
|
73
|
+
def run(node)
|
|
74
|
+
return @state.last_status if noexec?
|
|
75
|
+
|
|
76
|
+
@trap_runner.run_pending
|
|
77
|
+
@trap_runner.complete(node.execute(self))
|
|
78
|
+
rescue RedirectError
|
|
79
|
+
@trap_runner.complete(Status.new(2))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig { returns(T::Boolean) }
|
|
83
|
+
def noexec?
|
|
84
|
+
@state.options.on?(:noexec) && !@state.options.on?(:interactive)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
sig { params(node: AST::Node).returns(Status) }
|
|
88
|
+
def run_async(node)
|
|
89
|
+
return @state.last_status if noexec?
|
|
90
|
+
|
|
91
|
+
@state.record_status(BackgroundRunner.new(self, node).call)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Entering a forked child environment (subshell, pipeline stage, async
|
|
95
|
+
# list, command substitution): caught traps reset (POSIX 2.11), and the
|
|
96
|
+
# job table becomes a display copy — the duplicate environment keeps the
|
|
97
|
+
# async-pid knowledge for jobs/jobs -p (POSIX 2.12, the seam that makes
|
|
98
|
+
# `wait $(jobs -p)` work in the parent), while the parent's jobs are not
|
|
99
|
+
# this environment's children: wait on them reports 127, a %id reports
|
|
100
|
+
# No such job, and $! itself survives — which also switches job-control
|
|
101
|
+
# machinery off (dash: root shell only).
|
|
102
|
+
sig { void }
|
|
103
|
+
def enter_subshell
|
|
104
|
+
@trap_runner.reset_caught_for_subshell
|
|
105
|
+
@jobs.enter_subshell
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Permanently rebind the base IoTable (the `exec` redirection-only form),
|
|
109
|
+
# unlike with_io which restores afterwards.
|
|
110
|
+
sig { params(table: IoTable).void }
|
|
111
|
+
def replace_io(table)
|
|
112
|
+
@io = table
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sig { params(command: AST::SimpleCommand).returns(Status) }
|
|
116
|
+
def run_simple(command)
|
|
117
|
+
CommandRunner.new(self, command).call
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Run a compound command with its redirects bound for the whole body.
|
|
121
|
+
sig { params(command: AST::Node, redirects: T::Array[AST::Redirect]).returns(Status) }
|
|
122
|
+
def run_redirected(command, redirects)
|
|
123
|
+
@redirect_scope.with_redirects(redirects) { |io| with_io(io) { run(command) } }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The exit status of the last command substitution performed while a simple
|
|
127
|
+
# command is being built. Reset to success at the start of each command so a
|
|
128
|
+
# no-command-word command (only assignments/redirections) reports 0 unless a
|
|
129
|
+
# substitution sets it (POSIX 2.9.1: such a command takes the status of the
|
|
130
|
+
# last command substitution). Kept off last_status so a later $? in the same
|
|
131
|
+
# command still sees the previous command's status, as dash does.
|
|
132
|
+
sig { void }
|
|
133
|
+
def reset_cmd_sub_status
|
|
134
|
+
record_cmd_sub_status(Status.success)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
sig { params(status: Status).void }
|
|
138
|
+
def record_cmd_sub_status(status)
|
|
139
|
+
@cmd_sub_status = status
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Run a block with a different base IoTable (command substitution / future
|
|
143
|
+
# `exec`), restoring the previous one afterwards.
|
|
144
|
+
sig do
|
|
145
|
+
type_parameters(:U)
|
|
146
|
+
.params(io: IoTable, blk: T.proc.returns(T.type_parameter(:U)))
|
|
147
|
+
.returns(T.type_parameter(:U))
|
|
148
|
+
end
|
|
149
|
+
def with_io(io, &blk)
|
|
150
|
+
previous = @io
|
|
151
|
+
@io = io
|
|
152
|
+
yield
|
|
153
|
+
ensure
|
|
154
|
+
@io = previous
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Evaluate an if/while/until condition: run the command in a tested context
|
|
158
|
+
# (so a failing condition never trips errexit) and report whether it succeeded.
|
|
159
|
+
sig { params(command: AST::Node).returns(T::Boolean) }
|
|
160
|
+
def succeeds?(command)
|
|
161
|
+
@errexit.tested { run(command) }.success?
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|