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,124 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # Orchestrates the ordered POSIX word expansion. Each segment expands itself
7
+ # to one or more parts ([text, splittable, break_before, quoted]); unquoted results
8
+ # undergo IFS field splitting and then pathname expansion. The one multi-field
9
+ # case is "$@"/$@, which yields one part per positional parameter with a
10
+ # forced field break between them ($* always joins to a scalar). Field
11
+ # splitting carries quote provenance into the pathname-expansion boundary.
12
+ class Pipeline
13
+ extend T::Sig
14
+
15
+ # Tilde expansion strategy per mode (its value is a word's segment list).
16
+ TILDE_EXPANDERS = { none: NoTilde, leading: TildeExpander, assignment: AssignmentTilde }.freeze
17
+
18
+ sig { params(executor: Executor).void }
19
+ def initialize(executor)
20
+ @executor = executor
21
+ @glob_expander = GlobExpander.new(executor)
22
+ end
23
+
24
+ # Argv expansion: expand each word to fields (splitting unquoted on the IFS
25
+ # value in force after that word's substitutions), then pathname-expand.
26
+ sig { params(words: T::Array[AST::Word]).returns(T::Array[String]) }
27
+ def expand(words)
28
+ expanded = T.let([], T::Array[String])
29
+ words.each { |word| append_word(word, expanded) }
30
+ expanded
31
+ end
32
+
33
+ # Assignment RHS / redirection target / operator word: one field, no split.
34
+ # Tilde expands at the leading position by default; assignment context also
35
+ # expands after colons, and arithmetic opts out (~ is bitwise not there).
36
+ sig { params(word: T.any(AST::Word, HereDoc), tilde: Symbol).returns(String) }
37
+ def expand_value(word, tilde: :leading)
38
+ collapse(expand_parts(word, tilde: tilde))
39
+ end
40
+
41
+ # Expand without splitting or globbing while retaining each segment's
42
+ # quote provenance for a surrounding ${...} operator word.
43
+ sig { params(word: T.any(AST::Word, HereDoc), tilde: Symbol).returns(T::Array[FieldPart]) }
44
+ def expand_parts(word, tilde: :leading)
45
+ expanded = T.let([], T::Array[FieldPart])
46
+ tilde_expand(word.segments, tilde).each { |segment| append_field_parts(segment, expanded) }
47
+ expanded
48
+ end
49
+
50
+ sig { params(expanded: T::Array[FieldPart]).returns(String) }
51
+ def collapse(expanded)
52
+ result = +''
53
+ separator = field_separator
54
+ expanded.each { |text, _splittable, brk, _quoted| result << (brk ? separator : '') << text }
55
+ result
56
+ end
57
+
58
+ # A case/removal pattern keeps quoting as backslash shielding so quoted
59
+ # metacharacters remain literal when ShellPattern compiles the result.
60
+ sig { params(pattern: AST::Word, tilde: Symbol).returns(String) }
61
+ def expand_pattern(pattern, tilde: :leading)
62
+ result = +''
63
+ tilde_expand(pattern.segments, tilde).each { |segment| result << expanded_pattern(segment) }
64
+ result
65
+ end
66
+
67
+ private
68
+
69
+ sig { params(segment: AST::WordSegment[T.untyped]).returns(String) }
70
+ def expanded_pattern(segment)
71
+ escape_if_quoted(segment, segment.expand(@executor))
72
+ end
73
+
74
+ sig { params(word: AST::Word, expanded: T::Array[String]).void }
75
+ def append_word(word, expanded)
76
+ parts = expand_parts(word)
77
+ FieldSplitter.new(ifs).split(parts).each { |field| @glob_expander.append(field, expanded) }
78
+ end
79
+
80
+ sig { params(segments: T::Array[AST::WordSegment[T.untyped]], mode: Symbol).returns(T::Array[AST::WordSegment[T.untyped]]) }
81
+ def tilde_expand(segments, mode)
82
+ TILDE_EXPANDERS.fetch(mode).new(@executor, segments).expand
83
+ end
84
+
85
+ sig { params(segment: AST::WordSegment[T.untyped], expanded: T::Array[FieldPart]).void }
86
+ def append_field_parts(segment, expanded)
87
+ return append_splat_parts(segment, expanded) if segment.splat?
88
+
89
+ segment.append_field_parts(@executor, expanded)
90
+ end
91
+
92
+ # Glob metacharacters in quoted text are escaped so they match literally;
93
+ # unquoted text keeps them active.
94
+ sig { params(segment: AST::WordSegment[T.untyped], text: String).returns(String) }
95
+ def escape_if_quoted(segment, text)
96
+ segment.quoted ? escape(text) : text
97
+ end
98
+
99
+ sig { params(text: String).returns(String) }
100
+ def escape(text)
101
+ text.gsub(/[\\*?\[\]\-!^]/) { |meta| "\\#{meta}" }
102
+ end
103
+
104
+ sig { params(segment: AST::WordSegment[T.untyped], expanded: T::Array[FieldPart]).void }
105
+ def append_splat_parts(segment, expanded)
106
+ quoted = segment.quoted
107
+ @executor.state.positional.to_a.each_with_index do |element, index|
108
+ expanded << [element, !quoted, index.positive?, quoted]
109
+ end
110
+ end
111
+
112
+ sig { returns(String) }
113
+ def field_separator
114
+ value = ifs
115
+ value ? (value.each_char.first || '') : ' '
116
+ end
117
+
118
+ sig { returns(T.nilable(String)) }
119
+ def ifs
120
+ @executor.state.variables.get('IFS')
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,13 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # One annotated character of a line consumed by the `read` builtin, as
7
+ # [char, escaped]: the character itself and whether a backslash escaped it
8
+ # (an escaped character keeps its literal value and never acts as an IFS
9
+ # delimiter). A zero-width joint (['', true]) marks where a backslash-newline
10
+ # continuation joined two physical lines.
11
+ ReadChar = T.type_alias { [String, T::Boolean] }
12
+ end
13
+ end
@@ -0,0 +1,159 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # The count-limited, escape-aware field scanner behind ReadSplitter — a
7
+ # transcription of dash's ifsbreakup(maxargs): fields split off until the
8
+ # variable budget is spent; the delimiter that spends it starts the
9
+ # remainder, which accumulates verbatim with a cut mark (dash's `r`)
10
+ # tracking removable trailing IFS whitespace. Escaped characters are plain
11
+ # content: never delimiters, they end a whitespace run (dash's region
12
+ # boundary) and never touch the cut mark — reproducing dash's remainder
13
+ # edge cases exactly (see the unit spec).
14
+ class ReadFieldScanner
15
+ extend T::Sig
16
+
17
+ # The collected fields plus the field being built, carrying dash's `r`
18
+ # mark: the position from which the final field's trailing text is cut.
19
+ class Fields
20
+ extend T::Sig
21
+
22
+ sig { void }
23
+ def initialize
24
+ @done = T.let([], T::Array[String])
25
+ @text = T.let(+'', String)
26
+ @cut = T.let(nil, T.nilable(Integer))
27
+ end
28
+
29
+ sig { params(char: String).void }
30
+ def append(char)
31
+ @text << char
32
+ end
33
+
34
+ sig { returns(T::Boolean) }
35
+ def empty?
36
+ @text.empty?
37
+ end
38
+
39
+ sig { void }
40
+ def close
41
+ @done << @text
42
+ @text = +''
43
+ end
44
+
45
+ # Records the cut position — the start of removable trailing text
46
+ # (dash's `r`) — keeping the earliest one until uncut clears it.
47
+ sig { void }
48
+ def cut
49
+ @cut ||= @text.length
50
+ end
51
+
52
+ sig { void }
53
+ def uncut
54
+ @cut = nil
55
+ end
56
+
57
+ sig { returns(T::Array[String]) }
58
+ def result
59
+ cut = @cut
60
+ tail = cut ? T.must(@text[0, cut]) : @text
61
+ tail.empty? ? @done : @done + [tail]
62
+ end
63
+ end
64
+
65
+ sig { params(ifs: Ifs, count: Integer).void }
66
+ def initialize(ifs, count)
67
+ @ifs = ifs
68
+ @left = count
69
+ @fields = T.let(Fields.new, Fields)
70
+ @spaced = T.let(false, T::Boolean) # dash's ifsspc: in a whitespace-delimiter run
71
+ end
72
+
73
+ sig { params(chars: T::Array[ReadChar]).returns(T::Array[String]) }
74
+ def run(chars)
75
+ chars.each { |char, escaped| escaped ? shielded(char) : consume(char) }
76
+ @fields.result
77
+ end
78
+
79
+ private
80
+
81
+ sig { params(char: String).void }
82
+ def consume(char)
83
+ return tail(char) if @left.zero?
84
+ return absorb(char) if @spaced && ifs?(char)
85
+
86
+ head(char)
87
+ end
88
+
89
+ # A backslash-escaped character: plain content, never a delimiter. It
90
+ # ends any whitespace run (in dash a region boundary resets ifsspc) and
91
+ # leaves the cut mark alone (dash never scans the gap it sits in). The
92
+ # zero-width joint a line continuation leaves (['', true]) rides the same
93
+ # path: it only breaks the run.
94
+ sig { params(char: String).void }
95
+ def shielded(char)
96
+ @spaced = false
97
+ @fields.append(char)
98
+ end
99
+
100
+ # Inside a whitespace-delimiter run an IFS char joins the delimiter:
101
+ # more whitespace keeps the run open, one non-whitespace IFS character
102
+ # closes it.
103
+ sig { params(char: String).void }
104
+ def absorb(char)
105
+ @spaced = @ifs.whitespace?(char)
106
+ end
107
+
108
+ sig { params(char: String).void }
109
+ def head(char)
110
+ return delimiter(char) if ifs?(char)
111
+
112
+ @spaced = false
113
+ @fields.append(char)
114
+ end
115
+
116
+ # An unescaped IFS delimiter: whitespace before any field content is
117
+ # skipped; a delimiter splits a field while variables remain, and the
118
+ # one that spends the last variable opens the remainder.
119
+ sig { params(char: String).void }
120
+ def delimiter(char)
121
+ @spaced = @ifs.whitespace?(char)
122
+ return skip_leading if @spaced && @fields.empty?
123
+
124
+ (@left -= 1).zero? ? removable(char) : @fields.close
125
+ end
126
+
127
+ sig { void }
128
+ def skip_leading
129
+ @spaced = false
130
+ end
131
+
132
+ # Removable trailing text: the delimiter that spends the last variable
133
+ # (the remainder accumulates verbatim from it) and, later, IFS whitespace
134
+ # extending the trailing run — cut off unless real content follows.
135
+ sig { params(char: String).void }
136
+ def removable(char)
137
+ @fields.cut
138
+ @fields.append(char)
139
+ end
140
+
141
+ # Remainder mode (dash's exhausted maxargs): IFS whitespace extends the
142
+ # removable trailing run; anything else is content and clears the cut —
143
+ # except a single non-whitespace IFS char directly closing a run.
144
+ sig { params(char: String).void }
145
+ def tail(char)
146
+ return removable(char) if @ifs.whitespace?(char)
147
+
148
+ @fields.uncut unless @ifs.other?(char) && @spaced
149
+ @spaced = false
150
+ @fields.append(char)
151
+ end
152
+
153
+ sig { params(char: String).returns(T::Boolean) }
154
+ def ifs?(char)
155
+ @ifs.whitespace?(char) || @ifs.other?(char)
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,40 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # Field splitting for the `read` builtin over an escape-annotated line:
7
+ # ReadFieldScanner splits on unescaped IFS delimiters into at most `count`
8
+ # fields (the last keeps the remainder verbatim, trailing IFS whitespace
9
+ # removed), and the result is padded with empty strings up to `count`.
10
+ # A null IFS does not split — the whole line is one field, untrimmed.
11
+ class ReadSplitter
12
+ extend T::Sig
13
+
14
+ sig { params(ifs: T.nilable(String), count: Integer).void }
15
+ def initialize(ifs, count)
16
+ @ifs = Ifs.new(ifs)
17
+ @count = count
18
+ end
19
+
20
+ sig { params(chars: T::Array[ReadChar]).returns(T::Array[String]) }
21
+ def split(chars)
22
+ return pad([text(chars)]) if @ifs.null?
23
+
24
+ pad(ReadFieldScanner.new(@ifs, @count).run(chars))
25
+ end
26
+
27
+ private
28
+
29
+ sig { params(chars: T::Array[ReadChar]).returns(String) }
30
+ def text(chars)
31
+ chars.map(&:first).join
32
+ end
33
+
34
+ sig { params(fields: T::Array[String]).returns(T::Array[String]) }
35
+ def pad(fields)
36
+ (fields.each + [''].cycle).take(@count)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # Resolves a parameter name through ShellState's parameter namespace. The
7
+ # shell state owns variables, special parameters and positionals, including
8
+ # the original shell pid exposed through $$.
9
+ class Resolver
10
+ extend T::Sig
11
+
12
+ sig { params(executor: Executor).void }
13
+ def initialize(executor)
14
+ @executor = executor
15
+ end
16
+
17
+ sig { params(name: String).returns(T.nilable(String)) }
18
+ def resolve(name)
19
+ @executor.state.parameters.resolve(name)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,102 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # Tilde expansion (step 1): a leading unquoted `~` or `~name` in a word's
7
+ # first literal segment becomes $HOME or the named user's home directory. It
8
+ # is left untouched when there is no such user, or when HOME is unset for a
9
+ # bare `~`. This base handles the leading form; AssignmentTilde extends it to
10
+ # also expand after each unquoted colon, and NoTilde disables it entirely.
11
+ class TildeExpander
12
+ extend T::Sig
13
+
14
+ sig { params(executor: Executor, segments: T::Array[AST::WordSegment[T.untyped]]).void }
15
+ def initialize(executor, segments)
16
+ @executor = executor
17
+ @segments = segments
18
+ end
19
+
20
+ sig { returns(T::Array[AST::WordSegment[T.untyped]]) }
21
+ def expand
22
+ head = @segments.first
23
+ return @segments unless head
24
+
25
+ text = head.literal_value
26
+ text ? replace_head(head, text) : @segments
27
+ end
28
+
29
+ private
30
+
31
+ sig { params(head: AST::WordSegment[T.untyped], text: String).returns(T::Array[AST::WordSegment[T.untyped]]) }
32
+ def replace_head(head, text)
33
+ rewritten = rewrite(text)
34
+ return @segments if rewritten == text
35
+
36
+ @segments.dup.tap { |segments| segments[0] = head.with_value(rewritten) }
37
+ end
38
+
39
+ sig { params(text: String).returns(String) }
40
+ def rewrite(text)
41
+ prefix(text)
42
+ end
43
+
44
+ sig { params(text: String).returns(String) }
45
+ def prefix(text)
46
+ return text unless text.start_with?('~')
47
+
48
+ name, rest = split(text.delete_prefix('~'))
49
+ home = resolve(name)
50
+ home ? home + rest : text
51
+ end
52
+
53
+ sig { params(body: String).returns([String, String]) }
54
+ def split(body)
55
+ name, slash, rest = body.partition('/')
56
+ slash.empty? ? [body, ''] : [name, slash + rest]
57
+ end
58
+
59
+ sig { params(name: String).returns(T.nilable(String)) }
60
+ def resolve(name)
61
+ return @executor.state.variables.get('HOME') if name.empty?
62
+
63
+ @executor.system.home_dir(name)
64
+ end
65
+ end
66
+
67
+ # Assignment context (PATH=~/bin:~root/x): the leading tilde plus one after
68
+ # each unquoted colon, so every colon-separated piece gets the ~ treatment.
69
+ class AssignmentTilde < TildeExpander
70
+ extend T::Sig
71
+
72
+ private
73
+
74
+ sig { params(text: String).returns(String) }
75
+ def rewrite(text)
76
+ return text unless expandable?(text)
77
+
78
+ text.split(':', -1).map { |piece| prefix(piece) }.join(':')
79
+ end
80
+
81
+ sig { params(text: String).returns(T::Boolean) }
82
+ def expandable?(text)
83
+ text.start_with?('~') || text.include?(':~')
84
+ end
85
+ end
86
+
87
+ # Tilde expansion disabled (e.g. arithmetic operands): segments pass through.
88
+ class NoTilde
89
+ extend T::Sig
90
+
91
+ sig { params(_executor: Executor, segments: T::Array[AST::WordSegment[T.untyped]]).void }
92
+ def initialize(_executor, segments)
93
+ @segments = segments
94
+ end
95
+
96
+ sig { returns(T::Array[AST::WordSegment[T.untyped]]) }
97
+ def expand
98
+ @segments
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,75 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Runs an external program through the system port with the command's env and
6
+ # IoTable, translating the result: the OS exit/signal status, or POSIX 127
7
+ # (not found) / 126 (not executable).
8
+ class External
9
+ extend T::Sig
10
+
11
+ sig { params(executor: Executor, argv: T::Array[String], io: IoTable, env: T::Hash[String, String]).void }
12
+ def initialize(executor, argv, io, env)
13
+ @executor = executor
14
+ @argv = argv
15
+ @io = io
16
+ @env = env
17
+ end
18
+
19
+ # Under job control the spawned command has no child-side hook, so the
20
+ # foreground wrapper's parent-side give is what hands it the terminal —
21
+ # right after the spawn, before the wait. `text` is the job line a
22
+ # parked entry keeps, rendered pre-expansion by the caller (which holds
23
+ # the AST this class never sees). A signal death reports on the
24
+ # command's own stderr (dash applies simple-command redirects in the
25
+ # parent, so 2>/dev/null suppresses its printsignal there too — probed).
26
+ sig { params(text: T.nilable(String)).returns(Status) }
27
+ def call(text = nil)
28
+ call_file(T.must(@argv.first), text)
29
+ end
30
+
31
+ # Run a pre-resolved file in place of the $PATH search on argv[0] (plain
32
+ # #call passes the bare name, `command -p` its default-PATH resolution);
33
+ # argv[0] itself still names the child, like dash.
34
+ sig { params(path: String, text: T.nilable(String)).returns(Status) }
35
+ def call_file(path, text = nil)
36
+ pid = system.spawn(path, @env, @argv, spawn_options)
37
+ status = @executor.job_control.foreground([pid], text: text) { @executor.jobs.await(pid) }
38
+ SignalReport.report(status, @io.get(2))
39
+ rescue Errno::ENOENT, Errno::EACCES => e
40
+ error_status(e)
41
+ end
42
+
43
+ private
44
+
45
+ sig { returns(SystemCalls) }
46
+ def system
47
+ @executor.system
48
+ end
49
+
50
+ # close_others closes inherited fds >= 3 (e.g. pipeline pipe ends) in the
51
+ # child. Under job control the command is a job of its own: pgroup: true
52
+ # has the kernel start it as its own process-group leader — the spawn-path
53
+ # equivalent of the fork paths' double setpgid, with no race at all.
54
+ sig { returns(T::Hash[T.any(Integer, Symbol), T.untyped]) }
55
+ def spawn_options
56
+ base = @io.to_spawn_options.merge(close_others: true)
57
+ @executor.job_control.monitored? ? base.merge(pgroup: true) : base
58
+ end
59
+
60
+ sig { params(error: SystemCallError).returns(Status) }
61
+ def error_status(error)
62
+ code = error.is_a?(Errno::EACCES) ? 126 : 127
63
+ reason = code == 126 ? 'Permission denied' : 'not found'
64
+ report_error(reason)
65
+ Status.new(code)
66
+ end
67
+
68
+ sig { params(reason: String).void }
69
+ def report_error(reason)
70
+ @io.get(2).puts("rush: #{@argv.first}: #{reason}")
71
+ rescue Errno::EBADF
72
+ nil
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,60 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # One binding in the shell's logical fd table. It records the stream currently
6
+ # bound to a descriptor plus ownership: redirections that open files/heredocs
7
+ # are owned and closed after the command, while stdio, pipes and future
8
+ # inherited descriptors are borrowed. A closed entry models `n>&-` / `n<&-`.
9
+ class FdEntry
10
+ extend T::Sig
11
+
12
+ sig { params(stream: T.untyped).returns(FdEntry) }
13
+ def self.borrowed(stream)
14
+ new(stream, :borrowed)
15
+ end
16
+
17
+ sig { params(stream: T.untyped).returns(FdEntry) }
18
+ def self.owned(stream)
19
+ new(stream, :owned)
20
+ end
21
+
22
+ sig { returns(FdEntry) }
23
+ def self.closed
24
+ new(nil, :closed)
25
+ end
26
+
27
+ sig { params(stream: T.untyped, state: Symbol).void }
28
+ def initialize(stream, state)
29
+ @stream = stream
30
+ @state = state
31
+ end
32
+
33
+ sig { returns(T::Boolean) }
34
+ def owned?
35
+ @state == :owned
36
+ end
37
+
38
+ sig { returns(T::Boolean) }
39
+ def closed?
40
+ @state == :closed
41
+ end
42
+
43
+ sig { returns(T.untyped) }
44
+ def stream
45
+ raise Errno::EBADF if closed?
46
+
47
+ T.must(@stream)
48
+ end
49
+
50
+ sig { returns(T.untyped) }
51
+ def to_spawn_option
52
+ closed? ? :close : stream
53
+ end
54
+
55
+ sig { params(system: SystemCalls).void }
56
+ def close_redirect(system)
57
+ system.close_redirect(stream) if owned? && !closed?
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,50 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Runs a for loop: assign the variable to each value in turn and run the body,
6
+ # catching break/continue at the loop boundary (see LoopControlHandling). The
7
+ # loop's status is the last body status (0 if the value list is empty).
8
+ class ForRunner
9
+ extend T::Sig
10
+
11
+ include LoopControlHandling
12
+
13
+ sig { params(executor: Executor, name: String, values: T::Array[String], body: AST::Node).void }
14
+ def initialize(executor, name, values, body)
15
+ @executor = executor
16
+ @name = name
17
+ @values = values
18
+ @body = body
19
+ end
20
+
21
+ # Bracket the loop so break/continue see the right nesting depth; leave runs
22
+ # even when break unwinds.
23
+ sig { returns(Status) }
24
+ def call
25
+ @executor.state.with_loop { run_loop }
26
+ end
27
+
28
+ private
29
+
30
+ sig { returns(Status) }
31
+ def run_loop
32
+ # T.let pins the loop variable's type (see LoopRunner#run_loop): Sorbet forbids
33
+ # a variable changing type across a block (srb.help/7001) and the sig'd
34
+ # Status.success / unsig'd #iterate would. Steep ignores T.let (sorbet_dsl.rbs).
35
+ status = T.let(Status.success, Status)
36
+ @values.each { |value| status = iterate(value) }
37
+ status
38
+ rescue BreakSignal => e
39
+ unwind(e)
40
+ end
41
+
42
+ sig { params(value: String).returns(Status) }
43
+ def iterate(value)
44
+ @executor.state.variables.assign(@name, value)
45
+ @executor.run(@body)
46
+ rescue ContinueSignal => e
47
+ unwind(e)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,30 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Brackets a shell-function invocation: open a local-variable scope, reset loop
6
+ # nesting so break/continue cannot escape to the caller, bind positional
7
+ # parameters to the function arguments, then restore all three on return.
8
+ class FunctionFrame
9
+ extend T::Sig
10
+
11
+ sig { params(variables: ShellVariables, loops: LoopNesting, positional: Positional).void }
12
+ def initialize(variables:, loops:, positional:)
13
+ @variables = variables
14
+ @loops = loops
15
+ @positional = positional
16
+ end
17
+
18
+ sig do
19
+ type_parameters(:U)
20
+ .params(args: T::Array[String], blk: T.proc.returns(T.type_parameter(:U)))
21
+ .returns(T.type_parameter(:U))
22
+ end
23
+ def call(args, &blk)
24
+ @variables.begin_scope
25
+ @loops.without { @positional.with(args, &blk) }
26
+ ensure
27
+ @variables.end_scope
28
+ end
29
+ end
30
+ end