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,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
@@ -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