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,83 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Shared command-by-command session over ProgramReader. Subclasses provide the
6
+ # input source and tune error policy: batch source aborts on fatal errors,
7
+ # while the REPL reports recoverable errors and keeps reading.
8
+ class ProgramSession
9
+ extend T::Sig
10
+
11
+ sig { params(system: SystemCalls, state: ShellState, startup: T.nilable(Startup)).void }
12
+ def initialize(system, state: ShellState.new(pids: ShellProcessIds.for(system)), startup: nil)
13
+ @system = system
14
+ @startup = startup
15
+ @executor = Executor.new(system: system, state: state)
16
+ InteractiveSignals.install(@executor) if state.options.on?(:interactive)
17
+ @executor.job_control.startup
18
+ @reader = ProgramReader.new(aliases: executor.state.aliases) { |continuation| next_line(continuation) }
19
+ end
20
+
21
+ sig { returns(Integer) }
22
+ def run
23
+ executor.trap_runner.run_exit_trap(session)
24
+ rescue ExitSignal => e
25
+ executor.trap_runner.run_exit_trap(e.code)
26
+ end
27
+
28
+ private
29
+
30
+ sig { returns(SystemCalls) }
31
+ attr_reader :system
32
+
33
+ sig { returns(Executor) }
34
+ attr_reader :executor
35
+
36
+ sig { returns(ProgramReader) }
37
+ attr_reader :reader
38
+
39
+ sig { returns(Integer) }
40
+ def session
41
+ run_startup
42
+ run_loop
43
+ executor.state.last_status.exitstatus
44
+ end
45
+
46
+ # Login/ENV startup files run before the main input, inside the session's
47
+ # error policy: batch subclasses let fatal errors abort (Source#run), the
48
+ # REPL recovers (its override).
49
+ sig { void }
50
+ def run_startup
51
+ @startup&.run(executor)
52
+ end
53
+
54
+ sig { void }
55
+ def run_loop
56
+ until (program = read_program) == :eof
57
+ execute(T.cast(program, AST::List)) unless program == :error
58
+ end
59
+ end
60
+
61
+ sig { returns(T.any(AST::List, Symbol)) }
62
+ def read_program
63
+ reader.next_program
64
+ end
65
+
66
+ sig { params(_continuation: T::Boolean).returns(T.nilable(String)) }
67
+ def next_line(_continuation)
68
+ # :nocov:
69
+ raise NotImplementedError
70
+ # :nocov:
71
+ end
72
+
73
+ sig { params(program: AST::List).void }
74
+ def execute(program)
75
+ executor.run(program)
76
+ end
77
+
78
+ sig { params(error: StandardError).void }
79
+ def report(error)
80
+ system.stderr.puts("rush: #{error.message}")
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,52 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # The prompt strings (POSIX 2.5.3): PS1, PS2 and the PS4 trace prefix are
6
+ # re-read from the shell variables at every use — so assignments take effect
7
+ # mid-session — and subjected to parameter expansion ONLY (via ParamText),
8
+ # so a PS4 holding $(cmd) or backticks stays literal and tracing can never
9
+ # recurse into command execution (dash strips backticks unexecuted, errors
10
+ # $(cmd) back to the raw string, and expands only $((arith)) — POSIX's
11
+ # parameter-expansion-only text wins). Defaults:
12
+ # '$ ' ('# ' for a privileged shell), '> ' and '+ '. A malformed value
13
+ # (unterminated ${, a failing ${x?} form) falls back to the raw string: a
14
+ # prompt must never break the session.
15
+ class Prompt
16
+ extend T::Sig
17
+
18
+ sig { params(executor: Executor).void }
19
+ def initialize(executor)
20
+ @executor = executor
21
+ end
22
+
23
+ sig { returns(String) }
24
+ def primary
25
+ render('PS1', @executor.system.privileged? ? '# ' : '$ ')
26
+ end
27
+
28
+ sig { returns(String) }
29
+ def continuation
30
+ render('PS2', '> ')
31
+ end
32
+
33
+ sig { returns(String) }
34
+ def trace
35
+ render('PS4', '+ ')
36
+ end
37
+
38
+ private
39
+
40
+ sig { params(name: String, default: String).returns(String) }
41
+ def render(name, default)
42
+ expand(@executor.state.variables.get(name) || default)
43
+ end
44
+
45
+ sig { params(raw: String).returns(String) }
46
+ def expand(raw)
47
+ @executor.expander.expand_value(ParamText.new(raw).word, tilde: :none)
48
+ rescue ParseError, ExpansionError
49
+ raw
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,64 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Applies command redirects on top of an IoTable and owns cleanup of streams
6
+ # opened by those redirects. Redirect-only exec is detected by comparing the
7
+ # yielded table with the executor's current base table after the block returns.
8
+ class RedirectScope
9
+ extend T::Sig
10
+
11
+ sig { params(executor: T.untyped).void }
12
+ def initialize(executor)
13
+ @executor = executor
14
+ end
15
+
16
+ # Apply the redirects on top of a base IoTable (the executor's current one
17
+ # by default), yield the result, then flush+close the files those redirects
18
+ # opened (POSIX: a later command in the same shell sees the data). Only the
19
+ # streams this command opened are closed — the diff against the base leaves
20
+ # inherited streams and pipe ends untouched. Exception: redirect-only `exec`
21
+ # commits the table as the shell's base (replace_io), so it now equals that
22
+ # base — leave those files open so they persist for the rest of the shell
23
+ # rather than closing them out from under it.
24
+ sig do
25
+ type_parameters(:U)
26
+ .params(redirects: T::Array[AST::Redirect], base: IoTable,
27
+ blk: T.proc.params(io: IoTable).returns(T.type_parameter(:U)))
28
+ .returns(T.type_parameter(:U))
29
+ end
30
+ def with_redirects(redirects, base = @executor.io, &blk)
31
+ opened = T.let([], T::Array[FdEntry])
32
+ io = apply_redirects(redirects, base, opened)
33
+ yield io
34
+ ensure
35
+ close_redirect_io(T.must(opened), io || base)
36
+ end
37
+
38
+ private
39
+
40
+ sig { params(redirects: T::Array[AST::Redirect], base: IoTable, opened: T::Array[FdEntry]).returns(IoTable) }
41
+ def apply_redirects(redirects, base, opened)
42
+ redirects.reduce(base) { |io, redirect| apply_redirect(redirect, io, opened) }
43
+ end
44
+
45
+ sig { params(redirect: AST::Redirect, io: IoTable, opened: T::Array[FdEntry]).returns(IoTable) }
46
+ def apply_redirect(redirect, io, opened)
47
+ redirected = redirect_into(redirect, io)
48
+ opened.concat(redirected.opened_over(io))
49
+ redirected
50
+ end
51
+
52
+ sig { params(opened: T::Array[FdEntry], io: IoTable).void }
53
+ def close_redirect_io(opened, io)
54
+ entries = io.equal?(@executor.io) ? opened - io.entries : opened
55
+ IoTable.close_entries(entries, @executor.system)
56
+ end
57
+
58
+ sig { params(redirect: AST::Redirect, io: IoTable).returns(IoTable) }
59
+ def redirect_into(redirect, io)
60
+ target = @executor.expander.expand_value(redirect.target)
61
+ @executor.redirections.fetch(redirect.kind).apply(redirect, target, io, @executor.system)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,58 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Redirection
6
+ # n>&m / n<&m — make fd n a duplicate of fd m by binding it to m's stream
7
+ # (the left-to-right fold means it copies whatever m points at right now),
8
+ # n>&- / n<&- — close fd n. The default fd is 1 for >& and 0 for <&. A target
9
+ # that is neither a number nor `-`, or a number whose fd is not open, is a
10
+ # "bad fd number" — a special-builtin error that aborts the shell with 2.
11
+ class DupRedirect
12
+ extend T::Sig
13
+
14
+ FD_TARGET = /\A\d+\z/
15
+
16
+ sig { params(default_fd: Integer).void }
17
+ def initialize(default_fd)
18
+ @default_fd = default_fd
19
+ end
20
+
21
+ sig { params(redirect: AST::Redirect, target: String, io: IoTable, system: SystemCalls).returns(IoTable) }
22
+ def apply(redirect, target, io, system)
23
+ fd = redirect.io_number || @default_fd
24
+ target == '-' ? io.with_closed(fd) : io.with_entry(fd, source(io, target, system))
25
+ end
26
+
27
+ private
28
+
29
+ # A number whose fd is open duplicates it; a number whose fd is not open
30
+ # (unset, or already closed by an earlier n>&-) is a non-fatal redirect
31
+ # error (status 2, shell continues); a non-number is a special-builtin
32
+ # error (aborts the shell).
33
+ sig { params(io: IoTable, target: String, system: SystemCalls).returns(FdEntry) }
34
+ def source(io, target, system)
35
+ fd = numeric(target)
36
+ entry = io.entry(fd)
37
+ raise RedirectError, "#{target}: fd not open" if entry&.closed?
38
+
39
+ return entry if entry
40
+
41
+ inherited_entry(system, fd) || raise(RedirectError, "#{target}: fd not open")
42
+ end
43
+
44
+ sig { params(system: SystemCalls, fd: Integer).returns(T.nilable(FdEntry)) }
45
+ def inherited_entry(system, fd)
46
+ stream = system.inherited_fd(fd)
47
+ FdEntry.borrowed(stream) if stream
48
+ end
49
+
50
+ sig { params(target: String).returns(Integer) }
51
+ def numeric(target)
52
+ raise BuiltinError, "Bad fd number: #{target}" unless target.match?(FD_TARGET)
53
+
54
+ Integer(target, 10)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,50 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Redirection
6
+ # Opens the (already-expanded) target file and binds it to the redirection's
7
+ # fd, returning a new IoTable. Covers <, >, >>, <> and >|, which differ only
8
+ # in open mode and default fd.
9
+ class FileRedirect
10
+ extend T::Sig
11
+
12
+ EXCLUSIVE = T.let(File::WRONLY | File::CREAT | File::EXCL, Integer)
13
+
14
+ sig do
15
+ params(mode: T.any(String, Integer), default_fd: Integer,
16
+ options: T.nilable(Options), protection: T.nilable(Symbol)).void
17
+ end
18
+ def initialize(mode, default_fd, options: nil, protection: nil)
19
+ @mode = mode
20
+ @default_fd = default_fd
21
+ @options = options
22
+ @protection = protection
23
+ end
24
+
25
+ # A failure to open the target (missing directory, permission denied, a
26
+ # directory where a file is expected) is a redirection error: the command
27
+ # is left unrun with status 2 (RedirectError), or — on a special builtin —
28
+ # aborts the shell, the escalation being handled one level up in
29
+ # CommandRunner where the command word is known.
30
+ sig { params(redirect: AST::Redirect, target: String, io: IoTable, system: SystemCalls).returns(IoTable) }
31
+ def apply(redirect, target, io, system)
32
+ io.with_owned(redirect.io_number || @default_fd, system.open_file(target, open_mode))
33
+ rescue SystemCallError
34
+ raise RedirectError, "#{target}: cannot redirect"
35
+ end
36
+
37
+ private
38
+
39
+ sig { returns(T.any(String, Integer)) }
40
+ def open_mode
41
+ noclobber? ? EXCLUSIVE : @mode
42
+ end
43
+
44
+ sig { returns(T::Boolean) }
45
+ def noclobber?
46
+ @protection == :noclobber && @options&.on?(:noclobber) == true
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,17 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Redirection
6
+ # Binds a here-document's (already-expanded) body to the redirection's fd —
7
+ # fd 0 by default — as a readable stream supplied by the SystemCalls port.
8
+ class HereDocRedirect
9
+ extend T::Sig
10
+
11
+ sig { params(redirect: AST::Redirect, body: String, io: IoTable, system: SystemCalls).returns(IoTable) }
12
+ def apply(redirect, body, io, system)
13
+ io.with_owned(redirect.io_number || 0, system.here_doc(body))
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,53 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # I/O redirection: the per-kind appliers and the registry that dispatches to them.
6
+ module Redirection
7
+ extend T::Sig
8
+
9
+ # O(1) redirection-kind -> applier lookup, populated by default_registry.
10
+ class Registry
11
+ extend T::Sig
12
+
13
+ sig { void }
14
+ def initialize
15
+ @appliers = {}
16
+ end
17
+
18
+ sig { params(kind: Symbol, applier: T.untyped).void }
19
+ def register(kind, applier)
20
+ @appliers[kind] = applier
21
+ end
22
+
23
+ sig { params(kind: Symbol).returns(T.untyped) }
24
+ def fetch(kind)
25
+ @appliers.fetch(kind)
26
+ end
27
+ end
28
+
29
+ # <> opens read-write and creates the file (POSIX), unlike the string modes.
30
+ DEFAULTS = {
31
+ in: ['r', 0, nil], out: ['w', 1, :noclobber], append: ['a', 1, nil],
32
+ readwrite: [File::RDWR | File::CREAT, 0, nil], clobber: ['w', 1, nil]
33
+ }.freeze
34
+
35
+ # Default fd for the dup operators: 1 for >&, 0 for <&.
36
+ DUPS = { dup_out: 1, dup_in: 0 }.freeze
37
+
38
+ sig { params(options: T.nilable(Options)).returns(Registry) }
39
+ def self.default_registry(options = nil)
40
+ Registry.new.tap do |registry|
41
+ DEFAULTS.each { |kind, spec| register_file(registry, kind, spec, options) }
42
+ DUPS.each { |kind, fd| registry.register(kind, DupRedirect.new(fd)) }
43
+ registry.register(:heredoc, HereDocRedirect.new)
44
+ end
45
+ end
46
+
47
+ sig { params(registry: Registry, kind: Symbol, spec: T::Array[T.untyped], options: T.nilable(Options)).void }
48
+ def self.register_file(registry, kind, spec, options)
49
+ redirect = FileRedirect.new(spec.fetch(0), spec.fetch(1), options: options, protection: spec.fetch(2))
50
+ registry.register(kind, redirect)
51
+ end
52
+ end
53
+ end
data/lib/rush/repl.rb ADDED
@@ -0,0 +1,133 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # A simple interactive read-eval-print loop over the shared ProgramReader: each
6
+ # turn reads a complete command (continuation lines prompted with PS2) and runs
7
+ # it against one persistent ShellState, so variables and functions survive
8
+ # across lines. Prompts come from the PS1/PS2 shell variables (see Prompt) and
9
+ # go to stderr; `exit` and end-of-input (Ctrl-D) end the loop; errors and
10
+ # SIGINT are reported without ending the session (see #survive). Line
11
+ # editing/history and job control are deferred to later slices.
12
+ class Repl < ProgramSession
13
+ extend T::Sig
14
+
15
+ private
16
+
17
+ # A startup-file error (broken ~/.profile or ENV file) reports and keeps
18
+ # the interactive session alive, where a batch shell would abort.
19
+ sig { void }
20
+ def run_startup
21
+ survive(nil) { super }
22
+ end
23
+
24
+ # A real syntax error reports and resumes the session; the next turn starts
25
+ # fresh at PS1 (the reader discards the broken buffer per next_program call).
26
+ sig { returns(T.any(AST::List, Symbol)) }
27
+ def read_program
28
+ survive(:error) { super }
29
+ end
30
+
31
+ sig { params(continuation: T::Boolean).returns(T.nilable(String)) }
32
+ def next_line(continuation)
33
+ prompt_line(continuation)
34
+ end
35
+
36
+ sig { params(continuation: T::Boolean).returns(T.nilable(String)) }
37
+ def prompt_line(continuation)
38
+ text = continuation ? prompt.continuation : primary_prompt
39
+ system.tty? ? edited_line(text) : plain_line(text)
40
+ end
41
+
42
+ sig { returns(String) }
43
+ def primary_prompt
44
+ executor.trap_runner.run_pending
45
+ announce_jobs
46
+ prompt.primary
47
+ end
48
+
49
+ # dash's cmdloop showjobs(SHOW_CHANGED): before each PS1 under monitor
50
+ # mode, report every job whose state changed since it was last shown,
51
+ # freeing the finished ones (probed: set +m silences the reports, and
52
+ # the lines go to stderr with the prompts).
53
+ sig { void }
54
+ def announce_jobs
55
+ jobs = executor.jobs
56
+ jobs.announce_changed(system.stderr) if jobs.control.monitor?
57
+ end
58
+
59
+ # A terminal gets Reline: line editing and in-memory history, prompt drawn
60
+ # by the editor. Reline strips the newline the reader needs to keep lines
61
+ # apart, so it is restored here.
62
+ sig { params(text: String).returns(T.nilable(String)) }
63
+ def edited_line(text)
64
+ line = system.edit_line(text)
65
+ return unless line
66
+
67
+ "#{line}\n"
68
+ end
69
+
70
+ sig { params(text: String).returns(T.nilable(String)) }
71
+ def plain_line(text)
72
+ system.stderr.print(text)
73
+ system.read_line
74
+ end
75
+
76
+ sig { returns(Prompt) }
77
+ def prompt
78
+ @prompt ||= T.let(Prompt.new(executor), T.nilable(Prompt))
79
+ end
80
+
81
+ # Each interactive turn ticks the stopped-jobs exit warning (dash's
82
+ # cmdloop decrements job_warning per iteration): a refused exit repeated
83
+ # as the very next command goes through, anything later re-arms it.
84
+ sig { params(program: AST::List).void }
85
+ def execute(program)
86
+ executor.jobs.control.tick_warning
87
+ survive(nil) { super }
88
+ end
89
+
90
+ # The interactive survival policy: a recoverable error or a SIGINT reports,
91
+ # publishes its status as $?, and hands `fallback` back so the session
92
+ # carries on where a batch shell would abort.
93
+ sig do
94
+ type_parameters(:U)
95
+ .params(fallback: T.type_parameter(:U), blk: T.proc.returns(T.type_parameter(:U)))
96
+ .returns(T.type_parameter(:U))
97
+ end
98
+ def survive(fallback, &blk)
99
+ yield
100
+ rescue Error => e
101
+ recover(e)
102
+ fallback
103
+ end
104
+
105
+ # POSIX 2.8.1: where a non-interactive shell would exit (status 2, per
106
+ # Source#abort_with), the interactive shell reports the diagnostic,
107
+ # publishes the same status as $?, and returns to the prompt. A SIGINT
108
+ # publishes 130 instead, with a fresh line and no diagnostic.
109
+ sig { params(error: Error).void }
110
+ def recover(error)
111
+ decision = ErrorPolicy.decision(:interactive, error)
112
+ return interrupt if decision == :recover130
113
+ return recover_operational(error) if decision == :recover2
114
+ return if decision == :ignore
115
+
116
+ raise error
117
+ end
118
+
119
+ sig { params(error: Error).void }
120
+ def recover_operational(error)
121
+ report(error)
122
+ executor.state.record_status(Status.failure(2))
123
+ end
124
+
125
+ # ^C: a fresh line, $? = 130 (128+SIGINT), back to the prompt (dash
126
+ # behaviour whether the signal arrived at the prompt or mid-command).
127
+ sig { void }
128
+ def interrupt
129
+ system.stderr.puts
130
+ executor.state.record_status(Status.failure(130))
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,16 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module Rush
7
+ # Production policy for Sorbet's runtime method wrappers. This bootstrapping
8
+ # method deliberately has no runtime sig: its body must run before any sig
9
+ # block evaluates. Static Sorbet and Steep still check this source normally.
10
+ module RuntimeTypeChecks
11
+ def self.configure
12
+ level = ENV.fetch('RUSH_RUNTIME_TYPECHECKS', nil) == '1' ? :always : :never
13
+ T::Configuration.default_checked_level = level
14
+ end
15
+ end
16
+ end
data/lib/rush/scope.rb ADDED
@@ -0,0 +1,79 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Dynamic variable scope and logical working directory for ShellVariables,
6
+ # layered over one Environment: `local` frames snapshot variables restored when
7
+ # a function returns, and logical PWD is mirrored into $PWD/$OLDPWD.
8
+ class Scope
9
+ extend T::Sig
10
+
11
+ sig { returns(T.nilable(String)) }
12
+ attr_reader :pwd
13
+
14
+ sig { params(environment: Environment).void }
15
+ def initialize(environment)
16
+ @environment = environment
17
+ @frames = []
18
+ @pwd = environment.get('PWD')
19
+ end
20
+
21
+ # Change the logical working directory, keeping $OLDPWD (the directory we
22
+ # left) and $PWD (the one we entered) in step — the invariant cd relies on.
23
+ sig { params(pwd: String).void }
24
+ def move_to(pwd)
25
+ @environment.assign('OLDPWD', current_pwd)
26
+ @pwd = pwd
27
+ @environment.assign('PWD', pwd)
28
+ end
29
+
30
+ # Seed the logical pwd from the OS at startup when $PWD was unset; unlike
31
+ # #move_to this records no $OLDPWD/$PWD — there is no directory we came from.
32
+ sig { params(path: String).void }
33
+ def seed_pwd(path)
34
+ return if @pwd
35
+
36
+ @pwd = path
37
+ end
38
+
39
+ sig { returns(String) }
40
+ def current_pwd
41
+ @pwd || raise(Error, 'internal invariant: PWD not seeded')
42
+ end
43
+
44
+ # A function call brackets its body with begin/end_scope; declare_local
45
+ # snapshots a variable so end_scope restores its prior value (or unsets it
46
+ # when it had none).
47
+ sig { void }
48
+ def begin_scope
49
+ @frames.push({})
50
+ end
51
+
52
+ sig { void }
53
+ def end_scope
54
+ # pop is non-nil here (paired with begin_scope); .to_a pins its type to an
55
+ # array of pairs for the checker without changing behaviour on that path.
56
+ @frames.pop.to_a.each { |name, value| restore(name, value) }
57
+ end
58
+
59
+ sig { returns(T::Boolean) }
60
+ def in_function?
61
+ @frames.any?
62
+ end
63
+
64
+ sig { params(name: String).void }
65
+ def declare_local(name)
66
+ # last frame is non-nil here (only called inside a function); fetch(-1) pins
67
+ # its type to Hash for the checker and keeps the crash-if-empty invariant.
68
+ frame = @frames.fetch(-1)
69
+ frame[name] = @environment.get(name) unless frame.key?(name)
70
+ end
71
+
72
+ private
73
+
74
+ sig { params(name: String, value: T.nilable(String)).void }
75
+ def restore(name, value)
76
+ value ? @environment.assign(name, value) : @environment.unset(name)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,47 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Accumulates a word's segments during scanning: literal characters gather
6
+ # into a pending run, #push closes that run as an unquoted LiteralSegment
7
+ # before appending a structured segment, and #word closes it once more to
8
+ # build the finished AST::Word. Shared by the scanners that assemble words
9
+ # character by character (WordScanner, HeredocBody, ParamText).
10
+ class SegmentBuffer
11
+ extend T::Sig
12
+
13
+ sig { void }
14
+ def initialize
15
+ @segments = T.let([], T::Array[AST::WordSegment[T.untyped]])
16
+ @literal = +''
17
+ end
18
+
19
+ sig { params(text: String).returns(T.self_type) }
20
+ def <<(text)
21
+ @literal << text
22
+ self
23
+ end
24
+
25
+ sig { params(segment: AST::WordSegment[T.untyped]).void }
26
+ def push(segment)
27
+ flush
28
+ @segments << segment
29
+ end
30
+
31
+ sig { returns(AST::Word) }
32
+ def word
33
+ flush
34
+ AST::Word.new(@segments)
35
+ end
36
+
37
+ private
38
+
39
+ sig { void }
40
+ def flush
41
+ return if @literal.empty?
42
+
43
+ @segments << AST::LiteralSegment.new(@literal, false)
44
+ @literal = +''
45
+ end
46
+ end
47
+ end