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,87 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # Runs $(...) / `...`: parse the body, run it in a forked subshell whose
7
+ # stdout is a pipe, read everything the child writes, wait for it, and strip
8
+ # trailing newlines (POSIX). spawn_child/run_child are the irreducible
9
+ # fork/exit wrapper; `capture` (the child-side run) is tested directly.
10
+ class CommandSubstitution
11
+ extend T::Sig
12
+
13
+ sig { params(executor: Executor, source: String).void }
14
+ def initialize(executor, source)
15
+ @executor = executor
16
+ @source = source
17
+ end
18
+
19
+ sig { returns(String) }
20
+ def expand
21
+ read, write = @executor.system.pipe
22
+ pid = spawn_child(write)
23
+ write.close
24
+ strip(read_output(read, pid))
25
+ end
26
+
27
+ sig { params(write: T.untyped).returns(Status) }
28
+ def capture(write)
29
+ @executor.with_io(@executor.io.with(1, write)) { run_isolated }
30
+ end
31
+
32
+ private
33
+
34
+ # The body runs in a fresh errexit context (a subshell): a `set -e` failure
35
+ # ends only this substitution, leaving its status for the enclosing command.
36
+ # exit, and an uncaught return, both end the substitution with their code.
37
+ sig { returns(Status) }
38
+ def run_isolated
39
+ @executor.enter_subshell
40
+ status = run_isolated_status
41
+ Status.new(@executor.trap_runner.run_exit_trap(status.exitstatus))
42
+ end
43
+
44
+ sig { returns(Status) }
45
+ def run_isolated_status
46
+ @executor.errexit.untested { @executor.run(parse) }
47
+ rescue ExitSignal, ReturnSignal => e
48
+ @executor.state.record_status(Status.new(e.code))
49
+ end
50
+
51
+ # A substitution child killed by a signal reports on the shell's
52
+ # stderr, like dash (probed: x=$(sh -c 'kill -KILL $$') prints Killed).
53
+ sig { params(read: T.untyped, pid: T.untyped).returns(String) }
54
+ def read_output(read, pid)
55
+ output = read.read
56
+ read.close
57
+ status = SignalReport.report(@executor.jobs.await(pid), @executor.io.get(2))
58
+ @executor.record_cmd_sub_status(status)
59
+ output
60
+ end
61
+
62
+ sig { params(output: String).returns(String) }
63
+ def strip(output)
64
+ output.sub(/\n+\z/, '')
65
+ end
66
+
67
+ sig { params(write: T.untyped).returns(T.untyped) }
68
+ def spawn_child(write)
69
+ # :nocov:
70
+ @executor.system.fork { run_child(write) }
71
+ # :nocov:
72
+ end
73
+
74
+ sig { params(write: T.untyped).returns(T.untyped) }
75
+ def run_child(write)
76
+ # :nocov:
77
+ @executor.system.exit!(capture(write).exitstatus)
78
+ # :nocov:
79
+ end
80
+
81
+ sig { returns(AST::Node) }
82
+ def parse
83
+ Parser.new(Lexer.new(@source, aliases: @executor.state.aliases)).parse
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,12 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # One expanded fragment on its way to field splitting, as
7
+ # [text, splittable, break, quoted]: the text itself, whether IFS may split
8
+ # it, whether a new field opens before it regardless of IFS (the boundary
9
+ # between $@/$* elements), and whether glob metacharacters need shielding.
10
+ FieldPart = T.type_alias { [String, T::Boolean, T::Boolean, T::Boolean] }
11
+ end
12
+ end
@@ -0,0 +1,23 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # Entry point for IFS field splitting: it hands the parts and the IFS
7
+ # delimiter sets (an Ifs value) to IfsScanner, which applies the three POSIX
8
+ # cases.
9
+ class FieldSplitter
10
+ extend T::Sig
11
+
12
+ sig { params(ifs: T.nilable(String)).void }
13
+ def initialize(ifs)
14
+ @ifs = Ifs.new(ifs)
15
+ end
16
+
17
+ sig { params(parts: T::Array[FieldPart]).returns(T::Array[IfsScanner::Field]) }
18
+ def split(parts)
19
+ IfsScanner.new(@ifs).run(parts)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,48 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # Pathname expansion (step 3): a field is replaced by the sorted pathnames it
7
+ # matches; a field that matches nothing — or any field while `set -f`
8
+ # (noglob) is in effect — stays literal. IfsScanner::Field carries literal
9
+ # text separately from its lazily shielded pattern, so a data backslash is
10
+ # preserved when no pathname replaces the field.
11
+ class GlobExpander
12
+ extend T::Sig
13
+
14
+ PATHNAME_PATTERN = /(?<!\\)(?:\\\\)*(?:[*?]|\[(?:\\.|[^\\\]])*\])/
15
+
16
+ sig { params(executor: Executor).void }
17
+ def initialize(executor)
18
+ @executor = executor
19
+ end
20
+
21
+ sig { params(field: IfsScanner::Field, expanded: T::Array[String]).void }
22
+ def append(field, expanded)
23
+ pattern = field.pattern
24
+ return expanded << field.text unless expandable?(pattern)
25
+
26
+ append_matches(field, expanded, matches(pattern))
27
+ end
28
+
29
+ private
30
+
31
+ sig { params(pattern: String).returns(T::Boolean) }
32
+ def expandable?(pattern)
33
+ !@executor.state.options.on?(:noglob) && pattern.match?(PATHNAME_PATTERN)
34
+ end
35
+
36
+ sig { params(pattern: String).returns(T::Array[String]) }
37
+ def matches(pattern)
38
+ locale = @executor.state.variables.locale_settings
39
+ @executor.system.glob(pattern, locale: locale)
40
+ end
41
+
42
+ sig { params(field: IfsScanner::Field, expanded: T::Array[String], matches: T::Array[String]).void }
43
+ def append_matches(field, expanded, matches)
44
+ matches.empty? ? expanded << field.text : expanded.concat(matches)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,62 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # The IFS value, parsed once into its whitespace and non-whitespace delimiter
7
+ # sets for POSIX field splitting. Unset IFS (nil) defaults to the standard
8
+ # <space><tab><newline>; a null IFS (the empty string) leaves both sets empty,
9
+ # so no character delimits.
10
+ class Ifs
11
+ extend T::Sig
12
+
13
+ WHITESPACE = " \t\n"
14
+ DEFAULT_CHARS = WHITESPACE.chars.freeze
15
+ NO_CHARS = T.let(
16
+ [], #: Array[String]
17
+ T::Array[String]
18
+ ).freeze
19
+
20
+ sig { params(ifs: T.nilable(String)).void }
21
+ def initialize(ifs)
22
+ @ifs = ifs
23
+ @chars = ifs ? ifs.chars.uniq.freeze : DEFAULT_CHARS
24
+ @whitespace, @others = delimiter_sets
25
+ end
26
+
27
+ sig { returns(T::Boolean) }
28
+ def null?
29
+ @ifs == ''
30
+ end
31
+
32
+ sig { returns(T::Array[String]) }
33
+ attr_reader :whitespace, :others
34
+
35
+ sig { returns(T::Boolean) }
36
+ def preserve_empty_splat?
37
+ @chars.first == @others.first
38
+ end
39
+
40
+ sig { params(char: String).returns(T::Boolean) }
41
+ def whitespace?(char)
42
+ !!(@chars.include?(char) && WHITESPACE.include?(char))
43
+ end
44
+
45
+ sig { params(char: String).returns(T::Boolean) }
46
+ def other?(char)
47
+ !!(@chars.include?(char) && !WHITESPACE.include?(char))
48
+ end
49
+
50
+ private
51
+
52
+ sig { returns([T::Array[String], T::Array[String]]) }
53
+ def delimiter_sets
54
+ return [DEFAULT_CHARS, NO_CHARS] unless @ifs
55
+ return [NO_CHARS, NO_CHARS] if @chars.empty?
56
+
57
+ whitespace, others = @chars.partition { |char| WHITESPACE.include?(char) }
58
+ [whitespace.freeze, others.freeze]
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,168 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # The POSIX §2.6.5 field-splitting state machine. It runs once, left to right,
7
+ # over the expanded parts ([text, splittable, break, quoted]) so a delimiter that
8
+ # spans two adjacent unquoted expansions still yields the right (possibly
9
+ # empty) fields. IFS whitespace coalesces and is stripped at the ends; each
10
+ # non-whitespace IFS character delimits a field (so adjacent ones generate
11
+ # empty fields), while a single trailing delimiter is absorbed. Quoted text
12
+ # and a break-flagged part (the elements of $@/$*) anchor or force a field
13
+ # regardless of IFS. A trailing empty, non-anchored field is dropped.
14
+ #
15
+ # The field being built is held in @current (always present, by construction),
16
+ # with completed fields in @done — so the "there is always a current field"
17
+ # invariant is structural, not a runtime fact the reader/checker must infer.
18
+ class IfsScanner
19
+ extend T::Sig
20
+
21
+ # One output field under construction. Alongside its literal text it lazily
22
+ # records a distinct pathname pattern only when quoted metacharacters need
23
+ # shielding; a data backslash stays in both forms and is never stripped as
24
+ # if it were synthetic quote provenance.
25
+ class Field
26
+ extend T::Sig
27
+
28
+ QUOTED_PATTERN = /[\\*?\[\]\-!^]/
29
+ PATTERNS = { true => QUOTED_PATTERN, false => nil }.freeze
30
+
31
+ sig { returns(String) }
32
+ attr_reader :text
33
+
34
+ sig { void }
35
+ def initialize
36
+ @text = +''
37
+ @pattern = T.let(nil, T.nilable(String))
38
+ @real = false
39
+ end
40
+
41
+ sig { params(text: String, quoted: T::Boolean).void }
42
+ def append(text, quoted)
43
+ fragment = pattern_fragment(text, quoted)
44
+ @pattern = @text.dup if !@pattern && !fragment.equal?(text)
45
+ @pattern&.concat(fragment)
46
+ @text << text
47
+ end
48
+
49
+ sig { returns(String) }
50
+ def pattern
51
+ @pattern || @text
52
+ end
53
+
54
+ sig { void }
55
+ def mark_real
56
+ @real = true
57
+ end
58
+
59
+ sig { returns(T::Boolean) }
60
+ def empty_unreal?
61
+ @text.empty? && !@real
62
+ end
63
+
64
+ private
65
+
66
+ sig { params(text: String, quoted: T::Boolean).returns(String) }
67
+ def pattern_fragment(text, quoted)
68
+ pattern = PATTERNS.fetch(quoted)
69
+ return text unless pattern && text.match?(pattern)
70
+
71
+ text.gsub(pattern) { |meta| "\\#{meta}" }
72
+ end
73
+ end
74
+
75
+ sig { params(ifs: Ifs).void }
76
+ def initialize(ifs)
77
+ @ws = ifs.whitespace
78
+ @others = ifs.others
79
+ @preserve_empty_splat = ifs.preserve_empty_splat?
80
+ @done = T.let([], T::Array[Field])
81
+ @current = Field.new
82
+ @pending = false
83
+ @skip = true
84
+ end
85
+
86
+ sig { params(parts: T::Array[FieldPart]).returns(T::Array[Field]) }
87
+ def run(parts)
88
+ parts.each { |part| consume(part) }
89
+ result
90
+ end
91
+
92
+ private
93
+
94
+ sig { params(part: FieldPart).void }
95
+ def consume(part)
96
+ text, splittable, brk, quoted = part
97
+ break_field if brk
98
+ splittable ? text.each_char { |char| step(char) } : literal(text, quoted)
99
+ end
100
+
101
+ sig { params(char: String).void }
102
+ def step(char)
103
+ return pend if @ws.include?(char)
104
+ return open_field if @others.include?(char)
105
+
106
+ ordinary(char)
107
+ end
108
+
109
+ sig { void }
110
+ def pend
111
+ (@pending = true unless @skip)
112
+ end
113
+
114
+ sig { params(char: String).void }
115
+ def ordinary(char)
116
+ flush
117
+ @current.append(char, false)
118
+ @skip = false
119
+ end
120
+
121
+ sig { params(text: String, quoted: T::Boolean).void }
122
+ def literal(text, quoted)
123
+ flush
124
+ @current.append(text, quoted)
125
+ @current.mark_real
126
+ @skip = false
127
+ end
128
+
129
+ sig { void }
130
+ def flush
131
+ (open_field if @pending)
132
+ end
133
+
134
+ # An unquoted null $@/$* element disappears when the first IFS character
135
+ # is whitespace; otherwise its forced boundary remains an empty field.
136
+ sig { void }
137
+ def break_field
138
+ return reset_field if !@preserve_empty_splat && @current.empty_unreal?
139
+
140
+ open_field
141
+ end
142
+
143
+ sig { void }
144
+ def open_field
145
+ @done << @current
146
+ reset_field
147
+ end
148
+
149
+ sig { void }
150
+ def reset_field
151
+ @pending = false
152
+ @current = Field.new
153
+ @skip = true
154
+ end
155
+
156
+ sig { returns(T::Array[Field]) }
157
+ def result
158
+ @done << @current unless drop_last?
159
+ @done
160
+ end
161
+
162
+ sig { returns(T::Boolean) }
163
+ def drop_last?
164
+ @current.empty_unreal?
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,204 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'strscan'
5
+
6
+ module Rush
7
+ module Expansion
8
+ # Builds scalar and operator-word parts without discarding quote provenance.
9
+ class ParameterParts
10
+ extend T::Sig
11
+
12
+ sig { params(executor: Executor, quoted: T::Boolean).void }
13
+ def initialize(executor, quoted)
14
+ @executor = executor
15
+ @quoted = quoted
16
+ end
17
+
18
+ sig { params(text: String).returns(T::Array[FieldPart]) }
19
+ def scalar(text)
20
+ [[text, !@quoted, false, @quoted]]
21
+ end
22
+
23
+ sig { params(text: String).returns(T::Array[FieldPart]) }
24
+ def operator(text)
25
+ operator_word = word(text)
26
+ expanded = @executor.expander.expand_parts(operator_word, tilde: tilde_mode)
27
+ normalized(preserve_empty_at(expanded, operator_word))
28
+ end
29
+
30
+ sig { params(parts: T::Array[FieldPart]).returns(String) }
31
+ def collapse(parts)
32
+ @executor.expander.collapse(parts)
33
+ end
34
+
35
+ sig { params(text: String).returns(String) }
36
+ def pattern(text)
37
+ @executor.expander.expand_pattern(word(text), tilde: tilde_mode)
38
+ end
39
+
40
+ private
41
+
42
+ sig { params(expanded: T::Array[FieldPart], operator_word: AST::Word).returns(T::Array[FieldPart]) }
43
+ def preserve_empty_at(expanded, operator_word)
44
+ return expanded unless expanded.empty? && operator_word.segments.any?(&:quoted)
45
+
46
+ [['', false, false, true]]
47
+ end
48
+
49
+ sig { params(expanded: T::Array[FieldPart]).returns(T::Array[FieldPart]) }
50
+ def normalized(expanded)
51
+ expanded.map do |value, _splittable, brk, quoted|
52
+ protected = @quoted || quoted
53
+ [value, !protected, brk, protected]
54
+ end
55
+ end
56
+
57
+ sig { params(text: String).returns(AST::Word) }
58
+ def word(text)
59
+ @quoted ? Lexer::QuotedWord.new(text).word : Lexer::WordScanner.entire(text)
60
+ end
61
+
62
+ sig { returns(Symbol) }
63
+ def tilde_mode
64
+ @quoted ? :none : :leading
65
+ end
66
+ end
67
+
68
+ # Expands one ParamRef to quote-aware field parts: resolves the base value,
69
+ # then applies the operator form (if any). The operator word is itself expanded (so
70
+ # ${x:-$y} works) by re-scanning it into a Word and running it back through
71
+ # the expansion pipeline. `quoted` picks the word's re-scan rules: a ${...}
72
+ # inside double quotes or a here-doc keeps single quotes ordinary and
73
+ # skips tilde expansion, as dash does.
74
+ class ParameterExpander
75
+ extend T::Sig
76
+
77
+ # Operators handled here rather than by the FORMS lambdas: the ${#p} length
78
+ # and the # ## % %% pattern-removal forms.
79
+ PATTERN_REMOVAL = ['#', '##', '%', '%%'].freeze
80
+
81
+ sig { params(executor: Executor, ref: AST::ParamRef, quoted: T::Boolean).void }
82
+ def initialize(executor, ref, quoted:)
83
+ @executor = executor
84
+ @ref = ref
85
+ @quoted = quoted
86
+ @parts = ParameterParts.new(executor, quoted)
87
+ end
88
+
89
+ sig { returns(String) }
90
+ def expand
91
+ @parts.collapse(expand_parts)
92
+ end
93
+
94
+ sig { returns(T::Array[FieldPart]) }
95
+ def expand_parts
96
+ op = @ref.op
97
+ return @parts.scalar(plain) unless op
98
+ return @parts.scalar(length) if op == '#len'
99
+ return @parts.scalar(strip(op)) if PATTERN_REMOVAL.include?(op)
100
+
101
+ Parameter::FORMS.fetch(op.delete_prefix(':')).call(self)
102
+ end
103
+
104
+ # A bare $x / ${x}: under `set -u` an unset ordinary name or positional is
105
+ # an error (special parameters like $@ are exempt).
106
+ sig { returns(String) }
107
+ def plain
108
+ current = value
109
+ raise(ExpansionError, "#{@ref.name}: parameter not set") if !current && unbound?
110
+
111
+ current || ''
112
+ end
113
+
114
+ sig { returns(T.nilable(String)) }
115
+ def value
116
+ Resolver.new(@executor).resolve(@ref.name)
117
+ end
118
+
119
+ sig { returns(String) }
120
+ def value_text
121
+ value || ''
122
+ end
123
+
124
+ sig { returns(T::Boolean) }
125
+ def unset_or_null?
126
+ case value
127
+ in nil then true
128
+ in '' then colon?
129
+ else false
130
+ end
131
+ end
132
+
133
+ sig { returns(String) }
134
+ def arg
135
+ @parts.collapse(arg_parts)
136
+ end
137
+
138
+ sig { returns(T::Array[FieldPart]) }
139
+ def arg_parts
140
+ @parts.operator(argument)
141
+ end
142
+
143
+ sig { returns(T::Array[FieldPart]) }
144
+ def value_parts
145
+ @parts.scalar(value_text)
146
+ end
147
+
148
+ sig { returns(T::Array[FieldPart]) }
149
+ def empty_parts
150
+ @parts.scalar('')
151
+ end
152
+
153
+ sig { returns(T::Array[FieldPart]) }
154
+ def assign_parts
155
+ text = @parts.collapse(arg_parts)
156
+ @executor.state.variables.assign(@ref.name, text)
157
+ @parts.scalar(text)
158
+ end
159
+
160
+ sig { returns(T.noreturn) }
161
+ def raise_unset
162
+ raise(ExpansionError, "#{@ref.name}: #{message}")
163
+ end
164
+
165
+ private
166
+
167
+ sig { returns(String) }
168
+ def length
169
+ value_text.length.to_s
170
+ end
171
+
172
+ sig { params(op: String).returns(String) }
173
+ def strip(op)
174
+ PatternRemoval.new(@executor, op, value_text, pattern_arg).call
175
+ end
176
+
177
+ sig { returns(String) }
178
+ def pattern_arg
179
+ @parts.pattern(argument)
180
+ end
181
+
182
+ sig { returns(T::Boolean) }
183
+ def unbound?
184
+ @executor.state.options.on?(:nounset) && @ref.name.match?(/\A([a-zA-Z_]\w*|[1-9]\d*)\z/)
185
+ end
186
+
187
+ sig { returns(T::Boolean) }
188
+ def colon?
189
+ op = @ref.op
190
+ !!(op && op.start_with?(':'))
191
+ end
192
+
193
+ sig { returns(String) }
194
+ def message
195
+ argument.empty? ? 'parameter null or not set' : arg
196
+ end
197
+
198
+ sig { returns(String) }
199
+ def argument
200
+ @ref.arg || ''
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,22 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # Registry of ${} operator forms keyed by the operator character. Each form
7
+ # is given a ParameterExpander and returns quote-aware field parts. The ':'
8
+ # variants treat a null value like an unset one (ParameterExpander#unset_or_null?).
9
+ module Parameter
10
+ # '-' use default · '=' assign default · '?' error if unset · '+' use alternative
11
+ # The #: types each lambda's parameter: Steep does not propagate a frozen
12
+ # hash's declared value type into bare `->(param)` literals (it would for an
13
+ # un-frozen literal), so the param stays untyped without the annotation.
14
+ FORMS = {
15
+ '-' => ->(param) { param.unset_or_null? ? param.arg_parts : param.value_parts }, #: ^(ParameterExpander) -> Array[FieldPart]
16
+ '=' => ->(param) { param.unset_or_null? ? param.assign_parts : param.value_parts }, #: ^(ParameterExpander) -> Array[FieldPart]
17
+ '?' => ->(param) { param.unset_or_null? ? param.raise_unset : param.value_parts }, #: ^(ParameterExpander) -> Array[FieldPart]
18
+ '+' => ->(param) { param.unset_or_null? ? param.empty_parts : param.arg_parts } #: ^(ParameterExpander) -> Array[FieldPart]
19
+ }.freeze
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,63 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ module Expansion
6
+ # Implements ${p#pat} ${p##pat} ${p%pat} ${p%%pat}: remove from the value the
7
+ # smallest (#/%) or largest (##/%%) prefix (#) or suffix (%) that matches the
8
+ # glob pattern, leaving the value unchanged when nothing matches.
9
+ class PatternRemoval
10
+ extend T::Sig
11
+
12
+ sig { params(executor: Executor, op: String, value: String, pattern: String).void }
13
+ def initialize(executor, op, value, pattern)
14
+ @executor = executor
15
+ @op = op
16
+ @value = value
17
+ @pattern = pattern
18
+ end
19
+
20
+ sig { returns(String) }
21
+ def call
22
+ @op.start_with?('#') ? strip_prefix : strip_suffix
23
+ end
24
+
25
+ private
26
+
27
+ sig { returns(String) }
28
+ def strip_prefix
29
+ hit = order(prefixes).find { |part| match?(part) }
30
+ hit ? @value.delete_prefix(hit) : @value
31
+ end
32
+
33
+ sig { returns(String) }
34
+ def strip_suffix
35
+ hit = order(suffixes).find { |part| match?(part) }
36
+ hit ? @value.delete_suffix(hit) : @value
37
+ end
38
+
39
+ sig { returns(T::Array[String]) }
40
+ def prefixes
41
+ current = +''
42
+ @value.each_char.with_object(['']) { |char, result| result << (current += char) }
43
+ end
44
+
45
+ sig { returns(T::Array[String]) }
46
+ def suffixes
47
+ current = +''
48
+ @value.each_char.reverse_each.with_object(['']) { |char, result| result << (current = char + current) }
49
+ end
50
+
51
+ sig { params(list: T::Array[String]).returns(T::Array[String]) }
52
+ def order(list)
53
+ @op.length == 2 ? list.reverse : list
54
+ end
55
+
56
+ sig { params(part: String).returns(T::Boolean) }
57
+ def match?(part)
58
+ locale = @executor.state.variables.locale_settings
59
+ @executor.system.fnmatch?(@pattern, part, locale: locale)
60
+ end
61
+ end
62
+ end
63
+ end