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,228 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Resolves a command name for `type` and `command -v`: a reserved word, a
6
+ # function, a special or regular builtin, or an executable file found by
7
+ # searching PATH (or used directly when the name contains a slash). #find runs
8
+ # an ordered list of resolvers and returns the first Match (Unknown if none).
9
+ # An explicit `path:` replaces $PATH for the file search — `command -p` passes
10
+ # the system default PATH through it.
11
+ class CommandLookup
12
+ extend T::Sig
13
+
14
+ KEYWORDS = %w[! { } case do done elif else esac fi for if in then until while].freeze
15
+ RESOLVERS = %i[as_keyword as_alias as_function as_special as_builtin as_file].freeze
16
+
17
+ # A resolved lookup result: it is "known", and subclasses render the line
18
+ # `type` (#describe) and `command -v` (#terse) print. Only an executable
19
+ # caches its own location; the others leave the cache untouched.
20
+ class Match
21
+ extend T::Sig
22
+
23
+ # name is nilable: `command -v` with no operand resolves the nil name, which
24
+ # flows through #find to Unknown.new(nil) (the Sorbet sig set tolerates this;
25
+ # the RBS keeps the narrower String — the two checkers are independent).
26
+ sig { params(name: T.nilable(String), detail: T.nilable(String)).void }
27
+ def initialize(name, detail = nil)
28
+ @name = name
29
+ @detail = detail
30
+ end
31
+
32
+ sig { returns(T::Boolean) }
33
+ def known?
34
+ true
35
+ end
36
+
37
+ sig { params(_cache: T::Hash[T.untyped, T.untyped]).void }
38
+ def cache_into(_cache)
39
+ nil
40
+ end
41
+
42
+ # describe/terse are the Match protocol #find's result is used through; the
43
+ # subclasses provide them (the base is never instantiated, like AST::Node).
44
+ sig { returns(T.nilable(String)) }
45
+ def describe
46
+ raise NotImplementedError
47
+ end
48
+
49
+ sig { returns(T.nilable(String)) }
50
+ def terse
51
+ raise NotImplementedError
52
+ end
53
+
54
+ private
55
+
56
+ sig { returns(T.nilable(String)) }
57
+ attr_reader :name
58
+
59
+ sig { returns(T.nilable(String)) }
60
+ attr_reader :detail
61
+ end
62
+
63
+ # An unresolved name: not known, and nothing to describe.
64
+ class Unknown < Match
65
+ extend T::Sig
66
+
67
+ sig { returns(T::Boolean) }
68
+ def known?
69
+ false
70
+ end
71
+
72
+ sig { returns(NilClass) }
73
+ def describe
74
+ nil
75
+ end
76
+ end
77
+
78
+ # A keyword / function / special / regular builtin, named by a fixed label.
79
+ class Labelled < Match
80
+ extend T::Sig
81
+
82
+ sig { returns(String) }
83
+ def describe
84
+ "#{name} is #{detail}"
85
+ end
86
+
87
+ sig { returns(String) }
88
+ def terse
89
+ T.must(name)
90
+ end
91
+ end
92
+
93
+ # A shell alias; `command -v` prints its definition.
94
+ class Aliased < Match
95
+ extend T::Sig
96
+
97
+ sig { returns(String) }
98
+ def describe
99
+ "#{name} is an alias for #{detail}"
100
+ end
101
+
102
+ sig { returns(String) }
103
+ def terse
104
+ "alias '#{"#{name}=#{detail}".gsub("'", %q('"'"'))}'"
105
+ end
106
+ end
107
+
108
+ # An external executable found on PATH (or used directly via a slash path).
109
+ class Executable < Match
110
+ extend T::Sig
111
+
112
+ sig { returns(String) }
113
+ def describe
114
+ "#{name} is #{detail}"
115
+ end
116
+
117
+ sig { returns(T.nilable(String)) }
118
+ def terse
119
+ detail
120
+ end
121
+
122
+ sig { params(cache: T::Hash[T.untyped, T.untyped]).void }
123
+ def cache_into(cache)
124
+ cache[name] = detail
125
+ end
126
+ end
127
+
128
+ sig { params(executor: Executor, path: T.nilable(String)).void }
129
+ def initialize(executor, path: nil)
130
+ @executor = executor
131
+ @path = path
132
+ end
133
+
134
+ # The Match for a name (Unknown if it resolves to nothing). An alias outranks
135
+ # a function/builtin but not a reserved word; a PATH/slash file is the last
136
+ # resort.
137
+ sig { params(name: T.nilable(String)).returns(Match) }
138
+ def find(name)
139
+ RESOLVERS.lazy.filter_map { |resolver| send(resolver, name) }.first || Unknown.new(name)
140
+ end
141
+
142
+ # The `type`/`command -V` description line for a name, or nil if unknown.
143
+ sig { params(name: T.nilable(String)).returns(T.nilable(String)) }
144
+ def describe(name)
145
+ find(name).describe
146
+ end
147
+
148
+ # The executable file for a name on the search path (the name itself when
149
+ # it contains a slash), or nil. Public as well as feeding #find's file
150
+ # resolver: `command -p` resolves an external through it without the
151
+ # keyword/function/builtin layers, which that builtin handles separately.
152
+ sig { params(name: String).returns(T.nilable(String)) }
153
+ def executable_path(name)
154
+ return name if name.include?('/') && executable?(name)
155
+ return if name.include?('/')
156
+
157
+ dirs.map { |dir| join(dir, name) }.find { |candidate| executable?(candidate) }
158
+ end
159
+
160
+ private
161
+
162
+ sig { params(name: T.nilable(String)).returns(T.nilable(Match)) }
163
+ def as_keyword(name)
164
+ name && KEYWORDS.include?(name) ? Labelled.new(name, 'a shell keyword') : nil
165
+ end
166
+
167
+ sig { params(name: T.nilable(String)).returns(T.nilable(Match)) }
168
+ def as_alias(name)
169
+ value = name && aliases.value(name)
170
+ value ? Aliased.new(name, value) : nil
171
+ end
172
+
173
+ sig { params(name: T.nilable(String)).returns(T.nilable(Match)) }
174
+ def as_function(name)
175
+ name && functions.key?(name) ? Labelled.new(name, 'a shell function') : nil
176
+ end
177
+
178
+ sig { params(name: T.nilable(String)).returns(T.nilable(Match)) }
179
+ def as_special(name)
180
+ return unless name && CommandResolution.special_builtin?(name, @executor.builtins)
181
+
182
+ Labelled.new(name, 'a special shell builtin')
183
+ end
184
+
185
+ sig { params(name: T.nilable(String)).returns(T.nilable(Match)) }
186
+ def as_builtin(name)
187
+ name && @executor.builtins.key?(name) ? Labelled.new(name, 'a shell builtin') : nil
188
+ end
189
+
190
+ sig { params(name: T.nilable(String)).returns(T.nilable(Match)) }
191
+ def as_file(name)
192
+ return unless name
193
+
194
+ path = executable_path(name)
195
+ path ? Executable.new(name, path) : nil
196
+ end
197
+
198
+ sig { returns(AliasTable) }
199
+ def aliases
200
+ @executor.state.aliases
201
+ end
202
+
203
+ sig { returns(FunctionTable) }
204
+ def functions
205
+ @executor.state.functions
206
+ end
207
+
208
+ sig { params(dir: String, name: String).returns(String) }
209
+ def join(dir, name)
210
+ dir.empty? ? name : "#{dir}/#{name}"
211
+ end
212
+
213
+ sig { params(path: String).returns(T::Boolean) }
214
+ def executable?(path)
215
+ @executor.system.file?(path) && @executor.system.executable?(path)
216
+ end
217
+
218
+ sig { returns(T::Array[String]) }
219
+ def dirs
220
+ search_path.split(':', -1)
221
+ end
222
+
223
+ sig { returns(String) }
224
+ def search_path
225
+ @path || @executor.state.variables.get('PATH') || ''
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,115 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Classifies a command for ordinary execution and carries the policies that
6
+ # follow from that classification. This deliberately does not resolve aliases,
7
+ # keywords or PATH: `type`/`command -v`, `command`, and execution have distinct
8
+ # POSIX search modes and share only builtin metadata/vocabulary.
9
+ class CommandResolution
10
+ extend T::Sig
11
+
12
+ # Ordinary execution's four mutually exclusive target kinds.
13
+ class Kind < T::Enum
14
+ enums do
15
+ SPECIAL_BUILTIN = new('special_builtin')
16
+ FUNCTION = new('function')
17
+ BUILTIN = new('builtin')
18
+ EXTERNAL = new('external')
19
+ end
20
+ end
21
+
22
+ # How prefix assignments live for the selected target kind.
23
+ class AssignmentLifetime < T::Enum
24
+ enums do
25
+ PERSISTENT = new('persistent')
26
+ TEMPORARY = new('temporary')
27
+ ENVIRONMENT = new('environment')
28
+ end
29
+ end
30
+
31
+ # Whether redirect setup aborts the shell or fails only this command.
32
+ class RedirectFailure < T::Enum
33
+ enums do
34
+ FATAL = new('fatal')
35
+ ORDINARY = new('ordinary')
36
+ end
37
+ end
38
+
39
+ Policy = T.type_alias { [AssignmentLifetime, RedirectFailure] }
40
+ SPECIAL_BUILTINS = %w[: . break continue eval exec exit export readonly return set shift times trap unset].freeze
41
+ POLICIES = T.let(
42
+ {
43
+ Kind::SPECIAL_BUILTIN => [AssignmentLifetime::PERSISTENT, RedirectFailure::FATAL].freeze,
44
+ Kind::FUNCTION => [AssignmentLifetime::TEMPORARY, RedirectFailure::ORDINARY].freeze,
45
+ Kind::BUILTIN => [AssignmentLifetime::TEMPORARY, RedirectFailure::ORDINARY].freeze,
46
+ Kind::EXTERNAL => [AssignmentLifetime::ENVIRONMENT, RedirectFailure::ORDINARY].freeze
47
+ }.freeze,
48
+ T::Hash[Kind, Policy]
49
+ )
50
+
51
+ sig { returns(Kind) }
52
+ attr_reader :kind
53
+
54
+ sig { returns(AssignmentLifetime) }
55
+ attr_reader :assignment_lifetime
56
+
57
+ sig { returns(RedirectFailure) }
58
+ attr_reader :redirect_failure
59
+
60
+ sig { params(kind: Kind).void }
61
+ def initialize(kind)
62
+ @kind = kind
63
+ @assignment_lifetime, @redirect_failure = POLICIES.fetch(kind)
64
+ end
65
+ private :initialize
66
+
67
+ sig do
68
+ params(name: String, functions: FunctionTable, builtins: Builtins::Registry).returns(CommandResolution)
69
+ end
70
+ def self.for_execution(name, functions, builtins)
71
+ new(execution_kind(name, functions, builtins))
72
+ end
73
+
74
+ sig { params(name: String, builtins: Builtins::Registry).returns(T::Boolean) }
75
+ def self.special_builtin?(name, builtins)
76
+ SPECIAL_BUILTINS.include?(name) && builtins.key?(name)
77
+ end
78
+
79
+ sig { returns(T::Boolean) }
80
+ def builtin?
81
+ [Kind::SPECIAL_BUILTIN, Kind::BUILTIN].include?(kind)
82
+ end
83
+
84
+ sig { returns(T::Boolean) }
85
+ def function?
86
+ kind == Kind::FUNCTION
87
+ end
88
+
89
+ sig { returns(T::Boolean) }
90
+ def persistent_assignments?
91
+ assignment_lifetime == AssignmentLifetime::PERSISTENT
92
+ end
93
+
94
+ sig { returns(T::Boolean) }
95
+ def temporary_assignments?
96
+ assignment_lifetime == AssignmentLifetime::TEMPORARY
97
+ end
98
+
99
+ sig { returns(T::Boolean) }
100
+ def fatal_redirect?
101
+ redirect_failure == RedirectFailure::FATAL
102
+ end
103
+
104
+ sig do
105
+ params(name: String, functions: FunctionTable, builtins: Builtins::Registry).returns(Kind)
106
+ end
107
+ private_class_method def self.execution_kind(name, functions, builtins)
108
+ return Kind::SPECIAL_BUILTIN if special_builtin?(name, builtins)
109
+ return Kind::FUNCTION if functions.key?(name)
110
+ return Kind::BUILTIN if builtins.key?(name)
111
+
112
+ Kind::EXTERNAL
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,152 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Runs a SimpleCommand: expand argv, classify it for ordinary execution,
6
+ # evaluate redirections into a per-command IoTable, then apply the resolution's
7
+ # assignment and redirect policies while dispatching the selected command kind.
8
+ class CommandRunner
9
+ extend T::Sig
10
+
11
+ sig { params(executor: Executor, command: AST::SimpleCommand, base_io: IoTable).void }
12
+ def initialize(executor, command, base_io = executor.io)
13
+ @executor = executor
14
+ @command = command
15
+ @base_io = base_io
16
+ @assignments = CommandAssignments.new(command.assignments, executor.expander)
17
+ end
18
+
19
+ sig { returns(Status) }
20
+ def call
21
+ @executor.reset_cmd_sub_status
22
+ argv = @executor.expander.expand(@command.words)
23
+ return run_bare if argv.empty?
24
+
25
+ trace(argv)
26
+ run_command(argv)
27
+ end
28
+
29
+ private
30
+
31
+ # POSIX 2.5.3: each xtrace line goes to stderr prefixed with the expanded
32
+ # PS4 (parameter expansion only — see Prompt). The standard repeats PS4's
33
+ # first character per level of indirection; rush's tracer has no nesting
34
+ # depth (and dash never repeats), so a single prefix is correct here.
35
+ sig { params(argv: T::Array[String]).void }
36
+ def trace(argv)
37
+ return unless @executor.state.options.on?(:xtrace)
38
+
39
+ @executor.io.get(2).puts("#{Prompt.new(@executor).trace}#{argv.join(' ')}")
40
+ end
41
+
42
+ # No command word: perform redirections then assignments (POSIX order), and
43
+ # take the status of the last command substitution either ran (Status.success
44
+ # when none did), as published via the executor's cmd-sub channel.
45
+ # with_redirects opens/truncates the targets for their side effects, then
46
+ # flushes+closes them after the assignments so a later command sees the data.
47
+ sig { returns(Status) }
48
+ def run_bare
49
+ @executor.redirect_scope.with_redirects(@command.redirects, @base_io) { |io| persist_assignments(io) }
50
+ end
51
+
52
+ sig { params(io: IoTable).returns(Status) }
53
+ def persist_assignments(io)
54
+ @executor.with_io(io) do
55
+ @assignments.persist_to(@executor.state.variables)
56
+ @executor.cmd_sub_status
57
+ end
58
+ end
59
+
60
+ # POSIX command search: special builtin, then function (so a function may
61
+ # override a regular builtin), then regular builtin, then PATH. A redirect
62
+ # error leaves a regular command unrun with status 2 (RedirectError reaches
63
+ # Executor#run), but on a special builtin it aborts the shell (POSIX 2.8.1),
64
+ # so it is re-raised as a fatal BuiltinError.
65
+ sig { params(argv: T::Array[String]).returns(Status) }
66
+ def run_command(argv)
67
+ resolution = CommandResolution.for_execution(argv.fetch(0), @executor.state.functions, @executor.builtins)
68
+ dispatch_with_redirects(resolution, argv)
69
+ end
70
+
71
+ sig { params(resolution: CommandResolution, argv: T::Array[String]).returns(Status) }
72
+ def dispatch_with_redirects(resolution, argv)
73
+ @executor.redirect_scope.with_redirects(@command.redirects, @base_io) { |io| dispatch(resolution, argv, io) }
74
+ rescue RedirectError => e
75
+ raise unless resolution.fatal_redirect?
76
+
77
+ raise BuiltinError, e.message
78
+ end
79
+
80
+ sig { params(resolution: CommandResolution, argv: T::Array[String], io: IoTable).returns(Status) }
81
+ def dispatch(resolution, argv, io)
82
+ environment = command_env(io)
83
+ apply_assignment_policy(resolution, argv, io, environment)
84
+ end
85
+
86
+ sig do
87
+ params(resolution: CommandResolution, argv: T::Array[String], io: IoTable,
88
+ environment: T::Hash[String, String]).returns(Status)
89
+ end
90
+ def apply_assignment_policy(resolution, argv, io, environment)
91
+ return persistent_dispatch(argv, io, environment) if resolution.persistent_assignments?
92
+ return temporary_dispatch(resolution, argv, io, environment) if resolution.temporary_assignments?
93
+
94
+ external(argv, io, environment)
95
+ end
96
+
97
+ sig { params(argv: T::Array[String], io: IoTable, environment: T::Hash[String, String]).returns(Status) }
98
+ def persistent_dispatch(argv, io, environment)
99
+ @assignments.persist_environment(environment, @executor.state.variables)
100
+ invoke_builtin(argv, io, environment)
101
+ end
102
+
103
+ sig do
104
+ params(resolution: CommandResolution, argv: T::Array[String], io: IoTable,
105
+ environment: T::Hash[String, String]).returns(Status)
106
+ end
107
+ def temporary_dispatch(resolution, argv, io, environment)
108
+ values = @assignments.temporary_environment(environment)
109
+ @executor.state.variables.with_temporary(values) { invoke_temporary(resolution, argv, io, environment) }
110
+ end
111
+
112
+ sig do
113
+ params(resolution: CommandResolution, argv: T::Array[String], io: IoTable,
114
+ environment: T::Hash[String, String]).returns(Status)
115
+ end
116
+ def invoke_temporary(resolution, argv, io, environment)
117
+ resolution.function? ? invoke_function(argv, io) : invoke_builtin(argv, io, environment)
118
+ end
119
+
120
+ sig { params(argv: T::Array[String], io: IoTable, environment: T::Hash[String, String]).returns(Status) }
121
+ def external(argv, io, environment)
122
+ @assignments.validate(@executor.state.variables)
123
+ External.new(@executor, argv, io, environment).call(@executor.job_control.job_text(@command))
124
+ end
125
+
126
+ sig { params(argv: T::Array[String], io: IoTable, environment: T::Hash[String, String]).returns(Status) }
127
+ # A builtin reading from or writing to a fd closed by n>&- raises EBADF; like
128
+ # dash, that fails the command (status 1) without killing the shell.
129
+ def invoke_builtin(argv, io, environment)
130
+ @executor.builtins.fetch(argv.fetch(0)).new(@executor, argv, io, environment).call
131
+ rescue Errno::EBADF
132
+ Status.new(1)
133
+ end
134
+
135
+ # A function runs in the current shell, not a subshell. The call's redirects
136
+ # (if any) bind the whole body and are torn down when it returns — even an
137
+ # `exec` inside is scoped to them, as dash does. With no redirects the body
138
+ # shares the shell's io table so an `exec` inside *persists*, so wrap in
139
+ # with_io only when a redirect actually layered a new table over the base.
140
+ sig { params(argv: T::Array[String], io: IoTable).returns(Status) }
141
+ def invoke_function(argv, io)
142
+ body = @executor.state.functions.fetch(argv.fetch(0))
143
+ run = -> { FunctionRunner.new(@executor, body, argv.drop(1)).call }
144
+ io.equal?(@executor.io) ? run.call : @executor.with_io(io, &run)
145
+ end
146
+
147
+ sig { params(io: IoTable).returns(T::Hash[String, String]) }
148
+ def command_env(io)
149
+ @executor.with_io(io) { @assignments.environment_for(@executor.state.variables) }
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,140 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Renders an AST back into dash's canonical job text — the command column
6
+ # of the jobs listing, fg's echoed line and bg's "[n] ..." (dash's cmdtxt,
7
+ # probed shape by shape off-tty): words re-quoted with double quotes when
8
+ # they carried quoting ($ escaped as \$), parameter expansions always
9
+ # braced (${T}, "${@}", ${T:-9} with the raw default), command
10
+ # substitutions and here-document bodies elided to $(...) and <<...,
11
+ # redirect fds explicit (1>/dev/null, 0<f), assignments dropped from a
12
+ # command that has words — an assignment-only command renders as `set`, a
13
+ # dash quirk kept for parity — group braces dropped, elif spelled as a
14
+ # nested else-if, a negated pipeline prefixed with a spaceless !, and the
15
+ # implicit for-loop list spelled out as in "${@}".
16
+ module CommandText
17
+ extend T::Sig
18
+
19
+ TABLE = T.let({
20
+ AST::List => :list, AST::AndOr => :and_or, AST::Pipeline => :pipeline,
21
+ AST::SimpleCommand => :simple, AST::Subshell => :subshell, AST::BraceGroup => :group,
22
+ AST::If => :if_node, AST::While => :while_node, AST::Until => :until_node,
23
+ AST::For => :for_node, AST::Case => :case_node, AST::FunctionDef => :function_def,
24
+ AST::Redirected => :redirected
25
+ }.freeze, T::Hash[T.untyped, Symbol])
26
+
27
+ sig { params(node: AST::Node).returns(String) }
28
+ def self.render(node)
29
+ method(TABLE.fetch(node.class)).call(node)
30
+ end
31
+
32
+ sig { params(node: AST::List).returns(String) }
33
+ def self.list(node)
34
+ node.entries.map { |entry| "#{render(entry.and_or)}#{entry.async ? ' &' : ';'}" }
35
+ .join(' ').delete_suffix(';')
36
+ end
37
+
38
+ sig { params(node: AST::AndOr).returns(String) }
39
+ def self.and_or(node)
40
+ "#{render(node.left)} #{node.op == :and ? '&&' : '||'} #{render(node.right)}"
41
+ end
42
+
43
+ sig { params(node: AST::Pipeline).returns(String) }
44
+ def self.pipeline(node)
45
+ "#{'!' if node.negate}#{stages(node.commands)}"
46
+ end
47
+
48
+ # A pipeline's stages, also reachable for PipelineRunner's already-split
49
+ # command list (the adopted-job text of a stopped foreground pipeline).
50
+ sig { params(commands: T::Array[AST::Node]).returns(String) }
51
+ def self.stages(commands)
52
+ commands.map { |command| render(command) }.join(' | ')
53
+ end
54
+
55
+ sig { params(node: AST::SimpleCommand).returns(String) }
56
+ def self.simple(node)
57
+ words = node.words.map { |word| word(word) }
58
+ words = ['set'] if words.empty?
59
+ (words + node.redirects.map { |redirect| redirect(redirect) }).join(' ')
60
+ end
61
+
62
+ sig { params(node: AST::Subshell).returns(String) }
63
+ def self.subshell(node)
64
+ "(#{render(node.body)})"
65
+ end
66
+
67
+ sig { params(node: AST::BraceGroup).returns(String) }
68
+ def self.group(node)
69
+ render(node.body)
70
+ end
71
+
72
+ sig { params(node: AST::If).returns(String) }
73
+ def self.if_node(node)
74
+ tail = node.alternative ? " else #{render(T.must(node.alternative))};" : ''
75
+ "if #{render(node.condition)}; then #{render(node.consequent)};#{tail} fi"
76
+ end
77
+
78
+ sig { params(node: AST::While).returns(String) }
79
+ def self.while_node(node)
80
+ "while #{render(node.condition)}; do #{render(node.body)}; done"
81
+ end
82
+
83
+ sig { params(node: AST::Until).returns(String) }
84
+ def self.until_node(node)
85
+ "until #{render(node.condition)}; do #{render(node.body)}; done"
86
+ end
87
+
88
+ sig { params(node: AST::For).returns(String) }
89
+ def self.for_node(node)
90
+ list = node.words ? T.must(node.words).map { |word| word(word) }.join(' ') : '"${@}"'
91
+ "for #{node.name} in #{list}; do #{render(node.body)}; done"
92
+ end
93
+
94
+ sig { params(node: AST::Case).returns(String) }
95
+ def self.case_node(node)
96
+ items = node.items.map { |item| "#{word(item.patterns.fetch(0))}) #{render(item.body)};;" }
97
+ "case #{word(node.word)} in #{items.join(' ')} esac"
98
+ end
99
+
100
+ sig { params(node: AST::FunctionDef).returns(String) }
101
+ def self.function_def(node)
102
+ "#{node.name}() { #{render(node.body)}; }"
103
+ end
104
+
105
+ sig { params(node: AST::Redirected).returns(String) }
106
+ def self.redirected(node)
107
+ ([render(node.command)] + node.redirects.map { |redirect| redirect(redirect) }).join(' ')
108
+ end
109
+
110
+ # dash prints every fd explicitly (1>/dev/null, 0<f, 2>&1) and elides a
111
+ # here-document to <<... .
112
+ sig { params(redirect: AST::Redirect).returns(String) }
113
+ def self.redirect(redirect)
114
+ return '<<...' if redirect.kind == :heredoc
115
+
116
+ op, fd = T.must(OPERATORS[redirect.kind])
117
+ "#{redirect.io_number || fd}#{op}#{word(T.cast(redirect.target, AST::Word))}"
118
+ end
119
+
120
+ OPERATORS = T.let({
121
+ in: ['<', 0], out: ['>', 1], append: ['>>', 1], clobber: ['>|', 1],
122
+ readwrite: ['<>', 0], dup_out: ['>&', 1], dup_in: ['<&', 0]
123
+ }.freeze, T::Hash[Symbol, [String, Integer]])
124
+
125
+ # Consecutive quoted segments share one pair of double quotes (dash
126
+ # renders "x$T.y" as one quoted run); each segment canonicalises itself
127
+ # (WordSegment#canon).
128
+ sig { params(word: AST::Word).returns(String) }
129
+ def self.word(word)
130
+ runs = word.segments.chunk_while { |left, right| left.quoted == right.quoted }
131
+ runs.map { |run| run_text(run) }.join
132
+ end
133
+
134
+ sig { params(run: T::Array[AST::WordSegment[T.untyped]]).returns(String) }
135
+ def self.run_text(run)
136
+ body = run.map(&:canon).join
137
+ T.must(run.fetch(0)).quoted ? "\"#{body}\"" : body
138
+ end
139
+ end
140
+ end