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,75 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # The startup files a session runs before its main input, per the dash
6
+ # oracle (POSIX defines ENV and leaves login files unspecified): a login
7
+ # shell runs /etc/profile then $HOME/.profile; an interactive shell then
8
+ # runs the file named by ENV, its value subjected to parameter expansion
9
+ # only. Missing or unreadable files are skipped silently; errors inside a
10
+ # file follow the session's normal policy (a batch shell aborts, an
11
+ # interactive one reports and carries on); `return` is bounded to its file
12
+ # like a dot script, leaving its code in $?.
13
+ class Startup
14
+ extend T::Sig
15
+
16
+ PROFILE = '/etc/profile'
17
+
18
+ sig { params(login: T::Boolean, interactive: T::Boolean).void }
19
+ def initialize(login:, interactive:)
20
+ @login = login
21
+ @interactive = interactive
22
+ end
23
+
24
+ sig { params(executor: Executor).void }
25
+ def run(executor)
26
+ paths(executor).each { |path| source(executor, path) }
27
+ end
28
+
29
+ private
30
+
31
+ sig { params(executor: Executor).returns(T::Array[String]) }
32
+ def paths(executor)
33
+ paths = @login ? [PROFILE, home_profile(executor)] : [] #: Array[String?]
34
+ paths << env_file(executor) if @interactive
35
+ paths.compact
36
+ end
37
+
38
+ sig { params(executor: Executor).returns(T.nilable(String)) }
39
+ def home_profile(executor)
40
+ home = executor.state.variables.get('HOME').to_s
41
+ return if home.empty?
42
+
43
+ "#{home}/.profile"
44
+ end
45
+
46
+ # The ENV pathname, parameter-expanded (POSIX); nil when unset or empty.
47
+ sig { params(executor: Executor).returns(T.nilable(String)) }
48
+ def env_file(executor)
49
+ raw = executor.state.variables.get('ENV').to_s
50
+ return if raw.empty?
51
+
52
+ executor.expander.expand_value(ParamText.new(raw).word, tilde: :none)
53
+ end
54
+
55
+ sig { params(executor: Executor, path: String).void }
56
+ def source(executor, path)
57
+ text = read(executor, path)
58
+ run_file(executor, text) if text
59
+ end
60
+
61
+ sig { params(executor: Executor, path: String).returns(T.nilable(String)) }
62
+ def read(executor, path)
63
+ executor.system.read_file(path)
64
+ rescue SystemCallError
65
+ nil
66
+ end
67
+
68
+ sig { params(executor: Executor, text: String).void }
69
+ def run_file(executor, text)
70
+ SourceRunner.new(executor, text).run
71
+ rescue ReturnSignal => e
72
+ executor.state.record_status(Status.new(e.code))
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,93 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Immutable result of running a command. $? can hold a value wider than a byte:
6
+ # dash keeps `return 300` as 300 in-process, and only wraps to 0-255 at a real
7
+ # process boundary (exit!/the shell's own exit, where the OS truncates). The
8
+ # operand is validated non-negative and <= INT_MAX before reaching here.
9
+ # Under job control a WUNTRACED wait can answer for a job that STOPPED rather
10
+ # than ended: stopsig carries the stopping signal, and the exitstatus is
11
+ # dash's view of the pair — normally 128+stopsig, but a mixed pipeline keeps
12
+ # the last stage's exit code while the job as a whole is stopped (probed).
13
+ class Status
14
+ extend T::Sig
15
+
16
+ sig { returns(Integer) }
17
+ attr_reader :exitstatus
18
+
19
+ sig { returns(T.nilable(Integer)) }
20
+ attr_reader :stopsig
21
+
22
+ sig { returns(T.nilable(Integer)) }
23
+ attr_reader :termsig
24
+
25
+ sig do
26
+ params(exitstatus: Integer, stopsig: T.nilable(Integer), termsig: T.nilable(Integer),
27
+ coredump: T.nilable(T::Boolean)).void
28
+ end
29
+ def initialize(exitstatus, stopsig: nil, termsig: nil, coredump: nil)
30
+ @exitstatus = exitstatus
31
+ @stopsig = stopsig
32
+ @termsig = termsig
33
+ @coredump = coredump
34
+ end
35
+
36
+ # The OS recorded a core dump alongside the killing signal (WCOREDUMP,
37
+ # nil when no dump information accompanied the status): SignalReport
38
+ # suffixes its message with " (core dumped)" like dash.
39
+ sig { returns(T::Boolean) }
40
+ def coredump?
41
+ !!@coredump
42
+ end
43
+
44
+ sig { returns(T::Boolean) }
45
+ def success?
46
+ exitstatus.zero?
47
+ end
48
+
49
+ # The job this status describes is stopped, not gone: the foreground
50
+ # wait hands it back to the prompt as a Stopped job-table entry.
51
+ sig { returns(T::Boolean) }
52
+ def stopped?
53
+ !!@stopsig
54
+ end
55
+
56
+ # The same verdict, carrying a sibling stage's stop signal: dash keeps
57
+ # the last stage's exit code even when an earlier stage's ^Z parked the
58
+ # whole pipeline (probed: stopped|exit5 answers 5, with the job
59
+ # Stopped), so the job's stoppedness rides along on the chosen status.
60
+ sig { params(stopsig: T.nilable(Integer)).returns(Status) }
61
+ def with_stop(stopsig)
62
+ stopped? || !stopsig ? self : Status.new(exitstatus, stopsig: stopsig)
63
+ end
64
+
65
+ sig { returns(Status) }
66
+ def self.success
67
+ new(0)
68
+ end
69
+
70
+ sig { params(code: Integer).returns(Status) }
71
+ def self.failure(code = 1)
72
+ new(code)
73
+ end
74
+
75
+ # A signalled process reports no exitstatus; POSIX maps it to 128 + signal.
76
+ # termsig is Integer? to the type-checker (nil unless signalled); the signalled
77
+ # branch here is the only one reached when exitstatus is nil, so .to_i pins it
78
+ # to a plain Integer without changing behaviour on any reachable path. A
79
+ # stopped child (WUNTRACED) maps to 128 + stopsig with the signal kept.
80
+ sig { params(process_status: Process::Status).returns(Status) }
81
+ def self.of(process_status)
82
+ return stopped(process_status.stopsig.to_i) if process_status.stopped?
83
+
84
+ signal = process_status.termsig
85
+ new(process_status.exitstatus || (signal.to_i + 128), termsig: signal, coredump: process_status.coredump?)
86
+ end
87
+
88
+ sig { params(stopsig: Integer).returns(Status) }
89
+ def self.stopped(stopsig)
90
+ new(stopsig + 128, stopsig: stopsig)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,32 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # The armed stage relay (rush-l4o): a pipeline stage of a monitor-mode job
6
+ # is a transparent member of it — a stop its own foreground wait reaps is
7
+ # re-raised onto the stage process itself, so the job's owner sees the
8
+ # member stop; after fg/bg's SIGCONT the reap loop re-waits the same
9
+ # target. dash reaches the same picture by exec-ing simple stages in
10
+ # place. Methods are explicit singletons, like Signals.
11
+ module StopRelay
12
+ extend T::Sig
13
+
14
+ # The reaped target's stop should be re-raised: relay armed AND the
15
+ # status is a stop (deaths and exits pass through untouched).
16
+ sig { params(control: JobTable::Control, status: Process::Status).returns(T::Boolean) }
17
+ def self.relay?(control, status)
18
+ control.relay? && status.stopped?
19
+ end
20
+
21
+ # Default disposition first — the -m parent left TSTP/TTOU ignored,
22
+ # while SIGSTOP takes no disposition and cannot be trapped — then the
23
+ # stop signal onto this very process. The block never runs: the
24
+ # command string wins inside trap_signal, as TrapRunner's ignores do.
25
+ sig { params(system: SystemCalls, status: Process::Status).void }
26
+ def self.raise_onto_self(system, status)
27
+ name = Signals::NUMBERS.fetch(T.must(status.stopsig))
28
+ system.trap_signal(name, 'SYSTEM_DEFAULT') { nil } unless name == 'STOP'
29
+ system.kill(name, system.pid)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,70 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ # Runs `( list )` in a forked subshell so cd/variable changes never leak to the
6
+ # parent. The child runs the body and exits with its status; the parent waits
7
+ # and adopts that status. fork/exit! are the one irreducible wrapper (covered
8
+ # by subprocess specs); the child-side `run_body` is tested directly.
9
+ class SubshellRunner
10
+ extend T::Sig
11
+
12
+ sig { params(executor: Executor, body: AST::Node).void }
13
+ def initialize(executor, body)
14
+ @executor = executor
15
+ @body = body
16
+ end
17
+
18
+ sig { returns(Status) }
19
+ def call
20
+ control = @executor.job_control
21
+ pid = control.launch { run_child }
22
+ text = control.job_text(AST::Subshell.new(@body))
23
+ status = control.foreground([pid], text: text) { @executor.jobs.await(pid) }
24
+ SignalReport.report(status, @executor.io.get(2))
25
+ end
26
+
27
+ # The subshell is a fresh top level: exit (or an uncaught return) ends it
28
+ # with that code, a stray break/continue is a no-op, and a fatal error
29
+ # (readonly, ${x:?}, ...) aborts only the subshell — the parent shell carries
30
+ # on — without letting the exception escape the fork.
31
+ sig { returns(Status) }
32
+ def run_body
33
+ @executor.enter_subshell
34
+ status = run_body_status
35
+ Status.new(@executor.trap_runner.run_exit_trap(status.exitstatus))
36
+ end
37
+
38
+ private
39
+
40
+ sig { returns(Status) }
41
+ def run_body_status
42
+ @executor.run(@body)
43
+ rescue Error => e
44
+ resolve(e)
45
+ end
46
+
47
+ sig { params(error: Error).returns(Status) }
48
+ def resolve(error)
49
+ decision = ErrorPolicy.decision(:subshell, error)
50
+ return Status.new(T.cast(error, T.any(ExitSignal, ReturnSignal)).code) if decision == :return_code
51
+ return @executor.state.last_status if decision == :last_status
52
+ return report_fatal(error) if decision == :abort2
53
+
54
+ raise error
55
+ end
56
+
57
+ sig { params(error: Error).returns(Status) }
58
+ def report_fatal(error)
59
+ @executor.io.get(2).puts("rush: #{error.message}")
60
+ Status.failure(2)
61
+ end
62
+
63
+ sig { returns(T.noreturn) }
64
+ def run_child
65
+ # :nocov:
66
+ @executor.system.exit!(run_body.exitstatus)
67
+ # :nocov:
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,155 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'fiddle'
5
+
6
+ module Rush
7
+ class SystemCalls
8
+ # Optional glibc bridge for the locale data Ruby does not expose. POSIX ERE
9
+ # matching supplies bracket classes/equivalence/collating elements/ranges;
10
+ # strcoll supplies pathname order. The fixed regex buffer is deliberately
11
+ # glibc-gated (regex_t is opaque at the Fiddle boundary).
12
+ # One cohesive native-resource boundary; splitting function loading from
13
+ # regex ownership would separate lifecycle halves only to satisfy a metric.
14
+ # rubocop:disable Metrics/ClassLength
15
+ class Collation
16
+ extend T::Sig
17
+
18
+ LC_CTYPE = 0
19
+ LC_COLLATE = 3
20
+ REG_EXTENDED = 1
21
+ REGEX_BYTES = 256
22
+ MUTEX = Thread::Mutex.new
23
+ SIGNATURES = {
24
+ setlocale: [[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOIDP],
25
+ regcomp: [[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT],
26
+ regexec: [[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T,
27
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT],
28
+ regfree: [[Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID],
29
+ strcoll: [[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT]
30
+ }.freeze
31
+
32
+ sig { void }
33
+ def initialize
34
+ @functions = T.let(load_functions, T::Hash[Symbol, T.untyped])
35
+ end
36
+
37
+ sig { returns(T::Boolean) }
38
+ def available?
39
+ @functions.any?
40
+ end
41
+
42
+ sig do
43
+ params(pattern: String, text: String, settings: T::Array[String],
44
+ fallback: T.proc.returns(T::Boolean)).returns(T::Boolean)
45
+ end
46
+ def match_shell?(pattern, text, settings, &fallback)
47
+ return yield unless available?
48
+
49
+ match?(PosixPattern.new(pattern).source, text, settings)
50
+ end
51
+
52
+ sig do
53
+ params(pattern: String, settings: T::Array[String],
54
+ fallback: T.proc.returns(T::Array[String])).returns(T::Array[String])
55
+ end
56
+ def glob(pattern, settings, &fallback)
57
+ return yield unless available?
58
+
59
+ regex = PosixPattern.new(pattern).source
60
+ matches = candidates(pattern).select { |path| match?(regex, path, settings) }
61
+ matches.sort { |left, right| compare(left, right, settings) }
62
+ end
63
+
64
+ sig { returns(T::Array[String]) }
65
+ def default_settings
66
+ %w[LC_COLLATE LC_CTYPE].map { |category| locale_value(category) }
67
+ end
68
+
69
+ sig { params(regex: String, text: String, settings: T::Array[String]).returns(T::Boolean) }
70
+ def match?(regex, text, settings)
71
+ MUTEX.synchronize do
72
+ activate(settings)
73
+ compiled(regex) { |pointer| call(:regexec, pointer, text, 0, nil, 0).zero? }
74
+ end
75
+ end
76
+
77
+ sig { params(left: String, right: String, settings: T::Array[String]).returns(Integer) }
78
+ def compare(left, right, settings)
79
+ MUTEX.synchronize do
80
+ activate(settings)
81
+ verdict = call(:strcoll, left, right)
82
+ verdict.zero? ? T.must(left.b <=> right.b) : verdict
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ sig { params(pattern: String).returns(T::Array[String]) }
89
+ def candidates(pattern)
90
+ source = PosixPattern.new(pattern).glob_source
91
+ Dir.glob(source, sort: false)
92
+ end
93
+
94
+ sig { params(category: String).returns(String) }
95
+ def locale_value(category)
96
+ values = [ENV.fetch('LC_ALL', nil), ENV.fetch(category, nil), ENV.fetch('LANG', nil)].compact
97
+ values.find { |value| !value.empty? } || 'C'
98
+ end
99
+
100
+ sig { returns(T::Hash[Symbol, T.untyped]) }
101
+ def load_functions
102
+ handle = Fiddle::Handle::DEFAULT
103
+ handle['gnu_get_libc_version']
104
+ SIGNATURES.to_h { |name, signature| [name, native_function(handle, name, signature)] }
105
+ rescue Fiddle::DLError
106
+ {}
107
+ end
108
+
109
+ sig { params(handle: T.untyped, name: Symbol, signature: T.untyped).returns(T.untyped) }
110
+ def native_function(handle, name, signature)
111
+ arguments, result = signature
112
+ Fiddle::Function.new(handle[name.to_s], arguments, result)
113
+ end
114
+
115
+ sig { params(name: Symbol, args: T.untyped).returns(T.untyped) }
116
+ def call(name, *args)
117
+ @functions.fetch(name).call(*args)
118
+ end
119
+
120
+ sig { params(regex: String, blk: T.proc.params(pointer: T.untyped).returns(T::Boolean)).returns(T::Boolean) }
121
+ def compiled(regex, &blk)
122
+ pointer = Fiddle::Pointer.malloc(REGEX_BYTES, Fiddle::RUBY_FREE)
123
+ return false if call(:regcomp, pointer, regex, REG_EXTENDED).nonzero?
124
+
125
+ free_after(pointer, &blk)
126
+ end
127
+
128
+ sig do
129
+ params(pointer: T.untyped,
130
+ blk: T.proc.params(pointer: T.untyped).returns(T::Boolean)).returns(T::Boolean)
131
+ end
132
+ def free_after(pointer, &blk)
133
+ yield(pointer)
134
+ ensure
135
+ call(:regfree, pointer)
136
+ end
137
+
138
+ sig { params(settings: T::Array[String]).void }
139
+ def activate(settings)
140
+ install(LC_COLLATE, settings.fetch(0))
141
+ install(LC_CTYPE, settings.fetch(1))
142
+ end
143
+
144
+ sig { params(category: Integer, requested: String).returns(String) }
145
+ def install(category, requested)
146
+ result = call(:setlocale, category, requested)
147
+ return requested unless result.null?
148
+
149
+ call(:setlocale, category, 'C')
150
+ 'C'
151
+ end
152
+ end
153
+ # rubocop:enable Metrics/ClassLength
154
+ end
155
+ end
@@ -0,0 +1,95 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class SystemCalls
6
+ # File-test queries for the test/[ builtin (-e -f -d -r -w -x -s -h/-L,
7
+ # the file types -p -b -c -S, the mode bits -g -u, and the fd probe -t),
8
+ # mixed into SystemCalls. Thin File/IO delegations, like the rest of the
9
+ # syscall port.
10
+ module FileTests
11
+ extend T::Sig
12
+
13
+ sig { params(path: String).returns(T::Boolean) }
14
+ def exist?(path)
15
+ File.exist?(path)
16
+ end
17
+
18
+ sig { params(path: String).returns(T::Boolean) }
19
+ def file?(path)
20
+ File.file?(path)
21
+ end
22
+
23
+ sig { params(path: String).returns(T::Boolean) }
24
+ def directory?(path)
25
+ File.directory?(path)
26
+ end
27
+
28
+ sig { params(path: String).returns(T::Boolean) }
29
+ def readable?(path)
30
+ File.readable?(path)
31
+ end
32
+
33
+ sig { params(path: String).returns(T::Boolean) }
34
+ def writable?(path)
35
+ # !! because Sorbet's stdlib RBI types File.writable? as T.nilable(Integer)
36
+ # (it returns a real Boolean at runtime, like the other File predicates here).
37
+ !!File.writable?(path)
38
+ end
39
+
40
+ sig { params(path: String).returns(T::Boolean) }
41
+ def executable?(path)
42
+ File.executable?(path)
43
+ end
44
+
45
+ sig { params(path: String).returns(T::Boolean) }
46
+ def file_nonempty?(path)
47
+ File.size?(path).to_i.positive?
48
+ end
49
+
50
+ sig { params(path: String).returns(T::Boolean) }
51
+ def symlink?(path)
52
+ File.symlink?(path)
53
+ end
54
+
55
+ sig { params(path: String).returns(T::Boolean) }
56
+ def pipe?(path)
57
+ File.pipe?(path)
58
+ end
59
+
60
+ sig { params(path: String).returns(T::Boolean) }
61
+ def blockdev?(path)
62
+ File.blockdev?(path)
63
+ end
64
+
65
+ sig { params(path: String).returns(T::Boolean) }
66
+ def chardev?(path)
67
+ File.chardev?(path)
68
+ end
69
+
70
+ sig { params(path: String).returns(T::Boolean) }
71
+ def socket?(path)
72
+ File.socket?(path)
73
+ end
74
+
75
+ sig { params(path: String).returns(T::Boolean) }
76
+ def setgid?(path)
77
+ File.setgid?(path)
78
+ end
79
+
80
+ sig { params(path: String).returns(T::Boolean) }
81
+ def setuid?(path)
82
+ File.setuid?(path)
83
+ end
84
+
85
+ # isatty(fd) for test -t: any unusable descriptor number (closed, huge,
86
+ # negative) is simply not a terminal, as in dash.
87
+ sig { params(fd: Integer).returns(T::Boolean) }
88
+ def tty_fd?(fd)
89
+ IO.new(fd, autoclose: false).tty?
90
+ rescue ArgumentError, RangeError, SystemCallError
91
+ false
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,165 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class SystemCalls
6
+ # Child-process control for the job machinery, mixed into SystemCalls:
7
+ # reaping (JobTable is the sole consumer), process grouping, terminal
8
+ # handover (tcsetpgrp/tcgetpgrp via IO#ioctl), and the platform gate.
9
+ module ProcessControl
10
+ # ioctl request codes for tcgetpgrp/tcsetpgrp, keyed on the build's
11
+ # host_os: Ruby exposes no tc[gs]etpgrp, but IO#ioctl is core and the
12
+ # codes are stable ABI constants — the asm-generic pair on Linux, the
13
+ # sizeof-encoded form on darwin and the BSDs. An unmatched Unix gets
14
+ # no codes: tcgetpgrp answers nil, the terminal is never acquired, and
15
+ # job control degrades to grouping-only (the epic's Fiddle-into-libc
16
+ # fallback was judged not worth a dependency for platforms rush cannot
17
+ # test; revisit if one materialises).
18
+ TIOCGPGRP = { linux: 0x540F, bsd: 0x40047477 }.freeze
19
+ TIOCSPGRP = { linux: 0x5410, bsd: 0x80047476 }.freeze
20
+ TERMINAL_FAMILIES = { /linux/ => :linux, /darwin|bsd|dragonfly/ => :bsd }.freeze
21
+
22
+ # EINTR-style retry: an interactive SIGINT raises Interrupted at a safe
23
+ # point inside the blocking wait; the child is dying from the same signal,
24
+ # so wait again and reap it rather than leaking a zombie.
25
+ def waitpid2(pid)
26
+ Process.waitpid2(pid)
27
+ rescue Interrupted
28
+ retry
29
+ end
30
+
31
+ # The monitor-mode blocking wait (rush-mv8.4): WUNTRACED also returns a
32
+ # child the terminal (or a kill) has STOPPED, so ^Z hands control back
33
+ # to the shell instead of hanging it — dash waits this way whenever
34
+ # mflag is on, interactive or not (probed: $? = 148 off-tty too).
35
+ def wait_stoppable(pid)
36
+ Process.waitpid2(pid, Process::WUNTRACED)
37
+ rescue Interrupted
38
+ retry
39
+ end
40
+
41
+ # Non-blocking reap of any finished child: [pid, status], or nil while
42
+ # children exist but none has exited. Raises ECHILD, like waitpid2, when
43
+ # there are no children at all.
44
+ def poll_child
45
+ Process.waitpid2(-1, Process::WNOHANG)
46
+ end
47
+
48
+ def poll_pid(pid)
49
+ Process.waitpid2(pid, Process::WNOHANG)
50
+ end
51
+
52
+ # The monitor-mode poll (pairs with wait_stoppable as poll_child pairs
53
+ # with waitpid2): WUNTRACED, so the jobs builtin also sees a background
54
+ # job freshly SIGSTOPped since the last wait.
55
+ def poll_stopped
56
+ Process.waitpid2(-1, Process::WNOHANG | Process::WUNTRACED)
57
+ end
58
+
59
+ def poll_pid_stopped(pid)
60
+ Process.waitpid2(pid, Process::WNOHANG | Process::WUNTRACED)
61
+ end
62
+
63
+ # Place a process into a process group (pid 0 = the caller, pgid 0 = its
64
+ # own pid): the grouping seam. Failure means the double-setpgid race was
65
+ # lost benignly — EACCES: the child already exec'd (its own setpgid won);
66
+ # ESRCH/EPERM: it already exited — so the group is settled either way and
67
+ # the error is swallowed, as dash void-casts the same call.
68
+ # :nocov:
69
+ def setpgid(pid, pgid)
70
+ Process.setpgid(pid, pgid)
71
+ rescue Errno::EACCES, Errno::ESRCH, Errno::EPERM
72
+ nil
73
+ end
74
+
75
+ # Fork a child directly into a process group (pgid 0 = the child's own
76
+ # pid, per POSIX, so the kernel does the leader/joiner defaulting): the
77
+ # double setpgid, issued on both sides of the fork, means whichever side
78
+ # runs first settles the group before the child can exec or the parent
79
+ # can wait — the same dance as dash's forkchild/forkparent. A foreground
80
+ # job leader is also handed the terminal (tty given), child-side before
81
+ # the body runs, exactly where dash's forkchild calls xtcsetpgrp — so
82
+ # the job can never touch the tty before it owns it.
83
+ def fork_grouped(group, tty = nil, &child_main)
84
+ pid = fork { grouped_child(group, tty, &child_main) }
85
+ setpgid(pid, group) if pid
86
+ pid
87
+ end
88
+
89
+ def grouped_child(group, tty)
90
+ setpgid(0, group)
91
+ tcsetpgrp(tty, Process.pid) if tty
92
+ yield
93
+ end
94
+
95
+ # The controlling terminal, for handing between process groups:
96
+ # /dev/tty when the process has one, else a duplicate of the first
97
+ # standard stream that is a tty (dash walks fds 2..0 the same way; the
98
+ # dup means the caller always owns — and closes — its handle, immune
99
+ # to later redirections of the original). nil without a terminal.
100
+ def open_tty
101
+ File.open('/dev/tty', 'r+')
102
+ rescue SystemCallError
103
+ [$stderr, $stdout, $stdin].find(&:tty?)&.dup
104
+ end
105
+
106
+ # The terminal's foreground process group, or nil when the fd is not a
107
+ # tty or the platform has no known request code: dash's setjobctl uses
108
+ # the same call as the "can we do the tty dance at all" probe.
109
+ def tcgetpgrp(tty)
110
+ family = terminal_family
111
+ read_pgrp(tty, TIOCGPGRP.fetch(family)) if family
112
+ end
113
+
114
+ def read_pgrp(tty, request)
115
+ buffer = [0].pack('l')
116
+ tty.ioctl(request, buffer)
117
+ buffer.unpack1('l').then { |pgid| pgid.is_a?(Integer) ? pgid : nil }
118
+ rescue SystemCallError, IOError
119
+ nil
120
+ end
121
+
122
+ # Hand the terminal to a process group. The caller's group often does
123
+ # not own the terminal at that moment (reclaiming after a foreground
124
+ # job): the kernel answers such an ioctl with SIGTTOU, and a caught
125
+ # handler would abort it with EINTR while a default disposition would
126
+ # stop the shell — so the signal is ignored for real (SIG_IGN, not a
127
+ # handler block) for the call's duration, as dash holds TTOU ignored
128
+ # around xtcsetpgrp. Failure (the group already dead, the fd revoked)
129
+ # is swallowed like setpgid's: the reclaim that follows every
130
+ # foreground job settles ownership either way.
131
+ def tcsetpgrp(tty, pgid)
132
+ family = terminal_family
133
+ return unless family
134
+
135
+ ignoring_ttou { tty.ioctl(TIOCSPGRP.fetch(family), [pgid].pack('l')) }
136
+ rescue SystemCallError, IOError
137
+ nil
138
+ end
139
+ # :nocov:
140
+
141
+ # Job control needs POSIX process groups, which Windows builds lack (the
142
+ # rush-mv8 platform gate); everywhere else Process.setpgid is real.
143
+ def job_control_supported?
144
+ !RbConfig::CONFIG['host_os'].match?(/mswin|mingw|cygwin/)
145
+ end
146
+
147
+ # Which ioctl vocabulary this build speaks.
148
+ def terminal_family
149
+ host = RbConfig::CONFIG['host_os']
150
+ TERMINAL_FAMILIES.find { |pattern, _family| host.match?(pattern) }&.last
151
+ end
152
+
153
+ private
154
+
155
+ # :nocov:
156
+ def ignoring_ttou
157
+ previous = Signal.trap('TTOU', 'IGNORE')
158
+ yield
159
+ ensure
160
+ Signal.trap('TTOU', previous || 'DEFAULT')
161
+ end
162
+ # :nocov:
163
+ end
164
+ end
165
+ end