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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/exe/rush +10 -0
- data/grammar/shell.y +272 -0
- data/grammar/shell.y.output +2354 -0
- data/lib/rush/alias_table.rb +48 -0
- data/lib/rush/assignments.rb +48 -0
- data/lib/rush/ast/and_or.rb +37 -0
- data/lib/rush/ast/assignment.rb +20 -0
- data/lib/rush/ast/brace_group.rb +25 -0
- data/lib/rush/ast/case_node.rb +41 -0
- data/lib/rush/ast/condition_loop.rb +34 -0
- data/lib/rush/ast/for_node.rb +34 -0
- data/lib/rush/ast/function_def.rb +26 -0
- data/lib/rush/ast/if_node.rb +29 -0
- data/lib/rush/ast/list.rb +47 -0
- data/lib/rush/ast/node.rb +17 -0
- data/lib/rush/ast/param_ref.rb +59 -0
- data/lib/rush/ast/pipeline.rb +45 -0
- data/lib/rush/ast/redirect.rb +21 -0
- data/lib/rush/ast/redirected.rb +26 -0
- data/lib/rush/ast/simple_command.rb +62 -0
- data/lib/rush/ast/subshell.rb +24 -0
- data/lib/rush/ast/until_node.rb +18 -0
- data/lib/rush/ast/while_node.rb +18 -0
- data/lib/rush/ast/word.rb +44 -0
- data/lib/rush/ast/word_segment.rb +203 -0
- data/lib/rush/background_runner.rb +68 -0
- data/lib/rush/bracket_expression.rb +107 -0
- data/lib/rush/bracket_scanner.rb +64 -0
- data/lib/rush/builtins/alias_.rb +74 -0
- data/lib/rush/builtins/base.rb +96 -0
- data/lib/rush/builtins/break_.rb +19 -0
- data/lib/rush/builtins/cd.rb +127 -0
- data/lib/rush/builtins/colon.rb +16 -0
- data/lib/rush/builtins/command.rb +137 -0
- data/lib/rush/builtins/command_options.rb +103 -0
- data/lib/rush/builtins/continue_.rb +19 -0
- data/lib/rush/builtins/declare.rb +27 -0
- data/lib/rush/builtins/defaults.rb +28 -0
- data/lib/rush/builtins/dot.rb +52 -0
- data/lib/rush/builtins/echo.rb +37 -0
- data/lib/rush/builtins/eval.rb +25 -0
- data/lib/rush/builtins/exec.rb +54 -0
- data/lib/rush/builtins/exit.rb +45 -0
- data/lib/rush/builtins/export.rb +20 -0
- data/lib/rush/builtins/false_.rb +16 -0
- data/lib/rush/builtins/fd_operand.rb +87 -0
- data/lib/rush/builtins/getopts.rb +96 -0
- data/lib/rush/builtins/hash.rb +64 -0
- data/lib/rush/builtins/job_resume.rb +107 -0
- data/lib/rush/builtins/jobs.rb +86 -0
- data/lib/rush/builtins/kill.rb +152 -0
- data/lib/rush/builtins/local.rb +40 -0
- data/lib/rush/builtins/loop_jump.rb +45 -0
- data/lib/rush/builtins/printf.rb +39 -0
- data/lib/rush/builtins/printf_formatter.rb +111 -0
- data/lib/rush/builtins/pwd.rb +17 -0
- data/lib/rush/builtins/read.rb +93 -0
- data/lib/rush/builtins/read_input.rb +146 -0
- data/lib/rush/builtins/readonly.rb +20 -0
- data/lib/rush/builtins/registry.rb +36 -0
- data/lib/rush/builtins/return_.rb +24 -0
- data/lib/rush/builtins/set.rb +101 -0
- data/lib/rush/builtins/shift.rb +35 -0
- data/lib/rush/builtins/test_.rb +48 -0
- data/lib/rush/builtins/test_context.rb +24 -0
- data/lib/rush/builtins/test_expr.rb +74 -0
- data/lib/rush/builtins/test_grammar.rb +117 -0
- data/lib/rush/builtins/test_operators.rb +94 -0
- data/lib/rush/builtins/test_tokens.rb +69 -0
- data/lib/rush/builtins/times.rb +35 -0
- data/lib/rush/builtins/trap.rb +85 -0
- data/lib/rush/builtins/true_.rb +16 -0
- data/lib/rush/builtins/type_.rb +28 -0
- data/lib/rush/builtins/ulimit.rb +258 -0
- data/lib/rush/builtins/umask.rb +81 -0
- data/lib/rush/builtins/unalias.rb +46 -0
- data/lib/rush/builtins/unset.rb +40 -0
- data/lib/rush/builtins/wait.rb +127 -0
- data/lib/rush/cli.rb +31 -0
- data/lib/rush/command_assignments.rb +56 -0
- data/lib/rush/command_lookup.rb +228 -0
- data/lib/rush/command_resolution.rb +115 -0
- data/lib/rush/command_runner.rb +152 -0
- data/lib/rush/command_text.rb +140 -0
- data/lib/rush/environment.rb +134 -0
- data/lib/rush/errexit_context.rb +68 -0
- data/lib/rush/error_policy.rb +64 -0
- data/lib/rush/errors.rb +100 -0
- data/lib/rush/escape_table.rb +22 -0
- data/lib/rush/escaped_bracket.rb +49 -0
- data/lib/rush/executor.rb +164 -0
- data/lib/rush/exit_trap.rb +109 -0
- data/lib/rush/expansion/arithmetic/evaluator.rb +43 -0
- data/lib/rush/expansion/arithmetic/nodes.rb +180 -0
- data/lib/rush/expansion/arithmetic/number.rb +104 -0
- data/lib/rush/expansion/arithmetic/parser.rb +155 -0
- data/lib/rush/expansion/arithmetic/tokenizer.rb +56 -0
- data/lib/rush/expansion/arithmetic_expander.rb +38 -0
- data/lib/rush/expansion/command_substitution.rb +87 -0
- data/lib/rush/expansion/field_part.rb +12 -0
- data/lib/rush/expansion/field_splitter.rb +23 -0
- data/lib/rush/expansion/glob_expander.rb +48 -0
- data/lib/rush/expansion/ifs.rb +62 -0
- data/lib/rush/expansion/ifs_scanner.rb +168 -0
- data/lib/rush/expansion/parameter_expander.rb +204 -0
- data/lib/rush/expansion/parameter_forms.rb +22 -0
- data/lib/rush/expansion/pattern_removal.rb +63 -0
- data/lib/rush/expansion/pipeline.rb +124 -0
- data/lib/rush/expansion/read_char.rb +13 -0
- data/lib/rush/expansion/read_field_scanner.rb +159 -0
- data/lib/rush/expansion/read_splitter.rb +40 -0
- data/lib/rush/expansion/resolver.rb +23 -0
- data/lib/rush/expansion/tilde_expander.rb +102 -0
- data/lib/rush/external.rb +75 -0
- data/lib/rush/fd_entry.rb +60 -0
- data/lib/rush/for_runner.rb +50 -0
- data/lib/rush/function_frame.rb +30 -0
- data/lib/rush/function_runner.rb +37 -0
- data/lib/rush/function_table.rb +35 -0
- data/lib/rush/getopts_parser.rb +195 -0
- data/lib/rush/getopts_state.rb +104 -0
- data/lib/rush/here_doc.rb +57 -0
- data/lib/rush/interactive_signals.rb +26 -0
- data/lib/rush/invocation.rb +190 -0
- data/lib/rush/io_table.rb +94 -0
- data/lib/rush/job_control.rb +197 -0
- data/lib/rush/job_report.rb +45 -0
- data/lib/rush/job_spec.rb +51 -0
- data/lib/rush/job_table/control.rb +103 -0
- data/lib/rush/job_table/interruptible_wait.rb +70 -0
- data/lib/rush/job_table/job.rb +181 -0
- data/lib/rush/job_table.rb +194 -0
- data/lib/rush/lexer/alias_expander.rb +129 -0
- data/lib/rush/lexer/braced_reader.rb +86 -0
- data/lib/rush/lexer/case_frame.rb +114 -0
- data/lib/rush/lexer/case_tracker.rb +146 -0
- data/lib/rush/lexer/dollar_scanner.rb +53 -0
- data/lib/rush/lexer/double_quote_scanner.rb +71 -0
- data/lib/rush/lexer/heredoc_body.rb +91 -0
- data/lib/rush/lexer/heredoc_reader.rb +93 -0
- data/lib/rush/lexer/lex_state.rb +128 -0
- data/lib/rush/lexer/operator_table.rb +32 -0
- data/lib/rush/lexer/param_scanner.rb +43 -0
- data/lib/rush/lexer/paren_reader.rb +113 -0
- data/lib/rush/lexer/paren_regions.rb +74 -0
- data/lib/rush/lexer/quote_skips.rb +77 -0
- data/lib/rush/lexer/quoted_word.rb +67 -0
- data/lib/rush/lexer/scanner_predicates.rb +20 -0
- data/lib/rush/lexer/source_lines.rb +43 -0
- data/lib/rush/lexer/substitution_reader.rb +77 -0
- data/lib/rush/lexer/token_classifier.rb +144 -0
- data/lib/rush/lexer/token_predicates.rb +23 -0
- data/lib/rush/lexer/word_scanner.rb +134 -0
- data/lib/rush/lexer.rb +174 -0
- data/lib/rush/loop_control_handling.rb +29 -0
- data/lib/rush/loop_nesting.rb +50 -0
- data/lib/rush/loop_runner.rb +55 -0
- data/lib/rush/option_cluster.rb +39 -0
- data/lib/rush/options.rb +76 -0
- data/lib/rush/param_text.rb +39 -0
- data/lib/rush/parser.rb +1213 -0
- data/lib/rush/parser_support.rb +112 -0
- data/lib/rush/pattern_scanner.rb +35 -0
- data/lib/rush/pending_signals.rb +57 -0
- data/lib/rush/pipeline_runner.rb +173 -0
- data/lib/rush/pipeline_statuses.rb +45 -0
- data/lib/rush/positional.rb +45 -0
- data/lib/rush/posix_pattern.rb +78 -0
- data/lib/rush/program_input.rb +89 -0
- data/lib/rush/program_reader.rb +69 -0
- data/lib/rush/program_session.rb +83 -0
- data/lib/rush/prompt.rb +52 -0
- data/lib/rush/redirect_scope.rb +64 -0
- data/lib/rush/redirection/dup_redirect.rb +58 -0
- data/lib/rush/redirection/file_redirect.rb +50 -0
- data/lib/rush/redirection/here_doc_redirect.rb +17 -0
- data/lib/rush/redirection/registry.rb +53 -0
- data/lib/rush/repl.rb +133 -0
- data/lib/rush/runtime_type_checks.rb +16 -0
- data/lib/rush/scope.rb +79 -0
- data/lib/rush/segment_buffer.rb +47 -0
- data/lib/rush/shell_parameters.rb +77 -0
- data/lib/rush/shell_pattern.rb +80 -0
- data/lib/rush/shell_state.rb +163 -0
- data/lib/rush/shell_variables.rb +67 -0
- data/lib/rush/signal_report.rb +41 -0
- data/lib/rush/signals.rb +68 -0
- data/lib/rush/source.rb +82 -0
- data/lib/rush/source_line_counter.rb +41 -0
- data/lib/rush/source_runner.rb +47 -0
- data/lib/rush/startup.rb +75 -0
- data/lib/rush/status.rb +93 -0
- data/lib/rush/stop_relay.rb +32 -0
- data/lib/rush/subshell_runner.rb +70 -0
- data/lib/rush/system_calls/collation.rb +155 -0
- data/lib/rush/system_calls/file_tests.rb +95 -0
- data/lib/rush/system_calls/process_control.rb +165 -0
- data/lib/rush/system_calls/process_identity.rb +43 -0
- data/lib/rush/system_calls/resource_limits.rb +51 -0
- data/lib/rush/system_calls.rb +206 -0
- data/lib/rush/terminal.rb +126 -0
- data/lib/rush/trap_runner.rb +169 -0
- data/lib/rush/trap_table.rb +49 -0
- data/lib/rush/umask_mode.rb +133 -0
- data/lib/rush/version.rb +6 -0
- data/lib/rush.rb +190 -0
- metadata +527 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
module Expansion
|
|
6
|
+
# Orchestrates the ordered POSIX word expansion. Each segment expands itself
|
|
7
|
+
# to one or more parts ([text, splittable, break_before, quoted]); unquoted results
|
|
8
|
+
# undergo IFS field splitting and then pathname expansion. The one multi-field
|
|
9
|
+
# case is "$@"/$@, which yields one part per positional parameter with a
|
|
10
|
+
# forced field break between them ($* always joins to a scalar). Field
|
|
11
|
+
# splitting carries quote provenance into the pathname-expansion boundary.
|
|
12
|
+
class Pipeline
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
# Tilde expansion strategy per mode (its value is a word's segment list).
|
|
16
|
+
TILDE_EXPANDERS = { none: NoTilde, leading: TildeExpander, assignment: AssignmentTilde }.freeze
|
|
17
|
+
|
|
18
|
+
sig { params(executor: Executor).void }
|
|
19
|
+
def initialize(executor)
|
|
20
|
+
@executor = executor
|
|
21
|
+
@glob_expander = GlobExpander.new(executor)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Argv expansion: expand each word to fields (splitting unquoted on the IFS
|
|
25
|
+
# value in force after that word's substitutions), then pathname-expand.
|
|
26
|
+
sig { params(words: T::Array[AST::Word]).returns(T::Array[String]) }
|
|
27
|
+
def expand(words)
|
|
28
|
+
expanded = T.let([], T::Array[String])
|
|
29
|
+
words.each { |word| append_word(word, expanded) }
|
|
30
|
+
expanded
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Assignment RHS / redirection target / operator word: one field, no split.
|
|
34
|
+
# Tilde expands at the leading position by default; assignment context also
|
|
35
|
+
# expands after colons, and arithmetic opts out (~ is bitwise not there).
|
|
36
|
+
sig { params(word: T.any(AST::Word, HereDoc), tilde: Symbol).returns(String) }
|
|
37
|
+
def expand_value(word, tilde: :leading)
|
|
38
|
+
collapse(expand_parts(word, tilde: tilde))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Expand without splitting or globbing while retaining each segment's
|
|
42
|
+
# quote provenance for a surrounding ${...} operator word.
|
|
43
|
+
sig { params(word: T.any(AST::Word, HereDoc), tilde: Symbol).returns(T::Array[FieldPart]) }
|
|
44
|
+
def expand_parts(word, tilde: :leading)
|
|
45
|
+
expanded = T.let([], T::Array[FieldPart])
|
|
46
|
+
tilde_expand(word.segments, tilde).each { |segment| append_field_parts(segment, expanded) }
|
|
47
|
+
expanded
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { params(expanded: T::Array[FieldPart]).returns(String) }
|
|
51
|
+
def collapse(expanded)
|
|
52
|
+
result = +''
|
|
53
|
+
separator = field_separator
|
|
54
|
+
expanded.each { |text, _splittable, brk, _quoted| result << (brk ? separator : '') << text }
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A case/removal pattern keeps quoting as backslash shielding so quoted
|
|
59
|
+
# metacharacters remain literal when ShellPattern compiles the result.
|
|
60
|
+
sig { params(pattern: AST::Word, tilde: Symbol).returns(String) }
|
|
61
|
+
def expand_pattern(pattern, tilde: :leading)
|
|
62
|
+
result = +''
|
|
63
|
+
tilde_expand(pattern.segments, tilde).each { |segment| result << expanded_pattern(segment) }
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
sig { params(segment: AST::WordSegment[T.untyped]).returns(String) }
|
|
70
|
+
def expanded_pattern(segment)
|
|
71
|
+
escape_if_quoted(segment, segment.expand(@executor))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig { params(word: AST::Word, expanded: T::Array[String]).void }
|
|
75
|
+
def append_word(word, expanded)
|
|
76
|
+
parts = expand_parts(word)
|
|
77
|
+
FieldSplitter.new(ifs).split(parts).each { |field| @glob_expander.append(field, expanded) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
sig { params(segments: T::Array[AST::WordSegment[T.untyped]], mode: Symbol).returns(T::Array[AST::WordSegment[T.untyped]]) }
|
|
81
|
+
def tilde_expand(segments, mode)
|
|
82
|
+
TILDE_EXPANDERS.fetch(mode).new(@executor, segments).expand
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sig { params(segment: AST::WordSegment[T.untyped], expanded: T::Array[FieldPart]).void }
|
|
86
|
+
def append_field_parts(segment, expanded)
|
|
87
|
+
return append_splat_parts(segment, expanded) if segment.splat?
|
|
88
|
+
|
|
89
|
+
segment.append_field_parts(@executor, expanded)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Glob metacharacters in quoted text are escaped so they match literally;
|
|
93
|
+
# unquoted text keeps them active.
|
|
94
|
+
sig { params(segment: AST::WordSegment[T.untyped], text: String).returns(String) }
|
|
95
|
+
def escape_if_quoted(segment, text)
|
|
96
|
+
segment.quoted ? escape(text) : text
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { params(text: String).returns(String) }
|
|
100
|
+
def escape(text)
|
|
101
|
+
text.gsub(/[\\*?\[\]\-!^]/) { |meta| "\\#{meta}" }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
sig { params(segment: AST::WordSegment[T.untyped], expanded: T::Array[FieldPart]).void }
|
|
105
|
+
def append_splat_parts(segment, expanded)
|
|
106
|
+
quoted = segment.quoted
|
|
107
|
+
@executor.state.positional.to_a.each_with_index do |element, index|
|
|
108
|
+
expanded << [element, !quoted, index.positive?, quoted]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
sig { returns(String) }
|
|
113
|
+
def field_separator
|
|
114
|
+
value = ifs
|
|
115
|
+
value ? (value.each_char.first || '') : ' '
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
sig { returns(T.nilable(String)) }
|
|
119
|
+
def ifs
|
|
120
|
+
@executor.state.variables.get('IFS')
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
module Expansion
|
|
6
|
+
# One annotated character of a line consumed by the `read` builtin, as
|
|
7
|
+
# [char, escaped]: the character itself and whether a backslash escaped it
|
|
8
|
+
# (an escaped character keeps its literal value and never acts as an IFS
|
|
9
|
+
# delimiter). A zero-width joint (['', true]) marks where a backslash-newline
|
|
10
|
+
# continuation joined two physical lines.
|
|
11
|
+
ReadChar = T.type_alias { [String, T::Boolean] }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
module Expansion
|
|
6
|
+
# The count-limited, escape-aware field scanner behind ReadSplitter — a
|
|
7
|
+
# transcription of dash's ifsbreakup(maxargs): fields split off until the
|
|
8
|
+
# variable budget is spent; the delimiter that spends it starts the
|
|
9
|
+
# remainder, which accumulates verbatim with a cut mark (dash's `r`)
|
|
10
|
+
# tracking removable trailing IFS whitespace. Escaped characters are plain
|
|
11
|
+
# content: never delimiters, they end a whitespace run (dash's region
|
|
12
|
+
# boundary) and never touch the cut mark — reproducing dash's remainder
|
|
13
|
+
# edge cases exactly (see the unit spec).
|
|
14
|
+
class ReadFieldScanner
|
|
15
|
+
extend T::Sig
|
|
16
|
+
|
|
17
|
+
# The collected fields plus the field being built, carrying dash's `r`
|
|
18
|
+
# mark: the position from which the final field's trailing text is cut.
|
|
19
|
+
class Fields
|
|
20
|
+
extend T::Sig
|
|
21
|
+
|
|
22
|
+
sig { void }
|
|
23
|
+
def initialize
|
|
24
|
+
@done = T.let([], T::Array[String])
|
|
25
|
+
@text = T.let(+'', String)
|
|
26
|
+
@cut = T.let(nil, T.nilable(Integer))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { params(char: String).void }
|
|
30
|
+
def append(char)
|
|
31
|
+
@text << char
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { returns(T::Boolean) }
|
|
35
|
+
def empty?
|
|
36
|
+
@text.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
sig { void }
|
|
40
|
+
def close
|
|
41
|
+
@done << @text
|
|
42
|
+
@text = +''
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Records the cut position — the start of removable trailing text
|
|
46
|
+
# (dash's `r`) — keeping the earliest one until uncut clears it.
|
|
47
|
+
sig { void }
|
|
48
|
+
def cut
|
|
49
|
+
@cut ||= @text.length
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { void }
|
|
53
|
+
def uncut
|
|
54
|
+
@cut = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { returns(T::Array[String]) }
|
|
58
|
+
def result
|
|
59
|
+
cut = @cut
|
|
60
|
+
tail = cut ? T.must(@text[0, cut]) : @text
|
|
61
|
+
tail.empty? ? @done : @done + [tail]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { params(ifs: Ifs, count: Integer).void }
|
|
66
|
+
def initialize(ifs, count)
|
|
67
|
+
@ifs = ifs
|
|
68
|
+
@left = count
|
|
69
|
+
@fields = T.let(Fields.new, Fields)
|
|
70
|
+
@spaced = T.let(false, T::Boolean) # dash's ifsspc: in a whitespace-delimiter run
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sig { params(chars: T::Array[ReadChar]).returns(T::Array[String]) }
|
|
74
|
+
def run(chars)
|
|
75
|
+
chars.each { |char, escaped| escaped ? shielded(char) : consume(char) }
|
|
76
|
+
@fields.result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
sig { params(char: String).void }
|
|
82
|
+
def consume(char)
|
|
83
|
+
return tail(char) if @left.zero?
|
|
84
|
+
return absorb(char) if @spaced && ifs?(char)
|
|
85
|
+
|
|
86
|
+
head(char)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A backslash-escaped character: plain content, never a delimiter. It
|
|
90
|
+
# ends any whitespace run (in dash a region boundary resets ifsspc) and
|
|
91
|
+
# leaves the cut mark alone (dash never scans the gap it sits in). The
|
|
92
|
+
# zero-width joint a line continuation leaves (['', true]) rides the same
|
|
93
|
+
# path: it only breaks the run.
|
|
94
|
+
sig { params(char: String).void }
|
|
95
|
+
def shielded(char)
|
|
96
|
+
@spaced = false
|
|
97
|
+
@fields.append(char)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Inside a whitespace-delimiter run an IFS char joins the delimiter:
|
|
101
|
+
# more whitespace keeps the run open, one non-whitespace IFS character
|
|
102
|
+
# closes it.
|
|
103
|
+
sig { params(char: String).void }
|
|
104
|
+
def absorb(char)
|
|
105
|
+
@spaced = @ifs.whitespace?(char)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sig { params(char: String).void }
|
|
109
|
+
def head(char)
|
|
110
|
+
return delimiter(char) if ifs?(char)
|
|
111
|
+
|
|
112
|
+
@spaced = false
|
|
113
|
+
@fields.append(char)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# An unescaped IFS delimiter: whitespace before any field content is
|
|
117
|
+
# skipped; a delimiter splits a field while variables remain, and the
|
|
118
|
+
# one that spends the last variable opens the remainder.
|
|
119
|
+
sig { params(char: String).void }
|
|
120
|
+
def delimiter(char)
|
|
121
|
+
@spaced = @ifs.whitespace?(char)
|
|
122
|
+
return skip_leading if @spaced && @fields.empty?
|
|
123
|
+
|
|
124
|
+
(@left -= 1).zero? ? removable(char) : @fields.close
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sig { void }
|
|
128
|
+
def skip_leading
|
|
129
|
+
@spaced = false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Removable trailing text: the delimiter that spends the last variable
|
|
133
|
+
# (the remainder accumulates verbatim from it) and, later, IFS whitespace
|
|
134
|
+
# extending the trailing run — cut off unless real content follows.
|
|
135
|
+
sig { params(char: String).void }
|
|
136
|
+
def removable(char)
|
|
137
|
+
@fields.cut
|
|
138
|
+
@fields.append(char)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Remainder mode (dash's exhausted maxargs): IFS whitespace extends the
|
|
142
|
+
# removable trailing run; anything else is content and clears the cut —
|
|
143
|
+
# except a single non-whitespace IFS char directly closing a run.
|
|
144
|
+
sig { params(char: String).void }
|
|
145
|
+
def tail(char)
|
|
146
|
+
return removable(char) if @ifs.whitespace?(char)
|
|
147
|
+
|
|
148
|
+
@fields.uncut unless @ifs.other?(char) && @spaced
|
|
149
|
+
@spaced = false
|
|
150
|
+
@fields.append(char)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
sig { params(char: String).returns(T::Boolean) }
|
|
154
|
+
def ifs?(char)
|
|
155
|
+
@ifs.whitespace?(char) || @ifs.other?(char)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
module Expansion
|
|
6
|
+
# Field splitting for the `read` builtin over an escape-annotated line:
|
|
7
|
+
# ReadFieldScanner splits on unescaped IFS delimiters into at most `count`
|
|
8
|
+
# fields (the last keeps the remainder verbatim, trailing IFS whitespace
|
|
9
|
+
# removed), and the result is padded with empty strings up to `count`.
|
|
10
|
+
# A null IFS does not split — the whole line is one field, untrimmed.
|
|
11
|
+
class ReadSplitter
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
sig { params(ifs: T.nilable(String), count: Integer).void }
|
|
15
|
+
def initialize(ifs, count)
|
|
16
|
+
@ifs = Ifs.new(ifs)
|
|
17
|
+
@count = count
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(chars: T::Array[ReadChar]).returns(T::Array[String]) }
|
|
21
|
+
def split(chars)
|
|
22
|
+
return pad([text(chars)]) if @ifs.null?
|
|
23
|
+
|
|
24
|
+
pad(ReadFieldScanner.new(@ifs, @count).run(chars))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
sig { params(chars: T::Array[ReadChar]).returns(String) }
|
|
30
|
+
def text(chars)
|
|
31
|
+
chars.map(&:first).join
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { params(fields: T::Array[String]).returns(T::Array[String]) }
|
|
35
|
+
def pad(fields)
|
|
36
|
+
(fields.each + [''].cycle).take(@count)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
module Expansion
|
|
6
|
+
# Resolves a parameter name through ShellState's parameter namespace. The
|
|
7
|
+
# shell state owns variables, special parameters and positionals, including
|
|
8
|
+
# the original shell pid exposed through $$.
|
|
9
|
+
class Resolver
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(executor: Executor).void }
|
|
13
|
+
def initialize(executor)
|
|
14
|
+
@executor = executor
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { params(name: String).returns(T.nilable(String)) }
|
|
18
|
+
def resolve(name)
|
|
19
|
+
@executor.state.parameters.resolve(name)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
module Expansion
|
|
6
|
+
# Tilde expansion (step 1): a leading unquoted `~` or `~name` in a word's
|
|
7
|
+
# first literal segment becomes $HOME or the named user's home directory. It
|
|
8
|
+
# is left untouched when there is no such user, or when HOME is unset for a
|
|
9
|
+
# bare `~`. This base handles the leading form; AssignmentTilde extends it to
|
|
10
|
+
# also expand after each unquoted colon, and NoTilde disables it entirely.
|
|
11
|
+
class TildeExpander
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
sig { params(executor: Executor, segments: T::Array[AST::WordSegment[T.untyped]]).void }
|
|
15
|
+
def initialize(executor, segments)
|
|
16
|
+
@executor = executor
|
|
17
|
+
@segments = segments
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { returns(T::Array[AST::WordSegment[T.untyped]]) }
|
|
21
|
+
def expand
|
|
22
|
+
head = @segments.first
|
|
23
|
+
return @segments unless head
|
|
24
|
+
|
|
25
|
+
text = head.literal_value
|
|
26
|
+
text ? replace_head(head, text) : @segments
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
sig { params(head: AST::WordSegment[T.untyped], text: String).returns(T::Array[AST::WordSegment[T.untyped]]) }
|
|
32
|
+
def replace_head(head, text)
|
|
33
|
+
rewritten = rewrite(text)
|
|
34
|
+
return @segments if rewritten == text
|
|
35
|
+
|
|
36
|
+
@segments.dup.tap { |segments| segments[0] = head.with_value(rewritten) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
sig { params(text: String).returns(String) }
|
|
40
|
+
def rewrite(text)
|
|
41
|
+
prefix(text)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
sig { params(text: String).returns(String) }
|
|
45
|
+
def prefix(text)
|
|
46
|
+
return text unless text.start_with?('~')
|
|
47
|
+
|
|
48
|
+
name, rest = split(text.delete_prefix('~'))
|
|
49
|
+
home = resolve(name)
|
|
50
|
+
home ? home + rest : text
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { params(body: String).returns([String, String]) }
|
|
54
|
+
def split(body)
|
|
55
|
+
name, slash, rest = body.partition('/')
|
|
56
|
+
slash.empty? ? [body, ''] : [name, slash + rest]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { params(name: String).returns(T.nilable(String)) }
|
|
60
|
+
def resolve(name)
|
|
61
|
+
return @executor.state.variables.get('HOME') if name.empty?
|
|
62
|
+
|
|
63
|
+
@executor.system.home_dir(name)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Assignment context (PATH=~/bin:~root/x): the leading tilde plus one after
|
|
68
|
+
# each unquoted colon, so every colon-separated piece gets the ~ treatment.
|
|
69
|
+
class AssignmentTilde < TildeExpander
|
|
70
|
+
extend T::Sig
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
sig { params(text: String).returns(String) }
|
|
75
|
+
def rewrite(text)
|
|
76
|
+
return text unless expandable?(text)
|
|
77
|
+
|
|
78
|
+
text.split(':', -1).map { |piece| prefix(piece) }.join(':')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
sig { params(text: String).returns(T::Boolean) }
|
|
82
|
+
def expandable?(text)
|
|
83
|
+
text.start_with?('~') || text.include?(':~')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Tilde expansion disabled (e.g. arithmetic operands): segments pass through.
|
|
88
|
+
class NoTilde
|
|
89
|
+
extend T::Sig
|
|
90
|
+
|
|
91
|
+
sig { params(_executor: Executor, segments: T::Array[AST::WordSegment[T.untyped]]).void }
|
|
92
|
+
def initialize(_executor, segments)
|
|
93
|
+
@segments = segments
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
sig { returns(T::Array[AST::WordSegment[T.untyped]]) }
|
|
97
|
+
def expand
|
|
98
|
+
@segments
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Runs an external program through the system port with the command's env and
|
|
6
|
+
# IoTable, translating the result: the OS exit/signal status, or POSIX 127
|
|
7
|
+
# (not found) / 126 (not executable).
|
|
8
|
+
class External
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(executor: Executor, argv: T::Array[String], io: IoTable, env: T::Hash[String, String]).void }
|
|
12
|
+
def initialize(executor, argv, io, env)
|
|
13
|
+
@executor = executor
|
|
14
|
+
@argv = argv
|
|
15
|
+
@io = io
|
|
16
|
+
@env = env
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Under job control the spawned command has no child-side hook, so the
|
|
20
|
+
# foreground wrapper's parent-side give is what hands it the terminal —
|
|
21
|
+
# right after the spawn, before the wait. `text` is the job line a
|
|
22
|
+
# parked entry keeps, rendered pre-expansion by the caller (which holds
|
|
23
|
+
# the AST this class never sees). A signal death reports on the
|
|
24
|
+
# command's own stderr (dash applies simple-command redirects in the
|
|
25
|
+
# parent, so 2>/dev/null suppresses its printsignal there too — probed).
|
|
26
|
+
sig { params(text: T.nilable(String)).returns(Status) }
|
|
27
|
+
def call(text = nil)
|
|
28
|
+
call_file(T.must(@argv.first), text)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Run a pre-resolved file in place of the $PATH search on argv[0] (plain
|
|
32
|
+
# #call passes the bare name, `command -p` its default-PATH resolution);
|
|
33
|
+
# argv[0] itself still names the child, like dash.
|
|
34
|
+
sig { params(path: String, text: T.nilable(String)).returns(Status) }
|
|
35
|
+
def call_file(path, text = nil)
|
|
36
|
+
pid = system.spawn(path, @env, @argv, spawn_options)
|
|
37
|
+
status = @executor.job_control.foreground([pid], text: text) { @executor.jobs.await(pid) }
|
|
38
|
+
SignalReport.report(status, @io.get(2))
|
|
39
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
40
|
+
error_status(e)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
sig { returns(SystemCalls) }
|
|
46
|
+
def system
|
|
47
|
+
@executor.system
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# close_others closes inherited fds >= 3 (e.g. pipeline pipe ends) in the
|
|
51
|
+
# child. Under job control the command is a job of its own: pgroup: true
|
|
52
|
+
# has the kernel start it as its own process-group leader — the spawn-path
|
|
53
|
+
# equivalent of the fork paths' double setpgid, with no race at all.
|
|
54
|
+
sig { returns(T::Hash[T.any(Integer, Symbol), T.untyped]) }
|
|
55
|
+
def spawn_options
|
|
56
|
+
base = @io.to_spawn_options.merge(close_others: true)
|
|
57
|
+
@executor.job_control.monitored? ? base.merge(pgroup: true) : base
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig { params(error: SystemCallError).returns(Status) }
|
|
61
|
+
def error_status(error)
|
|
62
|
+
code = error.is_a?(Errno::EACCES) ? 126 : 127
|
|
63
|
+
reason = code == 126 ? 'Permission denied' : 'not found'
|
|
64
|
+
report_error(reason)
|
|
65
|
+
Status.new(code)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sig { params(reason: String).void }
|
|
69
|
+
def report_error(reason)
|
|
70
|
+
@io.get(2).puts("rush: #{@argv.first}: #{reason}")
|
|
71
|
+
rescue Errno::EBADF
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# One binding in the shell's logical fd table. It records the stream currently
|
|
6
|
+
# bound to a descriptor plus ownership: redirections that open files/heredocs
|
|
7
|
+
# are owned and closed after the command, while stdio, pipes and future
|
|
8
|
+
# inherited descriptors are borrowed. A closed entry models `n>&-` / `n<&-`.
|
|
9
|
+
class FdEntry
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(stream: T.untyped).returns(FdEntry) }
|
|
13
|
+
def self.borrowed(stream)
|
|
14
|
+
new(stream, :borrowed)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { params(stream: T.untyped).returns(FdEntry) }
|
|
18
|
+
def self.owned(stream)
|
|
19
|
+
new(stream, :owned)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { returns(FdEntry) }
|
|
23
|
+
def self.closed
|
|
24
|
+
new(nil, :closed)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(stream: T.untyped, state: Symbol).void }
|
|
28
|
+
def initialize(stream, state)
|
|
29
|
+
@stream = stream
|
|
30
|
+
@state = state
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig { returns(T::Boolean) }
|
|
34
|
+
def owned?
|
|
35
|
+
@state == :owned
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { returns(T::Boolean) }
|
|
39
|
+
def closed?
|
|
40
|
+
@state == :closed
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { returns(T.untyped) }
|
|
44
|
+
def stream
|
|
45
|
+
raise Errno::EBADF if closed?
|
|
46
|
+
|
|
47
|
+
T.must(@stream)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { returns(T.untyped) }
|
|
51
|
+
def to_spawn_option
|
|
52
|
+
closed? ? :close : stream
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
sig { params(system: SystemCalls).void }
|
|
56
|
+
def close_redirect(system)
|
|
57
|
+
system.close_redirect(stream) if owned? && !closed?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Runs a for loop: assign the variable to each value in turn and run the body,
|
|
6
|
+
# catching break/continue at the loop boundary (see LoopControlHandling). The
|
|
7
|
+
# loop's status is the last body status (0 if the value list is empty).
|
|
8
|
+
class ForRunner
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
include LoopControlHandling
|
|
12
|
+
|
|
13
|
+
sig { params(executor: Executor, name: String, values: T::Array[String], body: AST::Node).void }
|
|
14
|
+
def initialize(executor, name, values, body)
|
|
15
|
+
@executor = executor
|
|
16
|
+
@name = name
|
|
17
|
+
@values = values
|
|
18
|
+
@body = body
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Bracket the loop so break/continue see the right nesting depth; leave runs
|
|
22
|
+
# even when break unwinds.
|
|
23
|
+
sig { returns(Status) }
|
|
24
|
+
def call
|
|
25
|
+
@executor.state.with_loop { run_loop }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
sig { returns(Status) }
|
|
31
|
+
def run_loop
|
|
32
|
+
# T.let pins the loop variable's type (see LoopRunner#run_loop): Sorbet forbids
|
|
33
|
+
# a variable changing type across a block (srb.help/7001) and the sig'd
|
|
34
|
+
# Status.success / unsig'd #iterate would. Steep ignores T.let (sorbet_dsl.rbs).
|
|
35
|
+
status = T.let(Status.success, Status)
|
|
36
|
+
@values.each { |value| status = iterate(value) }
|
|
37
|
+
status
|
|
38
|
+
rescue BreakSignal => e
|
|
39
|
+
unwind(e)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { params(value: String).returns(Status) }
|
|
43
|
+
def iterate(value)
|
|
44
|
+
@executor.state.variables.assign(@name, value)
|
|
45
|
+
@executor.run(@body)
|
|
46
|
+
rescue ContinueSignal => e
|
|
47
|
+
unwind(e)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Brackets a shell-function invocation: open a local-variable scope, reset loop
|
|
6
|
+
# nesting so break/continue cannot escape to the caller, bind positional
|
|
7
|
+
# parameters to the function arguments, then restore all three on return.
|
|
8
|
+
class FunctionFrame
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(variables: ShellVariables, loops: LoopNesting, positional: Positional).void }
|
|
12
|
+
def initialize(variables:, loops:, positional:)
|
|
13
|
+
@variables = variables
|
|
14
|
+
@loops = loops
|
|
15
|
+
@positional = positional
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig do
|
|
19
|
+
type_parameters(:U)
|
|
20
|
+
.params(args: T::Array[String], blk: T.proc.returns(T.type_parameter(:U)))
|
|
21
|
+
.returns(T.type_parameter(:U))
|
|
22
|
+
end
|
|
23
|
+
def call(args, &blk)
|
|
24
|
+
@variables.begin_scope
|
|
25
|
+
@loops.without { @positional.with(args, &blk) }
|
|
26
|
+
ensure
|
|
27
|
+
@variables.end_scope
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|