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,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
|