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,77 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Resolves shell parameters from the namespaces a shell exposes: ordinary
6
+ # variables, special parameters ($?, $#, $$, $0, $@/$*, $-, $!), and positional
7
+ # parameters ($1, $2, ...). $$ is the original shell pid stored in ShellState,
8
+ # so forked subshells and command substitutions preserve it like POSIX shells.
9
+ class ShellParameters
10
+ extend T::Sig
11
+
12
+ sig { params(state: T.untyped).void }
13
+ def initialize(state)
14
+ @state = state
15
+ end
16
+
17
+ sig { params(parameter: String).returns(T.nilable(String)) }
18
+ def resolve(parameter)
19
+ return @state.pids.shell.to_s if parameter == '$'
20
+
21
+ special = special_parameters.fetch(parameter, nil)
22
+ special ? special.call : ordinary_or_positional(parameter)
23
+ end
24
+
25
+ private
26
+
27
+ sig { returns(T::Hash[String, T.proc.returns(T.nilable(String))]) }
28
+ def special_parameters
29
+ { '?' => -> { status }, '#' => -> { count }, '0' => -> { @state.name },
30
+ '@' => -> { positional_all }, '*' => -> { positional_all }, '-' => -> { options },
31
+ '!' => -> { background } }
32
+ end
33
+
34
+ sig { returns(String) }
35
+ def status
36
+ @state.last_status.exitstatus.to_s
37
+ end
38
+
39
+ sig { returns(String) }
40
+ def count
41
+ @state.positional.size.to_s
42
+ end
43
+
44
+ sig { returns(String) }
45
+ def positional_all
46
+ @state.positional.join(separator)
47
+ end
48
+
49
+ sig { returns(String) }
50
+ def separator
51
+ ifs = @state.variables.get('IFS')
52
+ ifs ? (ifs.each_char.first || '') : ' '
53
+ end
54
+
55
+ sig { returns(String) }
56
+ def options
57
+ @state.options.letters
58
+ end
59
+
60
+ sig { returns(T.nilable(String)) }
61
+ def background
62
+ @state.last_background_pid&.to_s
63
+ end
64
+
65
+ sig { params(parameter: String).returns(T.nilable(String)) }
66
+ def ordinary_or_positional(parameter)
67
+ return positional(Integer(parameter, 10)) if parameter.match?(/\A\d+\z/)
68
+
69
+ @state.variables.get(parameter)
70
+ end
71
+
72
+ sig { params(index: Integer).returns(T.nilable(String)) }
73
+ def positional(index)
74
+ @state.positional[index - 1]
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,80 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # A shell `case`/parameter/pathname pattern. Ruby handles ordinary patterns;
6
+ # patterns containing POSIX bracket subforms are compiled to a Ruby regexp,
7
+ # while #glob_source broadens those brackets for Dir.glob candidate discovery.
8
+ # :reek:InstanceVariableAssumption -- @scanner is initialized by PatternScanner#initialize
9
+ class ShellPattern < PatternScanner
10
+ extend T::Sig
11
+
12
+ sig { returns(String) }
13
+ attr_reader :glob_source
14
+
15
+ sig { params(source: String).void }
16
+ def initialize(source)
17
+ @regexp = +''
18
+ @glob_source = +''
19
+ @extended = false
20
+ super
21
+ end
22
+
23
+ sig { returns(T::Boolean) }
24
+ def extended?
25
+ @extended
26
+ end
27
+
28
+ sig { params(text: String).returns(T::Boolean) }
29
+ def match?(text)
30
+ return File.fnmatch(@scanner.string, text, File::FNM_DOTMATCH) unless extended?
31
+
32
+ Regexp.new("\\A#{@regexp}\\z", Regexp::MULTILINE).match?(text)
33
+ rescue RegexpError
34
+ false
35
+ end
36
+
37
+ sig { params(text: String).returns(T::Boolean) }
38
+ def ===(text)
39
+ match?(text)
40
+ end
41
+
42
+ private
43
+
44
+ sig { void }
45
+ def append_escape
46
+ @scanner.getch
47
+ escaped = @scanner.getch
48
+ @glob_source << '\\' << escaped.to_s
49
+ @regexp << Regexp.escape(escaped || '\\')
50
+ end
51
+
52
+ sig { void }
53
+ def append_wildcard
54
+ char = @scanner.getch.to_s
55
+ @glob_source << char
56
+ @regexp << (char == '*' ? '.*' : '.')
57
+ end
58
+
59
+ sig { void }
60
+ def append_bracket
61
+ bracket = BracketExpression.parse(@scanner.string, @scanner.pos)
62
+ return append_literal unless bracket
63
+
64
+ append_expression(bracket)
65
+ end
66
+
67
+ sig { params(bracket: BracketExpression).void }
68
+ def append_expression(bracket)
69
+ bracket.append_to(@regexp, @glob_source, @scanner)
70
+ @extended = bracket.special? || @extended
71
+ end
72
+
73
+ sig { void }
74
+ def append_literal
75
+ char = @scanner.getch.to_s
76
+ @glob_source << char
77
+ @regexp << Regexp.escape(char)
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,163 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Original shell and parent process ids used for $$ and PPID.
6
+ ShellProcessIds = Data.define(:shell, :parent) do
7
+ extend T::Sig
8
+
9
+ sig { params(system: SystemCalls).returns(ShellProcessIds) }
10
+ def self.for(system)
11
+ new(system.pid, system.ppid)
12
+ end
13
+ end
14
+
15
+ # The mutable shell state threaded through execution: shell variables,
16
+ # the last command's status ($?, the one field with behaviour here), the shell
17
+ # name ($0), and the session sub-objects it bundles for the rest of the
18
+ # interpreter to drive directly — LoopNesting, Options, Positional, the
19
+ # function/alias/trap tables and the command-location cache.
20
+ class ShellState
21
+ extend T::Sig
22
+
23
+ sig { returns(FunctionTable) }
24
+ attr_reader :functions
25
+
26
+ sig { returns(TrapTable) }
27
+ attr_reader :traps
28
+
29
+ sig { returns(AliasTable) }
30
+ attr_reader :aliases
31
+
32
+ sig { returns(T::Hash[String, String]) }
33
+ attr_reader :command_hash
34
+
35
+ sig { returns(String) }
36
+ attr_reader :name
37
+
38
+ # $$ and PPID stay the original ids in forked children, like POSIX shells.
39
+ sig { returns(ShellProcessIds) }
40
+ attr_reader :pids
41
+
42
+ sig { returns(ShellVariables) }
43
+ attr_reader :variables
44
+
45
+ sig { returns(LoopNesting) }
46
+ attr_reader :loops
47
+
48
+ sig { returns(Options) }
49
+ attr_reader :options
50
+
51
+ sig { returns(Status) }
52
+ attr_reader :last_status
53
+
54
+ sig { returns(T.nilable(Integer)) }
55
+ attr_reader :last_background_pid
56
+
57
+ sig { returns(Positional) }
58
+ attr_reader :positional
59
+
60
+ sig { returns(GetoptsState) }
61
+ attr_reader :getopts
62
+
63
+ # The one wiring point for every shell sub-table. Thirteen tables put the
64
+ # ABC size at ~18 from breadth alone — 13 assignments and 13 constructor
65
+ # sends, zero branches — and going lower would mean bundling namespaces
66
+ # POSIX keeps distinct (aliases are pre-parse substitution, not command
67
+ # search), so the disable stays as the measured wiring floor.
68
+ # rubocop:disable Metrics/AbcSize
69
+ sig { params(environment: Environment, name: String, positional: T::Array[String], pids: ShellProcessIds).void }
70
+ def initialize(environment: Environment.new, name: 'rush', positional: [], pids: ShellProcessIds.new(0, 0))
71
+ @name = name
72
+ @pids = pids
73
+ @variables = ShellVariables.new(environment)
74
+ @traps = TrapTable.new
75
+ @last_status = Status.success
76
+ @last_background_pid = T.let(nil, T.nilable(Integer))
77
+ @loops = LoopNesting.new
78
+ @options = Options.new
79
+ @positional = Positional.new(positional)
80
+ @getopts = GetoptsState.new
81
+ @functions = FunctionTable.new
82
+ @aliases = AliasTable.new
83
+ @command_hash = {}
84
+ seed_variables
85
+ end
86
+ # rubocop:enable Metrics/AbcSize
87
+
88
+ # A stateless view over this state, built on demand like Executor#job_control.
89
+ sig { returns(ShellParameters) }
90
+ def parameters
91
+ ShellParameters.new(self)
92
+ end
93
+
94
+ sig do
95
+ type_parameters(:U)
96
+ .params(args: T::Array[String], blk: T.proc.returns(T.type_parameter(:U)))
97
+ .returns(T.type_parameter(:U))
98
+ end
99
+ def with_function_frame(args, &blk)
100
+ FunctionFrame.new(variables: @variables, loops: @loops, positional: @positional).call(args, &blk)
101
+ end
102
+
103
+ sig do
104
+ type_parameters(:U)
105
+ .params(blk: T.proc.returns(T.type_parameter(:U)))
106
+ .returns(T.type_parameter(:U))
107
+ end
108
+ def with_loop(&blk)
109
+ @loops.enter
110
+ yield
111
+ ensure
112
+ @loops.leave
113
+ end
114
+
115
+ sig do
116
+ type_parameters(:U)
117
+ .params(blk: T.proc.returns(T.type_parameter(:U)))
118
+ .returns(T.type_parameter(:U))
119
+ end
120
+ def preserve_status(&blk)
121
+ saved = @last_status
122
+ yield
123
+ ensure
124
+ @last_status = T.must(saved)
125
+ end
126
+
127
+ # Flip an ordinary shell option, keeping the side effects options carry in
128
+ # sync (allexport mirrors onto the variable table). Invocation also uses
129
+ # this before an executor exists; live :monitor changes instead belong to
130
+ # JobControl, which owns the signal/terminal/process-group side effects.
131
+ sig { params(option: Symbol, enabled: T::Boolean).void }
132
+ def set_option(option, enabled)
133
+ @options.set(option, enabled)
134
+ @variables.allexport = enabled if option == :allexport
135
+ end
136
+
137
+ # The last command's exit status ($?), recorded after each command runs.
138
+ sig { params(status: Status).returns(Status) }
139
+ def record_status(status)
140
+ @last_status = status
141
+ end
142
+
143
+ sig { params(pid: Integer).returns(Integer) }
144
+ def record_background_pid(pid)
145
+ @last_background_pid = pid
146
+ end
147
+
148
+ sig { params(line: Integer).void }
149
+ def record_lineno(line)
150
+ @variables.update_lineno(line)
151
+ end
152
+
153
+ private
154
+
155
+ # The variables a POSIX shell is born with: PPID (2.5.3) and getopts'
156
+ # OPTIND, initialized to 1 at shell startup.
157
+ sig { void }
158
+ def seed_variables
159
+ @variables.assign('PPID', @pids.parent.to_s)
160
+ @variables.assign('OPTIND', '1')
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,67 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'forwardable'
5
+
6
+ module Rush
7
+ # Semantic facade for shell variables: ordinary get/assign/export/readonly
8
+ # operations plus dynamic local scopes and logical PWD tracking delegated to
9
+ # Scope. Environment and Scope remain storage details behind this API.
10
+ class ShellVariables
11
+ extend T::Sig
12
+ extend Forwardable
13
+
14
+ def_delegators :@environment, :get, :export, :readonly, :unset, :exported, :update_lineno, :with_temporary,
15
+ :validate_assignment
16
+ def_delegators :@scope, :pwd, :move_to, :seed_pwd, :current_pwd,
17
+ :begin_scope, :end_scope, :in_function?, :declare_local
18
+
19
+ sig { params(environment: Environment).void }
20
+ def initialize(environment)
21
+ @environment = environment
22
+ @scope = Scope.new(environment)
23
+ @assignments = Assignments.new(self)
24
+ @allexport = false
25
+ end
26
+
27
+ sig { params(enabled: T::Boolean).void }
28
+ def allexport=(enabled)
29
+ @allexport = enabled == true
30
+ end
31
+
32
+ sig { returns(T::Array[String]) }
33
+ def locale_settings
34
+ %w[LC_COLLATE LC_CTYPE].map { |category| locale(category) }
35
+ end
36
+
37
+ # The locale name for one category: a non-empty LC_ALL overrides the
38
+ # category-specific variable, then LANG, with the POSIX locale as default.
39
+ sig { params(category: String).returns(String) }
40
+ def locale(category)
41
+ names = ['LC_ALL', category, 'LANG']
42
+ names.filter_map { |name| get(name) }.reject(&:empty?).first || 'C'
43
+ end
44
+
45
+ sig { params(name: String, value: String).returns(String) }
46
+ def assign(name, value)
47
+ @environment.assign(name, value)
48
+ export(name) if @allexport
49
+ value
50
+ end
51
+
52
+ sig { params(text: String).void }
53
+ def export_operand(text)
54
+ export(@assignments.apply(text))
55
+ end
56
+
57
+ sig { params(text: String).void }
58
+ def readonly_operand(text)
59
+ readonly(@assignments.apply(text))
60
+ end
61
+
62
+ sig { params(text: String).void }
63
+ def declare_local_operand(text)
64
+ @assignments.apply_local(text)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,41 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # dash's printsignal(): when a foreground wait reaps a child that died by a
6
+ # signal other than SIGINT/SIGPIPE, the shell writes the strsignal
7
+ # description — "Killed", "Terminated", " (core dumped)" suffixed when the
8
+ # OS recorded a dump — to the stderr in effect at that wait. The wait
9
+ # builtin reports the same for pid/%id operands; a bare `wait` never
10
+ # reports (probed, rush-hkp). Methods are explicit singletons, like
11
+ # Signals (module_function leaves unkillable instance-side copies).
12
+ module SignalReport
13
+ extend T::Sig
14
+
15
+ # dash's exclusions: SIGINT ends interactive commands routinely and
16
+ # SIGPIPE is the normal fate of a producer feeding a finished consumer.
17
+ SILENT = T.let([Signals.number('INT'), Signals.number('PIPE')].freeze, T::Array[Integer])
18
+
19
+ # Report onto err and hand the status back, so reap sites stay
20
+ # expressions. A closed stderr swallows the report, not the status.
21
+ sig { params(status: Status, err: T.untyped).returns(Status) }
22
+ def self.report(status, err)
23
+ err.puts(line(status)) if report?(status)
24
+ status
25
+ rescue Errno::EBADF
26
+ status
27
+ end
28
+
29
+ sig { params(status: Status).returns(T::Boolean) }
30
+ def self.report?(status)
31
+ signal = status.termsig
32
+ !!signal && !SILENT.include?(signal)
33
+ end
34
+
35
+ sig { params(status: Status).returns(String) }
36
+ def self.line(status)
37
+ description = Signals.description(T.must(status.termsig))
38
+ status.coredump? ? "#{description} (core dumped)" : description
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,68 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Resolves a trap signal spec to a single canonical name. Numbers map through
6
+ # the POSIX signal table, names match case-insensitively and without a "SIG"
7
+ # prefix, and 0/EXIT denote the pseudo-signal that fires when the shell exits.
8
+ # An unknown spec resolves to nil so `trap` can report "bad trap" (dash parity).
9
+ # Methods are explicit singletons: module_function would define a second,
10
+ # instance-side copy that callers never reach — dead weight mutant proved
11
+ # unkillable (journal, rush-211.8).
12
+ module Signals
13
+ extend T::Sig
14
+
15
+ EXIT = 'EXIT'
16
+ NUMBERS = {
17
+ 0 => EXIT, 1 => 'HUP', 2 => 'INT', 3 => 'QUIT', 4 => 'ILL', 5 => 'TRAP',
18
+ 6 => 'ABRT', 7 => 'BUS', 8 => 'FPE', 9 => 'KILL', 10 => 'USR1',
19
+ 11 => 'SEGV', 12 => 'USR2', 13 => 'PIPE', 14 => 'ALRM', 15 => 'TERM',
20
+ 17 => 'CHLD', 18 => 'CONT', 19 => 'STOP', 20 => 'TSTP', 21 => 'TTIN',
21
+ 22 => 'TTOU', 23 => 'URG', 24 => 'XCPU', 25 => 'XFSZ', 26 => 'VTALRM',
22
+ 27 => 'PROF', 28 => 'WINCH', 29 => 'IO', 30 => 'PWR', 31 => 'SYS'
23
+ }.freeze
24
+ NAMES = NUMBERS.invert.freeze
25
+
26
+ sig { params(spec: String).returns(T.nilable(String)) }
27
+ def self.decode(spec)
28
+ return NUMBERS.fetch(Integer(spec, 10), nil) if spec.match?(/\A\d+\z/)
29
+
30
+ name = spec.upcase
31
+ NAMES.key?(name) ? name : nil
32
+ end
33
+
34
+ sig { params(name: String).returns(Integer) }
35
+ def self.number(name)
36
+ NAMES.fetch(name)
37
+ end
38
+
39
+ # strsignal(3)-style descriptions, as dash's jobs listing prints for a
40
+ # signalled job ("Killed", "Terminated"). Only the common ones are
41
+ # spelled out; the rest fall back to the signal name.
42
+ DESCRIPTIONS = {
43
+ 1 => 'Hangup', 2 => 'Interrupt', 3 => 'Quit', 4 => 'Illegal instruction',
44
+ 5 => 'Trace/breakpoint trap', 6 => 'Aborted', 7 => 'Bus error',
45
+ 8 => 'Floating point exception', 9 => 'Killed', 10 => 'User defined signal 1',
46
+ 11 => 'Segmentation fault', 12 => 'User defined signal 2', 13 => 'Broken pipe',
47
+ 14 => 'Alarm clock', 15 => 'Terminated'
48
+ }.freeze
49
+
50
+ sig { params(number: Integer).returns(String) }
51
+ def self.description(number)
52
+ DESCRIPTIONS.fetch(number) { NUMBERS.fetch(number, "Signal #{number}") }
53
+ end
54
+
55
+ # strsignal(3) again, for the stop signals as the jobs listing shows a
56
+ # Stopped entry: plain for ^Z's TSTP, qualified for the others (dash
57
+ # prints the same strings, probed).
58
+ STOPPED = {
59
+ 19 => 'Stopped (signal)', 20 => 'Stopped',
60
+ 21 => 'Stopped (tty input)', 22 => 'Stopped (tty output)'
61
+ }.freeze
62
+
63
+ sig { params(number: Integer).returns(String) }
64
+ def self.stop_description(number)
65
+ STOPPED.fetch(number, 'Stopped')
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,82 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Runs non-interactive shell source from `-c`, a script file, or stdin,
6
+ # command by command. It owns the batch-mode error policy: fatal
7
+ # syntax/expansion/builtin errors publish status 2, run the EXIT trap, and
8
+ # stop the remaining input.
9
+ class Source < ProgramSession
10
+ extend T::Sig
11
+
12
+ sig { params(invocation: Invocation, system: SystemCalls, state: ShellState, startup: T.nilable(Startup)).void }
13
+ def initialize(invocation, system, state:, startup: nil)
14
+ @source = T.let(invocation.source, String)
15
+ super(system, state: state, startup: startup)
16
+ end
17
+
18
+ sig { returns(Integer) }
19
+ def run
20
+ super
21
+ rescue Error => e
22
+ handle_error(e)
23
+ end
24
+
25
+ private
26
+
27
+ sig { params(_continuation: T::Boolean).returns(T.nilable(String)) }
28
+ def next_line(_continuation)
29
+ source_line.tap { |line| echo_verbose(line) }
30
+ end
31
+
32
+ # Under `set -v` (verbose) each input line is written to stderr as it is read,
33
+ # before it runs, so a `set -v`/`set +v` toggles which later lines echo (POSIX).
34
+ sig { params(line: T.nilable(String)).void }
35
+ def echo_verbose(line)
36
+ system.stderr.print(line) if line && executor.state.options.on?(:verbose)
37
+ end
38
+
39
+ sig { returns(T.nilable(String)) }
40
+ def source_line
41
+ source_lines.next
42
+ rescue StopIteration
43
+ nil
44
+ end
45
+
46
+ sig { returns(Enumerator) }
47
+ def source_lines
48
+ @source_lines ||= source.each_line
49
+ end
50
+
51
+ # A `return` not caught by a function or dot script acts like `exit` with
52
+ # that code in a non-interactive shell (POSIX): re-raise as ExitSignal so it
53
+ # settles the status and fires the EXIT trap. A stray break/continue no longer
54
+ # reaches here — with no enclosing loop the builtin is a no-op.
55
+ sig { params(program: AST::List).void }
56
+ def execute(program)
57
+ super
58
+ rescue ReturnSignal => e
59
+ raise ExitSignal, e.code
60
+ end
61
+
62
+ sig { params(error: Error).returns(Integer) }
63
+ def handle_error(error)
64
+ decision = ErrorPolicy.decision(:batch, error)
65
+ return executor.trap_runner.run_exit_trap(130) if decision == :interrupt130
66
+ return abort_with(error) if decision == :abort2
67
+
68
+ raise error
69
+ end
70
+
71
+ # A fatal error (syntax/expansion/readonly): report it, publish 2 as $?, then
72
+ # fire the EXIT trap (which may override the code via `exit`) — like dash.
73
+ sig { params(error: StandardError).returns(Integer) }
74
+ def abort_with(error)
75
+ report(error)
76
+ executor.trap_runner.run_exit_trap(2)
77
+ end
78
+
79
+ sig { returns(String) }
80
+ attr_reader :source
81
+ end
82
+ end
@@ -0,0 +1,41 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Tracks absolute source line numbers while ProgramReader buffers one complete
6
+ # program. Each Lexer sees only the current buffer, so the counter supplies the
7
+ # offset that makes word/source-line metadata absolute within the caller's
8
+ # source stream.
9
+ class SourceLineCounter
10
+ extend T::Sig
11
+
12
+ sig { void }
13
+ def initialize
14
+ @line_number = 0
15
+ @buffer_start = 1
16
+ end
17
+
18
+ sig { params(line: String).void }
19
+ def start(line)
20
+ @buffer_start = @line_number + 1
21
+ advance(line)
22
+ end
23
+
24
+ sig { params(line: String).void }
25
+ def continue(line)
26
+ advance(line)
27
+ end
28
+
29
+ sig { returns(Integer) }
30
+ def offset
31
+ @buffer_start - 1
32
+ end
33
+
34
+ private
35
+
36
+ sig { params(line: String).void }
37
+ def advance(line)
38
+ @line_number += line.each_line.count
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,47 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Runs shell text command by command in the current shell — the engine shared
6
+ # by `eval` and `.`. Reading and executing incrementally (via ProgramReader)
7
+ # lets each command shape how the next is parsed: an `alias` or function
8
+ # defined on one line takes effect on the following lines, and a syntax error
9
+ # only stops the commands after it (the earlier ones have already run), exactly
10
+ # as dash reads its input. The result starts at success and is updated by each
11
+ # non-empty command, so empty input is status 0 while $? stays live for the
12
+ # commands themselves (POSIX: eval and dot inherit the current $?).
13
+ class SourceRunner
14
+ extend T::Sig
15
+
16
+ sig { params(executor: Executor, text: String).void }
17
+ def initialize(executor, text)
18
+ @executor = executor
19
+ @lines = text.each_line.to_a
20
+ @reader = ProgramReader.new(aliases: executor.state.aliases) { @lines.shift }
21
+ @result = Status.success
22
+ end
23
+
24
+ sig { returns(Status) }
25
+ def run
26
+ loop { break if advance == :eof }
27
+ @result
28
+ end
29
+
30
+ private
31
+
32
+ # Read the next complete program and run it, returning it (or :eof) so `run`
33
+ # knows when the input is exhausted.
34
+ sig { returns(T.any(AST::List, Symbol)) }
35
+ def advance
36
+ program = @reader.next_program
37
+ execute(T.cast(program, AST::List)) unless program == :eof
38
+ program
39
+ end
40
+
41
+ sig { params(program: AST::List).void }
42
+ def execute(program)
43
+ @executor.run(program)
44
+ @result = @executor.state.last_status unless program.empty?
45
+ end
46
+ end
47
+ end