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,16 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Builtins
6
+ # `false` — does nothing, fails (status 1).
7
+ class False < Base
8
+ extend T::Sig
9
+
10
+ sig { returns(Status) }
11
+ def call
12
+ failure
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,87 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Builtins
6
+ # Resolves test/[ operands that name shell file descriptors through the
7
+ # command's logical IoTable. In production, a redirected File/pipe/tty has a
8
+ # real fileno; rewriting to that descriptor lets the kernel's existing
9
+ # File/IO predicates answer exactly as if the shell had applied dup2.
10
+ class FdOperand
11
+ extend T::Sig
12
+
13
+ PATH_OPERATORS = %w[-e -f -d -r -w -x -s -p -b -c -S -g -u].freeze
14
+ STANDARD_PATHS = { '/dev/stdin' => 0, '/dev/stdout' => 1, '/dev/stderr' => 2 }.freeze
15
+ FD_PATH = %r{\A/(?:dev|proc/self)/fd/(\d+)\z}
16
+ CLOSED_PATH = '/dev/fd/-'
17
+ CLOSED_DESCRIPTOR = '-1'
18
+
19
+ sig { params(io: IoTable).void }
20
+ def initialize(io)
21
+ @io = io
22
+ end
23
+
24
+ sig { params(operator: String, operand: String).returns(String) }
25
+ def resolve(operator, operand)
26
+ return descriptor(operand) if operator == '-t'
27
+ return operand unless PATH_OPERATORS.include?(operator)
28
+
29
+ path(operand)
30
+ end
31
+
32
+ private
33
+
34
+ # Steep 2 crashes internally on &:to_s here, so keep the explicit block.
35
+ # rubocop:disable Style/SymbolProc
36
+ sig { params(operand: String).returns(String) }
37
+ def descriptor(operand)
38
+ fd = integer(operand)
39
+ fd ? rewrite(fd, operand, CLOSED_DESCRIPTOR) { |fileno| fileno.to_s } : operand
40
+ end
41
+ # rubocop:enable Style/SymbolProc
42
+
43
+ sig { params(operand: String).returns(String) }
44
+ def path(operand)
45
+ fd = path_fd(operand)
46
+ fd ? rewrite(fd, operand, CLOSED_PATH) { |fileno| "/dev/fd/#{fileno}" } : operand
47
+ end
48
+
49
+ sig do
50
+ params(fd: Integer, original: String, closed: String,
51
+ blk: T.proc.params(fileno: Integer).returns(String)).returns(String)
52
+ end
53
+ def rewrite(fd, original, closed, &blk)
54
+ entry = @io.entry(fd)
55
+ return original unless entry
56
+ return closed if entry.closed?
57
+
58
+ replacement(entry, original, &blk)
59
+ end
60
+
61
+ sig do
62
+ params(entry: FdEntry, original: String,
63
+ blk: T.proc.params(fileno: Integer).returns(String)).returns(String)
64
+ end
65
+ def replacement(entry, original, &blk)
66
+ fileno = entry.stream.fileno
67
+ fileno ? yield(fileno) : original
68
+ rescue IOError, SystemCallError
69
+ original
70
+ end
71
+
72
+ sig { params(operand: String).returns(T.nilable(Integer)) }
73
+ def path_fd(operand)
74
+ standard = STANDARD_PATHS.fetch(operand, nil)
75
+ return standard if standard
76
+
77
+ match = FD_PATH.match(operand)
78
+ integer(match[1].to_s) if match
79
+ end
80
+
81
+ sig { params(text: String).returns(T.nilable(Integer)) }
82
+ def integer(text)
83
+ Integer(text, 10) if text.match?(/\A\s*\+?\d+\s*\z/)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,96 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Builtins
6
+ # `getopts optstring name [args...]` — parse one POSIX short option per call,
7
+ # storing the option in `name`, the next public position in OPTIND and any
8
+ # option argument in OPTARG. A leading ':' in optstring enables silent error
9
+ # mode for bad options / missing arguments.
10
+ class Getopts < Base
11
+ extend T::Sig
12
+
13
+ sig { returns(Status) }
14
+ def call
15
+ return usage if operands.size < 2
16
+
17
+ apply(GetoptsParser.new(state: state, optstring: optstring, arguments: arguments, optind: optind).call)
18
+ end
19
+
20
+ private
21
+
22
+ sig { returns(Status) }
23
+ def usage
24
+ report('getopts: Usage: getopts optstring var [arg...]')
25
+ failure(2)
26
+ end
27
+
28
+ sig { params(result: GetoptsParser::Result).returns(Status) }
29
+ def apply(result)
30
+ store(result)
31
+ report_message(result.message)
32
+ Status.new(result.exitstatus)
33
+ end
34
+
35
+ # The variable updates a getopts step produces: `name`, OPTARG, OPTIND.
36
+ sig { params(result: GetoptsParser::Result).void }
37
+ def store(result)
38
+ variables.assign(name, result.name)
39
+ update_optarg(result.optarg)
40
+ variables.assign('OPTIND', result.optind.to_s)
41
+ end
42
+
43
+ sig { params(value: T.untyped).void }
44
+ def update_optarg(value)
45
+ return if value.is_a?(Symbol) # GetoptsParser::KEEP, the only Symbol optarg
46
+
47
+ value ? variables.assign('OPTARG', value) : variables.unset('OPTARG')
48
+ end
49
+
50
+ sig { params(message: T.nilable(String)).void }
51
+ def report_message(message)
52
+ report(message) if message
53
+ end
54
+
55
+ sig { params(message: String).void }
56
+ def report(message)
57
+ stderr.puts(message)
58
+ rescue Errno::EBADF
59
+ nil
60
+ end
61
+
62
+ sig { returns(Integer) }
63
+ def optind
64
+ value = Integer(variables.get('OPTIND') || '1', 10)
65
+ value.positive? ? value : 1
66
+ rescue ArgumentError
67
+ 1
68
+ end
69
+
70
+ sig { returns(T::Array[String]) }
71
+ def arguments
72
+ operands.drop(2).empty? ? executor.state.positional.to_a : operands.drop(2)
73
+ end
74
+
75
+ sig { returns(String) }
76
+ def optstring
77
+ operands.fetch(0)
78
+ end
79
+
80
+ sig { returns(String) }
81
+ def name
82
+ operands.fetch(1)
83
+ end
84
+
85
+ sig { returns(GetoptsState) }
86
+ def state
87
+ executor.state.getopts
88
+ end
89
+
90
+ sig { returns(ShellVariables) }
91
+ def variables
92
+ executor.state.variables
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,64 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Builtins
6
+ # `hash [-r] [name ...]` — the command-location cache. With names, look each up
7
+ # in PATH and remember it; a name containing a slash or naming a builtin /
8
+ # function / keyword is a silent no-op, an unresolvable name an error (status
9
+ # 1). `-r` forgets all. With no operands, list the remembered locations, one
10
+ # path per line, sorted by name.
11
+ #
12
+ # Divergence: rush does not auto-populate the cache as commands run (POSIX/dash
13
+ # remember a utility's location when it is used); only an explicit `hash name`
14
+ # records one. Observable only via `<cmd>; hash` and noted in the journal — the
15
+ # cache is otherwise bit-for-bit consistent with dash.
16
+ class Hash < Base
17
+ extend T::Sig
18
+
19
+ sig { returns(Status) }
20
+ def call
21
+ return reset if operands.first == '-r'
22
+
23
+ operands.empty? ? list : remember(operands)
24
+ end
25
+
26
+ private
27
+
28
+ sig { returns(T::Hash[String, String]) }
29
+ def cache
30
+ executor.state.command_hash
31
+ end
32
+
33
+ sig { returns(Status) }
34
+ def reset
35
+ cache.clear
36
+ success
37
+ end
38
+
39
+ sig { returns(Status) }
40
+ def list
41
+ cache.sort.each { |_name, path| stdout.puts(path) }
42
+ success
43
+ end
44
+
45
+ sig { params(names: T::Array[String]).returns(Status) }
46
+ def remember(names)
47
+ missing = names.reject { |name| known?(name) }
48
+ missing.each { |name| stderr.puts("rush: hash: #{name}: not found") }
49
+ missing.empty? ? success : failure
50
+ end
51
+
52
+ # Whether the name is a known command; a resolvable PATH command is also
53
+ # cached (a slash path or a builtin / function is known but not cached).
54
+ sig { params(name: String).returns(T::Boolean) }
55
+ def known?(name)
56
+ return true if name.include?('/')
57
+
58
+ match = CommandLookup.new(executor).find(name)
59
+ match.cache_into(cache)
60
+ match.known?
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,107 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Builtins
6
+ # Shared base of fg and bg: resolve each %id operand (no operand means
7
+ # the current job, and an empty table is "No current job", like dash),
8
+ # refuse a job that was not created under job control — dash's per-job
9
+ # jobctl bit, independent of the monitor flag now in force (probed
10
+ # across set -m/+m flips) — and resume survivors with SIGCONT to the
11
+ # job's process group. dash loops multiple operands for both commands;
12
+ # the last one's status is the builtin's.
13
+ class JobResume < Base
14
+ extend T::Sig
15
+
16
+ sig { returns(Status) }
17
+ def call
18
+ specs = operands.empty? ? [nil] : operands
19
+ specs.map { |spec| resume_one(spec) }.fetch(-1)
20
+ rescue JobError => e
21
+ bad(e.message)
22
+ end
23
+
24
+ private
25
+
26
+ # A job outside job control aborts the whole command (dash's sh_error:
27
+ # later operands never run), through the same status-2 error path as a
28
+ # bad %id.
29
+ sig { params(spec: T.nilable(String)).returns(Status) }
30
+ def resume_one(spec)
31
+ job = JobSpec.resolve(executor.jobs, spec || '%+')
32
+ raise JobError, refusal(spec) unless job.controlled?
33
+
34
+ resume(job)
35
+ end
36
+
37
+ # The subclass's whole move: bg lists and leaves the job running in
38
+ # the background; fg reattaches and waits.
39
+ sig { params(_job: JobTable::Job).returns(Status) }
40
+ def resume(_job)
41
+ # :nocov: overridden by fg/bg
42
+ raise NotImplementedError
43
+ # :nocov:
44
+ end
45
+
46
+ sig { params(spec: T.nilable(String)).returns(String) }
47
+ def refusal(spec)
48
+ spec ? "job #{spec} not created under job control" : 'job not created under job control'
49
+ end
50
+
51
+ sig { params(message: String).returns(Status) }
52
+ def bad(message)
53
+ stderr.puts("#{argv.fetch(0)}: #{message}")
54
+ failure(2)
55
+ end
56
+ end
57
+
58
+ # `fg [%id...]`: bring a job to the foreground — the command line goes
59
+ # to stdout (an empty placeholder until the command-text column lands
60
+ # with rush-mv8.6), SIGCONT resumes the group, the terminal follows it
61
+ # (JobControl#foreground), and the shell waits as for any foreground
62
+ # job: the job's status becomes $?, a ^Z parks it Stopped again under
63
+ # the same number, and a finished job leaves the table (dash frees the
64
+ # entry it fg-waited).
65
+ class Fg < JobResume
66
+ extend T::Sig
67
+
68
+ private
69
+
70
+ sig { params(job: JobTable::Job).returns(Status) }
71
+ def resume(job)
72
+ stdout.puts(job.text)
73
+ executor.job_control.foreground(job.members, text: job.text) do
74
+ job.continue(executor.system)
75
+ settle(job)
76
+ end
77
+ end
78
+
79
+ # A finished job answers from memory (dash's fg on a dead-but-
80
+ # remembered entry: getstatus, probed 137); a live one waits every
81
+ # member (JobTable#settle_members). Either way a signal death reports
82
+ # like any foreground wait's (dash: fg + kill prints Killed).
83
+ sig { params(job: JobTable::Job).returns(Status) }
84
+ def settle(job)
85
+ verdict = SignalReport.report(job.finished? ? job.status : executor.jobs.settle_members(job), stderr)
86
+ executor.jobs.forget(job) unless verdict.stopped?
87
+ verdict
88
+ end
89
+ end
90
+
91
+ # `bg [%id...]`: resume a stopped job in the background — "[n] " and the
92
+ # (mv8.6) command line to stdout, SIGCONT, the entry runs on in the
93
+ # table and the terminal stays with the shell.
94
+ class Bg < JobResume
95
+ extend T::Sig
96
+
97
+ private
98
+
99
+ sig { params(job: JobTable::Job).returns(Status) }
100
+ def resume(job)
101
+ job.continue(executor.system)
102
+ stdout.puts("[#{job.number}] #{job.text}")
103
+ success
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,86 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Builtins
6
+ # `jobs [-l|-p] [%id...]`: list background jobs, newest first, + and -
7
+ # marking the current and previous job, states Running / Done / Done(n) /
8
+ # a signal description. -l inserts the pid after the mark; -p prints only
9
+ # pids. The command column (34) stays empty away from a tty, exactly like
10
+ # dash, which keeps no command text there. Displaying a finished job
11
+ # frees its entry (-p does not); a bad %id reports No such job with
12
+ # status 2 after the entries already rendered (dash-verified).
13
+ class Jobs < Base
14
+ extend T::Sig
15
+
16
+ # The flag picks a rendering style; anything else is an illegal option.
17
+ STYLES = T.let({ nil => :short_line, '-l' => :long_line, '-p' => :pid_line }.freeze,
18
+ T::Hash[T.nilable(String), Symbol])
19
+
20
+ sig { returns(Status) }
21
+ def call
22
+ executor.jobs.poll
23
+ flag, args = split_flag(operands)
24
+ STYLES.key?(flag) ? show(STYLES.fetch(flag), args) : bad("Illegal option #{flag}")
25
+ rescue JobError => e
26
+ bad(e.message)
27
+ end
28
+
29
+ private
30
+
31
+ # tuple: the optional leading -flag and the remaining %id operands.
32
+ sig { params(args: T::Array[String]).returns([T.nilable(String), T::Array[String]]) }
33
+ def split_flag(args)
34
+ args.first&.start_with?('-') ? [args.fetch(0), args.drop(1)] : [nil, args]
35
+ end
36
+
37
+ sig { params(style: Symbol, args: T::Array[String]).returns(Status) }
38
+ def show(style, args)
39
+ line = method(style)
40
+ each_selected(args) { |job| line.call(job) }
41
+ success
42
+ end
43
+
44
+ # Resolution stays interleaved with rendering so a bad %id aborts after
45
+ # the entries before it have already been printed, as dash does.
46
+ sig { params(args: T::Array[String], blk: T.proc.params(job: JobTable::Job).returns(T.untyped)).void }
47
+ def each_selected(args, &blk)
48
+ return executor.jobs.ordered.each(&blk) if args.empty?
49
+
50
+ args.each { |spec| yield(JobSpec.resolve(executor.jobs, spec)) }
51
+ end
52
+
53
+ sig { params(job: JobTable::Job).void }
54
+ def pid_line(job)
55
+ stdout.puts(job.pid)
56
+ end
57
+
58
+ sig { params(job: JobTable::Job).void }
59
+ def long_line(job)
60
+ report(job, "#{job.pid} ")
61
+ end
62
+
63
+ sig { params(job: JobTable::Job).void }
64
+ def short_line(job)
65
+ report(job, '')
66
+ end
67
+
68
+ # dash's showjob line (JobReport). Displaying counts as reporting (no
69
+ # pre-prompt notification follows), a displayed finished job is freed —
70
+ # a Stopped one is not: it is still alive, and dash keeps listing it
71
+ # (probed) — and the bare-pid style never frees or reports.
72
+ sig { params(job: JobTable::Job, pid_field: String).void }
73
+ def report(job, pid_field)
74
+ stdout.puts(JobReport.line(executor.jobs, job, pid_field))
75
+ job.reported
76
+ executor.jobs.forget(job) if job.finished?
77
+ end
78
+
79
+ sig { params(message: String).returns(Status) }
80
+ def bad(message)
81
+ stderr.puts("jobs: #{message}")
82
+ failure(2)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,152 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Builtins
6
+ # `kill [-s sigspec | -sigspec | -signum] pid|%id...` sends a signal (TERM
7
+ # by default) to each process; `kill -0 pid` only checks that it exists.
8
+ # Signal names follow Rush::Signals (case-insensitive, no "SIG" prefix);
9
+ # numbers go straight to the OS so the unnamed ones (16, RT, ...) still
10
+ # work. A %id targets the job's process group (-pid, as dash) — without
11
+ # the terminal half of job control no such group exists, so delivery
12
+ # fails "no such process", status 1, and the job survives (dash parity).
13
+ # `kill -l N` prints the signal name for a number or wait status (128 +
14
+ # signal), and a bare `kill -l` lists the known names. A bad spec or an
15
+ # unresolved %id exits 2; a delivery that fails exits 1.
16
+ class Kill < Base
17
+ extend T::Sig
18
+
19
+ sig { returns(Status) }
20
+ def call
21
+ return usage if operands.empty?
22
+ return list(operands.drop(1)) if operands.first == '-l'
23
+
24
+ spec, pids = parse
25
+ send_signal(spec, pids)
26
+ end
27
+
28
+ private
29
+
30
+ # tuple, not Array: parse is destructured into send_signal's two arguments.
31
+ sig { returns([String, T::Array[String]]) }
32
+ # mutant:disable -- for incomplete `-s`, the returned sigspec is ignored:
33
+ # `send_signal` reports usage before resolving any signal when no pid exists.
34
+ def parse
35
+ first = operands.fetch(0)
36
+ return [operands.fetch(1, ''), operands.drop(2)] if first == '-s'
37
+ return [first.delete_prefix('-'), operands.drop(1)] if flag?(first)
38
+
39
+ ['TERM', operands]
40
+ end
41
+
42
+ sig { params(arg: String).returns(T::Boolean) }
43
+ def flag?(arg)
44
+ arg.start_with?('-') && !arg.eql?('-')
45
+ end
46
+
47
+ sig { params(spec: String, pids: T::Array[String]).returns(Status) }
48
+ def send_signal(spec, pids)
49
+ return usage if pids.empty?
50
+
51
+ signal = resolve(spec)
52
+ signal ? deliver(signal, pids) : bad("#{spec}: invalid signal specification")
53
+ rescue JobError => e
54
+ bad(e.message)
55
+ end
56
+
57
+ # A numeric spec goes straight to the OS (Integer); a name decodes to its
58
+ # canonical String, or nil when the spec is unknown.
59
+ sig { params(spec: String).returns(T.nilable(T.any(Integer, String))) }
60
+ def resolve(spec)
61
+ return Integer(spec) if spec.match?(/\A\d+\z/)
62
+
63
+ Signals.decode(spec)
64
+ end
65
+
66
+ sig { params(signal: T.any(Integer, String), pids: T::Array[String]).returns(Status) }
67
+ def deliver(signal, pids)
68
+ failed = pids.reject { |pid| send_to(signal, pid) }
69
+ failed.empty? ? success : failure
70
+ end
71
+
72
+ # Returns a truthy OS delivery count on success, nil when delivery failed.
73
+ sig { params(signal: T.any(Integer, String), pid: String).returns(T.nilable(Integer)) }
74
+ def send_to(signal, pid)
75
+ target = os_target(pid)
76
+ executor.system.kill(signal, target)
77
+ rescue SystemCallError, ArgumentError, TypeError
78
+ oops("#{pid}: no such process")
79
+ end
80
+
81
+ # A %id names the job's process group: negative pid, like dash. An
82
+ # unresolved %id raises JobError past the delivery loop (status 2).
83
+ sig { params(pid: String).returns(Integer) }
84
+ def os_target(pid)
85
+ return -JobSpec.resolve(executor.jobs, pid).pid if pid.start_with?('%')
86
+
87
+ Integer(pid)
88
+ end
89
+
90
+ sig { params(args: T::Array[String]).returns(Status) }
91
+ def list(args)
92
+ args.empty? ? list_all : list_one(args.fetch(0))
93
+ end
94
+
95
+ sig { returns(Status) }
96
+ def list_all
97
+ Signals::NUMBERS.each { |num, name| stdout.puts(name) if num.nonzero? }
98
+ success
99
+ end
100
+
101
+ sig { params(arg: String).returns(Status) }
102
+ def list_one(arg)
103
+ name = listed_signal_name(arg)
104
+ name ? ok(name) : invalid_signal(arg)
105
+ end
106
+
107
+ sig { params(arg: String).returns(T.nilable(String)) }
108
+ def listed_signal_name(arg)
109
+ return unless arg.match?(/\A\d+\z/)
110
+
111
+ num = adjust(Integer(arg))
112
+ return unless num.positive?
113
+
114
+ Signals::NUMBERS.fetch(num, nil)
115
+ end
116
+
117
+ sig { params(num: Integer).returns(Integer) }
118
+ # mutant:disable -- for `kill -l 128`, `128` and `0` are both invalid;
119
+ # changing the wait-status boundary is observationally equivalent there.
120
+ def adjust(num)
121
+ num >= 129 ? num - 128 : num
122
+ end
123
+
124
+ sig { params(name: String).returns(Status) }
125
+ def ok(name)
126
+ stdout.puts(name)
127
+ success
128
+ end
129
+
130
+ sig { params(spec: T.nilable(String)).returns(Status) }
131
+ def invalid_signal(spec)
132
+ bad("#{spec}: invalid signal specification")
133
+ end
134
+
135
+ sig { returns(Status) }
136
+ def usage
137
+ bad('usage: kill [-s sigspec | -signum] pid ...')
138
+ end
139
+
140
+ sig { params(message: String).returns(Status) }
141
+ def bad(message)
142
+ stderr.puts("kill: #{message}")
143
+ failure(2)
144
+ end
145
+
146
+ sig { params(message: String).returns(NilClass) }
147
+ def oops(message)
148
+ stderr.puts("kill: #{message}")
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,40 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Builtins
6
+ # `local name[=value] ...` — inside a function, mark each name as local so its
7
+ # prior value is restored when the function returns. A bare name keeps its
8
+ # current value (dash semantics); name=value also assigns. Used outside a
9
+ # function it is an error with status 2.
10
+ class Local < Base
11
+ extend T::Sig
12
+
13
+ sig { returns(Status) }
14
+ def call
15
+ return not_in_function unless state.variables.in_function?
16
+
17
+ operands.each { |operand| declare(operand) }
18
+ success
19
+ end
20
+
21
+ private
22
+
23
+ sig { returns(ShellState) }
24
+ def state
25
+ executor.state
26
+ end
27
+
28
+ sig { params(operand: String).void }
29
+ def declare(operand)
30
+ state.variables.declare_local_operand(operand)
31
+ end
32
+
33
+ sig { returns(Status) }
34
+ def not_in_function
35
+ stderr.puts('rush: local: not in a function')
36
+ failure(2)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,45 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Builtins
6
+ # Shared engine of `break` and `continue` (POSIX 2.9.5). Both are successful
7
+ # builtins, so they set $? to 0 before unwinding: the status is seen after
8
+ # the loop, and by the next iteration's body. With no enclosing loop the
9
+ # builtin is a no-op; a level past the actual nesting is clamped, so
10
+ # `break 5` in two loops exits both. The level (>= 1) is validated even
11
+ # with no loop, so `break abc` still aborts. Subclasses name the signal
12
+ # that carries the level up to the enclosing loop runner.
13
+ class LoopJump < Base
14
+ extend T::Sig
15
+
16
+ sig { returns(Status) }
17
+ def call
18
+ level = validated
19
+ executor.state.record_status(success)
20
+ raise signal, clamped(level) if executor.state.loops.any?
21
+
22
+ success
23
+ end
24
+
25
+ private
26
+
27
+ # The LoopControl subclass this builtin unwinds with.
28
+ sig { returns(T.class_of(LoopControl)) }
29
+ def signal
30
+ raise NotImplementedError
31
+ end
32
+
33
+ sig { returns(Integer) }
34
+ def validated
35
+ first = operands.first
36
+ first ? numeric_operand(first, min: 1) : 1
37
+ end
38
+
39
+ sig { params(level: Integer).returns(Integer) }
40
+ def clamped(level)
41
+ [level, executor.state.loops.depth].min
42
+ end
43
+ end
44
+ end
45
+ end