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,83 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Shared command-by-command session over ProgramReader. Subclasses provide the
|
|
6
|
+
# input source and tune error policy: batch source aborts on fatal errors,
|
|
7
|
+
# while the REPL reports recoverable errors and keeps reading.
|
|
8
|
+
class ProgramSession
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(system: SystemCalls, state: ShellState, startup: T.nilable(Startup)).void }
|
|
12
|
+
def initialize(system, state: ShellState.new(pids: ShellProcessIds.for(system)), startup: nil)
|
|
13
|
+
@system = system
|
|
14
|
+
@startup = startup
|
|
15
|
+
@executor = Executor.new(system: system, state: state)
|
|
16
|
+
InteractiveSignals.install(@executor) if state.options.on?(:interactive)
|
|
17
|
+
@executor.job_control.startup
|
|
18
|
+
@reader = ProgramReader.new(aliases: executor.state.aliases) { |continuation| next_line(continuation) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
sig { returns(Integer) }
|
|
22
|
+
def run
|
|
23
|
+
executor.trap_runner.run_exit_trap(session)
|
|
24
|
+
rescue ExitSignal => e
|
|
25
|
+
executor.trap_runner.run_exit_trap(e.code)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
sig { returns(SystemCalls) }
|
|
31
|
+
attr_reader :system
|
|
32
|
+
|
|
33
|
+
sig { returns(Executor) }
|
|
34
|
+
attr_reader :executor
|
|
35
|
+
|
|
36
|
+
sig { returns(ProgramReader) }
|
|
37
|
+
attr_reader :reader
|
|
38
|
+
|
|
39
|
+
sig { returns(Integer) }
|
|
40
|
+
def session
|
|
41
|
+
run_startup
|
|
42
|
+
run_loop
|
|
43
|
+
executor.state.last_status.exitstatus
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Login/ENV startup files run before the main input, inside the session's
|
|
47
|
+
# error policy: batch subclasses let fatal errors abort (Source#run), the
|
|
48
|
+
# REPL recovers (its override).
|
|
49
|
+
sig { void }
|
|
50
|
+
def run_startup
|
|
51
|
+
@startup&.run(executor)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
sig { void }
|
|
55
|
+
def run_loop
|
|
56
|
+
until (program = read_program) == :eof
|
|
57
|
+
execute(T.cast(program, AST::List)) unless program == :error
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
sig { returns(T.any(AST::List, Symbol)) }
|
|
62
|
+
def read_program
|
|
63
|
+
reader.next_program
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { params(_continuation: T::Boolean).returns(T.nilable(String)) }
|
|
67
|
+
def next_line(_continuation)
|
|
68
|
+
# :nocov:
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
# :nocov:
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sig { params(program: AST::List).void }
|
|
74
|
+
def execute(program)
|
|
75
|
+
executor.run(program)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sig { params(error: StandardError).void }
|
|
79
|
+
def report(error)
|
|
80
|
+
system.stderr.puts("rush: #{error.message}")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/rush/prompt.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# The prompt strings (POSIX 2.5.3): PS1, PS2 and the PS4 trace prefix are
|
|
6
|
+
# re-read from the shell variables at every use — so assignments take effect
|
|
7
|
+
# mid-session — and subjected to parameter expansion ONLY (via ParamText),
|
|
8
|
+
# so a PS4 holding $(cmd) or backticks stays literal and tracing can never
|
|
9
|
+
# recurse into command execution (dash strips backticks unexecuted, errors
|
|
10
|
+
# $(cmd) back to the raw string, and expands only $((arith)) — POSIX's
|
|
11
|
+
# parameter-expansion-only text wins). Defaults:
|
|
12
|
+
# '$ ' ('# ' for a privileged shell), '> ' and '+ '. A malformed value
|
|
13
|
+
# (unterminated ${, a failing ${x?} form) falls back to the raw string: a
|
|
14
|
+
# prompt must never break the session.
|
|
15
|
+
class Prompt
|
|
16
|
+
extend T::Sig
|
|
17
|
+
|
|
18
|
+
sig { params(executor: Executor).void }
|
|
19
|
+
def initialize(executor)
|
|
20
|
+
@executor = executor
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { returns(String) }
|
|
24
|
+
def primary
|
|
25
|
+
render('PS1', @executor.system.privileged? ? '# ' : '$ ')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { returns(String) }
|
|
29
|
+
def continuation
|
|
30
|
+
render('PS2', '> ')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig { returns(String) }
|
|
34
|
+
def trace
|
|
35
|
+
render('PS4', '+ ')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
sig { params(name: String, default: String).returns(String) }
|
|
41
|
+
def render(name, default)
|
|
42
|
+
expand(@executor.state.variables.get(name) || default)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { params(raw: String).returns(String) }
|
|
46
|
+
def expand(raw)
|
|
47
|
+
@executor.expander.expand_value(ParamText.new(raw).word, tilde: :none)
|
|
48
|
+
rescue ParseError, ExpansionError
|
|
49
|
+
raw
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Applies command redirects on top of an IoTable and owns cleanup of streams
|
|
6
|
+
# opened by those redirects. Redirect-only exec is detected by comparing the
|
|
7
|
+
# yielded table with the executor's current base table after the block returns.
|
|
8
|
+
class RedirectScope
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(executor: T.untyped).void }
|
|
12
|
+
def initialize(executor)
|
|
13
|
+
@executor = executor
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Apply the redirects on top of a base IoTable (the executor's current one
|
|
17
|
+
# by default), yield the result, then flush+close the files those redirects
|
|
18
|
+
# opened (POSIX: a later command in the same shell sees the data). Only the
|
|
19
|
+
# streams this command opened are closed — the diff against the base leaves
|
|
20
|
+
# inherited streams and pipe ends untouched. Exception: redirect-only `exec`
|
|
21
|
+
# commits the table as the shell's base (replace_io), so it now equals that
|
|
22
|
+
# base — leave those files open so they persist for the rest of the shell
|
|
23
|
+
# rather than closing them out from under it.
|
|
24
|
+
sig do
|
|
25
|
+
type_parameters(:U)
|
|
26
|
+
.params(redirects: T::Array[AST::Redirect], base: IoTable,
|
|
27
|
+
blk: T.proc.params(io: IoTable).returns(T.type_parameter(:U)))
|
|
28
|
+
.returns(T.type_parameter(:U))
|
|
29
|
+
end
|
|
30
|
+
def with_redirects(redirects, base = @executor.io, &blk)
|
|
31
|
+
opened = T.let([], T::Array[FdEntry])
|
|
32
|
+
io = apply_redirects(redirects, base, opened)
|
|
33
|
+
yield io
|
|
34
|
+
ensure
|
|
35
|
+
close_redirect_io(T.must(opened), io || base)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
sig { params(redirects: T::Array[AST::Redirect], base: IoTable, opened: T::Array[FdEntry]).returns(IoTable) }
|
|
41
|
+
def apply_redirects(redirects, base, opened)
|
|
42
|
+
redirects.reduce(base) { |io, redirect| apply_redirect(redirect, io, opened) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { params(redirect: AST::Redirect, io: IoTable, opened: T::Array[FdEntry]).returns(IoTable) }
|
|
46
|
+
def apply_redirect(redirect, io, opened)
|
|
47
|
+
redirected = redirect_into(redirect, io)
|
|
48
|
+
opened.concat(redirected.opened_over(io))
|
|
49
|
+
redirected
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { params(opened: T::Array[FdEntry], io: IoTable).void }
|
|
53
|
+
def close_redirect_io(opened, io)
|
|
54
|
+
entries = io.equal?(@executor.io) ? opened - io.entries : opened
|
|
55
|
+
IoTable.close_entries(entries, @executor.system)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(redirect: AST::Redirect, io: IoTable).returns(IoTable) }
|
|
59
|
+
def redirect_into(redirect, io)
|
|
60
|
+
target = @executor.expander.expand_value(redirect.target)
|
|
61
|
+
@executor.redirections.fetch(redirect.kind).apply(redirect, target, io, @executor.system)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
module Redirection
|
|
6
|
+
# n>&m / n<&m — make fd n a duplicate of fd m by binding it to m's stream
|
|
7
|
+
# (the left-to-right fold means it copies whatever m points at right now),
|
|
8
|
+
# n>&- / n<&- — close fd n. The default fd is 1 for >& and 0 for <&. A target
|
|
9
|
+
# that is neither a number nor `-`, or a number whose fd is not open, is a
|
|
10
|
+
# "bad fd number" — a special-builtin error that aborts the shell with 2.
|
|
11
|
+
class DupRedirect
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
FD_TARGET = /\A\d+\z/
|
|
15
|
+
|
|
16
|
+
sig { params(default_fd: Integer).void }
|
|
17
|
+
def initialize(default_fd)
|
|
18
|
+
@default_fd = default_fd
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
sig { params(redirect: AST::Redirect, target: String, io: IoTable, system: SystemCalls).returns(IoTable) }
|
|
22
|
+
def apply(redirect, target, io, system)
|
|
23
|
+
fd = redirect.io_number || @default_fd
|
|
24
|
+
target == '-' ? io.with_closed(fd) : io.with_entry(fd, source(io, target, system))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# A number whose fd is open duplicates it; a number whose fd is not open
|
|
30
|
+
# (unset, or already closed by an earlier n>&-) is a non-fatal redirect
|
|
31
|
+
# error (status 2, shell continues); a non-number is a special-builtin
|
|
32
|
+
# error (aborts the shell).
|
|
33
|
+
sig { params(io: IoTable, target: String, system: SystemCalls).returns(FdEntry) }
|
|
34
|
+
def source(io, target, system)
|
|
35
|
+
fd = numeric(target)
|
|
36
|
+
entry = io.entry(fd)
|
|
37
|
+
raise RedirectError, "#{target}: fd not open" if entry&.closed?
|
|
38
|
+
|
|
39
|
+
return entry if entry
|
|
40
|
+
|
|
41
|
+
inherited_entry(system, fd) || raise(RedirectError, "#{target}: fd not open")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
sig { params(system: SystemCalls, fd: Integer).returns(T.nilable(FdEntry)) }
|
|
45
|
+
def inherited_entry(system, fd)
|
|
46
|
+
stream = system.inherited_fd(fd)
|
|
47
|
+
FdEntry.borrowed(stream) if stream
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { params(target: String).returns(Integer) }
|
|
51
|
+
def numeric(target)
|
|
52
|
+
raise BuiltinError, "Bad fd number: #{target}" unless target.match?(FD_TARGET)
|
|
53
|
+
|
|
54
|
+
Integer(target, 10)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
module Redirection
|
|
6
|
+
# Opens the (already-expanded) target file and binds it to the redirection's
|
|
7
|
+
# fd, returning a new IoTable. Covers <, >, >>, <> and >|, which differ only
|
|
8
|
+
# in open mode and default fd.
|
|
9
|
+
class FileRedirect
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
EXCLUSIVE = T.let(File::WRONLY | File::CREAT | File::EXCL, Integer)
|
|
13
|
+
|
|
14
|
+
sig do
|
|
15
|
+
params(mode: T.any(String, Integer), default_fd: Integer,
|
|
16
|
+
options: T.nilable(Options), protection: T.nilable(Symbol)).void
|
|
17
|
+
end
|
|
18
|
+
def initialize(mode, default_fd, options: nil, protection: nil)
|
|
19
|
+
@mode = mode
|
|
20
|
+
@default_fd = default_fd
|
|
21
|
+
@options = options
|
|
22
|
+
@protection = protection
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A failure to open the target (missing directory, permission denied, a
|
|
26
|
+
# directory where a file is expected) is a redirection error: the command
|
|
27
|
+
# is left unrun with status 2 (RedirectError), or — on a special builtin —
|
|
28
|
+
# aborts the shell, the escalation being handled one level up in
|
|
29
|
+
# CommandRunner where the command word is known.
|
|
30
|
+
sig { params(redirect: AST::Redirect, target: String, io: IoTable, system: SystemCalls).returns(IoTable) }
|
|
31
|
+
def apply(redirect, target, io, system)
|
|
32
|
+
io.with_owned(redirect.io_number || @default_fd, system.open_file(target, open_mode))
|
|
33
|
+
rescue SystemCallError
|
|
34
|
+
raise RedirectError, "#{target}: cannot redirect"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
sig { returns(T.any(String, Integer)) }
|
|
40
|
+
def open_mode
|
|
41
|
+
noclobber? ? EXCLUSIVE : @mode
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
sig { returns(T::Boolean) }
|
|
45
|
+
def noclobber?
|
|
46
|
+
@protection == :noclobber && @options&.on?(:noclobber) == true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
module Redirection
|
|
6
|
+
# Binds a here-document's (already-expanded) body to the redirection's fd —
|
|
7
|
+
# fd 0 by default — as a readable stream supplied by the SystemCalls port.
|
|
8
|
+
class HereDocRedirect
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(redirect: AST::Redirect, body: String, io: IoTable, system: SystemCalls).returns(IoTable) }
|
|
12
|
+
def apply(redirect, body, io, system)
|
|
13
|
+
io.with_owned(redirect.io_number || 0, system.here_doc(body))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# I/O redirection: the per-kind appliers and the registry that dispatches to them.
|
|
6
|
+
module Redirection
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
# O(1) redirection-kind -> applier lookup, populated by default_registry.
|
|
10
|
+
class Registry
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { void }
|
|
14
|
+
def initialize
|
|
15
|
+
@appliers = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig { params(kind: Symbol, applier: T.untyped).void }
|
|
19
|
+
def register(kind, applier)
|
|
20
|
+
@appliers[kind] = applier
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { params(kind: Symbol).returns(T.untyped) }
|
|
24
|
+
def fetch(kind)
|
|
25
|
+
@appliers.fetch(kind)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# <> opens read-write and creates the file (POSIX), unlike the string modes.
|
|
30
|
+
DEFAULTS = {
|
|
31
|
+
in: ['r', 0, nil], out: ['w', 1, :noclobber], append: ['a', 1, nil],
|
|
32
|
+
readwrite: [File::RDWR | File::CREAT, 0, nil], clobber: ['w', 1, nil]
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Default fd for the dup operators: 1 for >&, 0 for <&.
|
|
36
|
+
DUPS = { dup_out: 1, dup_in: 0 }.freeze
|
|
37
|
+
|
|
38
|
+
sig { params(options: T.nilable(Options)).returns(Registry) }
|
|
39
|
+
def self.default_registry(options = nil)
|
|
40
|
+
Registry.new.tap do |registry|
|
|
41
|
+
DEFAULTS.each { |kind, spec| register_file(registry, kind, spec, options) }
|
|
42
|
+
DUPS.each { |kind, fd| registry.register(kind, DupRedirect.new(fd)) }
|
|
43
|
+
registry.register(:heredoc, HereDocRedirect.new)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sig { params(registry: Registry, kind: Symbol, spec: T::Array[T.untyped], options: T.nilable(Options)).void }
|
|
48
|
+
def self.register_file(registry, kind, spec, options)
|
|
49
|
+
redirect = FileRedirect.new(spec.fetch(0), spec.fetch(1), options: options, protection: spec.fetch(2))
|
|
50
|
+
registry.register(kind, redirect)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/rush/repl.rb
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# A simple interactive read-eval-print loop over the shared ProgramReader: each
|
|
6
|
+
# turn reads a complete command (continuation lines prompted with PS2) and runs
|
|
7
|
+
# it against one persistent ShellState, so variables and functions survive
|
|
8
|
+
# across lines. Prompts come from the PS1/PS2 shell variables (see Prompt) and
|
|
9
|
+
# go to stderr; `exit` and end-of-input (Ctrl-D) end the loop; errors and
|
|
10
|
+
# SIGINT are reported without ending the session (see #survive). Line
|
|
11
|
+
# editing/history and job control are deferred to later slices.
|
|
12
|
+
class Repl < ProgramSession
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# A startup-file error (broken ~/.profile or ENV file) reports and keeps
|
|
18
|
+
# the interactive session alive, where a batch shell would abort.
|
|
19
|
+
sig { void }
|
|
20
|
+
def run_startup
|
|
21
|
+
survive(nil) { super }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A real syntax error reports and resumes the session; the next turn starts
|
|
25
|
+
# fresh at PS1 (the reader discards the broken buffer per next_program call).
|
|
26
|
+
sig { returns(T.any(AST::List, Symbol)) }
|
|
27
|
+
def read_program
|
|
28
|
+
survive(:error) { super }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { params(continuation: T::Boolean).returns(T.nilable(String)) }
|
|
32
|
+
def next_line(continuation)
|
|
33
|
+
prompt_line(continuation)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(continuation: T::Boolean).returns(T.nilable(String)) }
|
|
37
|
+
def prompt_line(continuation)
|
|
38
|
+
text = continuation ? prompt.continuation : primary_prompt
|
|
39
|
+
system.tty? ? edited_line(text) : plain_line(text)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { returns(String) }
|
|
43
|
+
def primary_prompt
|
|
44
|
+
executor.trap_runner.run_pending
|
|
45
|
+
announce_jobs
|
|
46
|
+
prompt.primary
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# dash's cmdloop showjobs(SHOW_CHANGED): before each PS1 under monitor
|
|
50
|
+
# mode, report every job whose state changed since it was last shown,
|
|
51
|
+
# freeing the finished ones (probed: set +m silences the reports, and
|
|
52
|
+
# the lines go to stderr with the prompts).
|
|
53
|
+
sig { void }
|
|
54
|
+
def announce_jobs
|
|
55
|
+
jobs = executor.jobs
|
|
56
|
+
jobs.announce_changed(system.stderr) if jobs.control.monitor?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A terminal gets Reline: line editing and in-memory history, prompt drawn
|
|
60
|
+
# by the editor. Reline strips the newline the reader needs to keep lines
|
|
61
|
+
# apart, so it is restored here.
|
|
62
|
+
sig { params(text: String).returns(T.nilable(String)) }
|
|
63
|
+
def edited_line(text)
|
|
64
|
+
line = system.edit_line(text)
|
|
65
|
+
return unless line
|
|
66
|
+
|
|
67
|
+
"#{line}\n"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { params(text: String).returns(T.nilable(String)) }
|
|
71
|
+
def plain_line(text)
|
|
72
|
+
system.stderr.print(text)
|
|
73
|
+
system.read_line
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sig { returns(Prompt) }
|
|
77
|
+
def prompt
|
|
78
|
+
@prompt ||= T.let(Prompt.new(executor), T.nilable(Prompt))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Each interactive turn ticks the stopped-jobs exit warning (dash's
|
|
82
|
+
# cmdloop decrements job_warning per iteration): a refused exit repeated
|
|
83
|
+
# as the very next command goes through, anything later re-arms it.
|
|
84
|
+
sig { params(program: AST::List).void }
|
|
85
|
+
def execute(program)
|
|
86
|
+
executor.jobs.control.tick_warning
|
|
87
|
+
survive(nil) { super }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The interactive survival policy: a recoverable error or a SIGINT reports,
|
|
91
|
+
# publishes its status as $?, and hands `fallback` back so the session
|
|
92
|
+
# carries on where a batch shell would abort.
|
|
93
|
+
sig do
|
|
94
|
+
type_parameters(:U)
|
|
95
|
+
.params(fallback: T.type_parameter(:U), blk: T.proc.returns(T.type_parameter(:U)))
|
|
96
|
+
.returns(T.type_parameter(:U))
|
|
97
|
+
end
|
|
98
|
+
def survive(fallback, &blk)
|
|
99
|
+
yield
|
|
100
|
+
rescue Error => e
|
|
101
|
+
recover(e)
|
|
102
|
+
fallback
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# POSIX 2.8.1: where a non-interactive shell would exit (status 2, per
|
|
106
|
+
# Source#abort_with), the interactive shell reports the diagnostic,
|
|
107
|
+
# publishes the same status as $?, and returns to the prompt. A SIGINT
|
|
108
|
+
# publishes 130 instead, with a fresh line and no diagnostic.
|
|
109
|
+
sig { params(error: Error).void }
|
|
110
|
+
def recover(error)
|
|
111
|
+
decision = ErrorPolicy.decision(:interactive, error)
|
|
112
|
+
return interrupt if decision == :recover130
|
|
113
|
+
return recover_operational(error) if decision == :recover2
|
|
114
|
+
return if decision == :ignore
|
|
115
|
+
|
|
116
|
+
raise error
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
sig { params(error: Error).void }
|
|
120
|
+
def recover_operational(error)
|
|
121
|
+
report(error)
|
|
122
|
+
executor.state.record_status(Status.failure(2))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ^C: a fresh line, $? = 130 (128+SIGINT), back to the prompt (dash
|
|
126
|
+
# behaviour whether the signal arrived at the prompt or mid-command).
|
|
127
|
+
sig { void }
|
|
128
|
+
def interrupt
|
|
129
|
+
system.stderr.puts
|
|
130
|
+
executor.state.record_status(Status.failure(130))
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module Rush
|
|
7
|
+
# Production policy for Sorbet's runtime method wrappers. This bootstrapping
|
|
8
|
+
# method deliberately has no runtime sig: its body must run before any sig
|
|
9
|
+
# block evaluates. Static Sorbet and Steep still check this source normally.
|
|
10
|
+
module RuntimeTypeChecks
|
|
11
|
+
def self.configure
|
|
12
|
+
level = ENV.fetch('RUSH_RUNTIME_TYPECHECKS', nil) == '1' ? :always : :never
|
|
13
|
+
T::Configuration.default_checked_level = level
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/rush/scope.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Dynamic variable scope and logical working directory for ShellVariables,
|
|
6
|
+
# layered over one Environment: `local` frames snapshot variables restored when
|
|
7
|
+
# a function returns, and logical PWD is mirrored into $PWD/$OLDPWD.
|
|
8
|
+
class Scope
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { returns(T.nilable(String)) }
|
|
12
|
+
attr_reader :pwd
|
|
13
|
+
|
|
14
|
+
sig { params(environment: Environment).void }
|
|
15
|
+
def initialize(environment)
|
|
16
|
+
@environment = environment
|
|
17
|
+
@frames = []
|
|
18
|
+
@pwd = environment.get('PWD')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Change the logical working directory, keeping $OLDPWD (the directory we
|
|
22
|
+
# left) and $PWD (the one we entered) in step — the invariant cd relies on.
|
|
23
|
+
sig { params(pwd: String).void }
|
|
24
|
+
def move_to(pwd)
|
|
25
|
+
@environment.assign('OLDPWD', current_pwd)
|
|
26
|
+
@pwd = pwd
|
|
27
|
+
@environment.assign('PWD', pwd)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Seed the logical pwd from the OS at startup when $PWD was unset; unlike
|
|
31
|
+
# #move_to this records no $OLDPWD/$PWD — there is no directory we came from.
|
|
32
|
+
sig { params(path: String).void }
|
|
33
|
+
def seed_pwd(path)
|
|
34
|
+
return if @pwd
|
|
35
|
+
|
|
36
|
+
@pwd = path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
sig { returns(String) }
|
|
40
|
+
def current_pwd
|
|
41
|
+
@pwd || raise(Error, 'internal invariant: PWD not seeded')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A function call brackets its body with begin/end_scope; declare_local
|
|
45
|
+
# snapshots a variable so end_scope restores its prior value (or unsets it
|
|
46
|
+
# when it had none).
|
|
47
|
+
sig { void }
|
|
48
|
+
def begin_scope
|
|
49
|
+
@frames.push({})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { void }
|
|
53
|
+
def end_scope
|
|
54
|
+
# pop is non-nil here (paired with begin_scope); .to_a pins its type to an
|
|
55
|
+
# array of pairs for the checker without changing behaviour on that path.
|
|
56
|
+
@frames.pop.to_a.each { |name, value| restore(name, value) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { returns(T::Boolean) }
|
|
60
|
+
def in_function?
|
|
61
|
+
@frames.any?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sig { params(name: String).void }
|
|
65
|
+
def declare_local(name)
|
|
66
|
+
# last frame is non-nil here (only called inside a function); fetch(-1) pins
|
|
67
|
+
# its type to Hash for the checker and keeps the crash-if-empty invariant.
|
|
68
|
+
frame = @frames.fetch(-1)
|
|
69
|
+
frame[name] = @environment.get(name) unless frame.key?(name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
sig { params(name: String, value: T.nilable(String)).void }
|
|
75
|
+
def restore(name, value)
|
|
76
|
+
value ? @environment.assign(name, value) : @environment.unset(name)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
# Accumulates a word's segments during scanning: literal characters gather
|
|
6
|
+
# into a pending run, #push closes that run as an unquoted LiteralSegment
|
|
7
|
+
# before appending a structured segment, and #word closes it once more to
|
|
8
|
+
# build the finished AST::Word. Shared by the scanners that assemble words
|
|
9
|
+
# character by character (WordScanner, HeredocBody, ParamText).
|
|
10
|
+
class SegmentBuffer
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { void }
|
|
14
|
+
def initialize
|
|
15
|
+
@segments = T.let([], T::Array[AST::WordSegment[T.untyped]])
|
|
16
|
+
@literal = +''
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig { params(text: String).returns(T.self_type) }
|
|
20
|
+
def <<(text)
|
|
21
|
+
@literal << text
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig { params(segment: AST::WordSegment[T.untyped]).void }
|
|
26
|
+
def push(segment)
|
|
27
|
+
flush
|
|
28
|
+
@segments << segment
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { returns(AST::Word) }
|
|
32
|
+
def word
|
|
33
|
+
flush
|
|
34
|
+
AST::Word.new(@segments)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
sig { void }
|
|
40
|
+
def flush
|
|
41
|
+
return if @literal.empty?
|
|
42
|
+
|
|
43
|
+
@segments << AST::LiteralSegment.new(@literal, false)
|
|
44
|
+
@literal = +''
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|