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,146 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class Lexer
|
|
6
|
+
# Tracks paren depth and `case` constructs inside a $( ... ) body so
|
|
7
|
+
# ParenReader can tell a pattern-terminating `)` (POSIX case_item — not
|
|
8
|
+
# paren-balanced) from a `)` that counts toward closing the substitution.
|
|
9
|
+
# ParenReader feeds it words and operator events; each open construct is
|
|
10
|
+
# a CaseFrame advancing :case_word → :await_in → :pattern ⇄ :body until
|
|
11
|
+
# `esac` pops it.
|
|
12
|
+
class CaseTracker
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
# Keyword words at a command position.
|
|
16
|
+
KEYWORDS = { 'case' => :open_case, 'esac' => :close_case }.freeze
|
|
17
|
+
# Reserved words that keep the next word at a command position.
|
|
18
|
+
CONTINUERS = %w[if then else elif while until do ! {].freeze
|
|
19
|
+
# What a CaseFrame word directive means to the frame stack.
|
|
20
|
+
DIRECTIVES = { pop: :pop_frame, none: :noop }.freeze
|
|
21
|
+
|
|
22
|
+
sig { void }
|
|
23
|
+
def initialize
|
|
24
|
+
@frames = T.let([], T::Array[CaseFrame])
|
|
25
|
+
@command = T.let(true, T::Boolean)
|
|
26
|
+
@depth = T.let(1, Integer)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { returns(T::Boolean) }
|
|
30
|
+
def open?
|
|
31
|
+
@depth.positive?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { params(text: String).void }
|
|
35
|
+
def word(text)
|
|
36
|
+
return command_word(text) if state == :body
|
|
37
|
+
|
|
38
|
+
send(DIRECTIVES.fetch(T.must(frame).word(text)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { void }
|
|
42
|
+
def separator
|
|
43
|
+
@command = true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# dash rejects a newline between a pattern word and its `)` at parse
|
|
47
|
+
# time; abandoning the frame lets the next `)` close the substitution,
|
|
48
|
+
# so the invalid construct fails at the re-parse, as it does in dash.
|
|
49
|
+
sig { void }
|
|
50
|
+
def newline
|
|
51
|
+
separator
|
|
52
|
+
@frames.pop if frame&.marked_pattern?(@depth)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# After a redirection operator the next word is a target, not a command.
|
|
56
|
+
sig { void }
|
|
57
|
+
def redirect
|
|
58
|
+
@command = false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
sig { void }
|
|
62
|
+
def dsemi
|
|
63
|
+
@command = true
|
|
64
|
+
frame&.reopen(@depth)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
sig { void }
|
|
68
|
+
def open_paren
|
|
69
|
+
@command = true
|
|
70
|
+
frame&.group(@depth)
|
|
71
|
+
@depth += 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# True when the just-read `)` stays in the body: either it terminates a
|
|
75
|
+
# case pattern (swallowed, not counted) or it closes a nested paren.
|
|
76
|
+
# False only for the `)` that ends the substitution.
|
|
77
|
+
sig { returns(T::Boolean) }
|
|
78
|
+
def close_paren?
|
|
79
|
+
return true if pattern_close?
|
|
80
|
+
|
|
81
|
+
@depth -= 1
|
|
82
|
+
finish_group
|
|
83
|
+
open?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
sig { returns(T.nilable(CaseFrame)) }
|
|
89
|
+
def frame
|
|
90
|
+
@frames.last
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sig { returns(Symbol) }
|
|
94
|
+
def state
|
|
95
|
+
found = frame
|
|
96
|
+
found ? found.state : :body
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { returns(T::Boolean) }
|
|
100
|
+
def pattern_close?
|
|
101
|
+
return false unless frame&.pattern?(@depth)
|
|
102
|
+
|
|
103
|
+
enter_body
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A grouped pattern `(x)` closing back to its base depth enters the
|
|
108
|
+
# item's body.
|
|
109
|
+
sig { void }
|
|
110
|
+
def finish_group
|
|
111
|
+
enter_body if frame&.grouped?(@depth)
|
|
112
|
+
@command = true
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sig { void }
|
|
116
|
+
def enter_body
|
|
117
|
+
T.must(frame).body
|
|
118
|
+
@command = true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
sig { params(text: String).void }
|
|
122
|
+
def command_word(text)
|
|
123
|
+
send(KEYWORDS.fetch(text, :noop))
|
|
124
|
+
@command = CONTINUERS.include?(text)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sig { void }
|
|
128
|
+
def noop; end
|
|
129
|
+
|
|
130
|
+
sig { void }
|
|
131
|
+
def pop_frame
|
|
132
|
+
@frames.pop if frame&.at?(@depth)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
sig { void }
|
|
136
|
+
def open_case
|
|
137
|
+
@frames.push(CaseFrame.new(@depth)) if @command
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
sig { void }
|
|
141
|
+
def close_case
|
|
142
|
+
pop_frame if @command
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class Lexer
|
|
6
|
+
# Reads a substitution at the word scanner's current position into the matching
|
|
7
|
+
# AST segment: $name / ${...} parameter refs (via ParamScanner), $(...) command
|
|
8
|
+
# substitution, $((...)) arithmetic, and `...` backticks. #read returns nil for
|
|
9
|
+
# a lone `$` that begins no valid reference, so the caller keeps it as a
|
|
10
|
+
# literal dollar.
|
|
11
|
+
class DollarScanner
|
|
12
|
+
extend T::Sig
|
|
13
|
+
include ScannerPredicates
|
|
14
|
+
|
|
15
|
+
sig { params(scanner: StringScanner).void }
|
|
16
|
+
def initialize(scanner)
|
|
17
|
+
@scanner = scanner
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The segment for a `$...` at the scanner head (the `$` not yet consumed),
|
|
21
|
+
# or nil when no valid reference follows. An unterminated ${ asks for more
|
|
22
|
+
# input: an interactive word may continue on the next line.
|
|
23
|
+
sig { params(quoted: T::Boolean).returns(T.untyped) }
|
|
24
|
+
def read(quoted:)
|
|
25
|
+
@scanner.getch
|
|
26
|
+
return dollar_paren(quoted) if peek?('(')
|
|
27
|
+
|
|
28
|
+
ref = ParamScanner.new(@scanner, error: IncompleteInput, quoted: quoted).read
|
|
29
|
+
ref && AST::ParamSegment.new(ref, quoted)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The command segment for a `` `...` `` at the scanner head.
|
|
33
|
+
sig { params(quoted: T::Boolean).returns(AST::CommandSegment) }
|
|
34
|
+
def read_backtick(quoted:)
|
|
35
|
+
@scanner.getch # `
|
|
36
|
+
AST::CommandSegment.new(SubstitutionReader.new(@scanner).backticks, quoted)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# `$((` begins arithmetic; a lone `$(` (including `$( (`) is command sub.
|
|
42
|
+
sig { params(quoted: T::Boolean).returns(AST::DynamicSegment[String]) }
|
|
43
|
+
def dollar_paren(quoted)
|
|
44
|
+
@scanner.getch # opening (
|
|
45
|
+
reader = SubstitutionReader.new(@scanner)
|
|
46
|
+
return AST::CommandSegment.new(reader.parens, quoted) unless peek?('(')
|
|
47
|
+
|
|
48
|
+
@scanner.getch # second (
|
|
49
|
+
AST::ArithSegment.new(reader.arithmetic, quoted)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class Lexer
|
|
6
|
+
# Scans the interior of a "..." region into the host word's SegmentBuffer:
|
|
7
|
+
# literal runs keep their quoted flag, $ and ` stay live, and a backslash
|
|
8
|
+
# escapes only " \ $ ` — plus the line continuation, whose interactive
|
|
9
|
+
# rules the host owns and injects as a callback. The opening quote is
|
|
10
|
+
# already consumed when this scanner starts; it consumes the closing one.
|
|
11
|
+
class DoubleQuoteScanner
|
|
12
|
+
extend T::Sig
|
|
13
|
+
include ScannerPredicates
|
|
14
|
+
|
|
15
|
+
LITERAL = /[^"$\\`]+/
|
|
16
|
+
SPECIAL = ['"', '\\', '$', '`'].freeze
|
|
17
|
+
|
|
18
|
+
sig { params(scanner: StringScanner, buffer: SegmentBuffer, continuation: T.proc.void).void }
|
|
19
|
+
def initialize(scanner, buffer, continuation)
|
|
20
|
+
@scanner = scanner
|
|
21
|
+
@buffer = buffer
|
|
22
|
+
@continuation = continuation
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig { void }
|
|
26
|
+
def scan
|
|
27
|
+
push('') if peek?('"') # "" yields one empty field
|
|
28
|
+
step until done?
|
|
29
|
+
raise IncompleteInput, 'unterminated double quote' if @scanner.eos?
|
|
30
|
+
|
|
31
|
+
@scanner.getch
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
sig { returns(T::Boolean) }
|
|
37
|
+
def done?
|
|
38
|
+
@scanner.eos? || peek?('"')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { void }
|
|
42
|
+
def step
|
|
43
|
+
return dollar if peek?('$')
|
|
44
|
+
return @buffer.push(DollarScanner.new(@scanner).read_backtick(quoted: true)) if peek?('`')
|
|
45
|
+
return escape if peek?('\\')
|
|
46
|
+
|
|
47
|
+
push(@scanner.scan(LITERAL))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { void }
|
|
51
|
+
def escape
|
|
52
|
+
@scanner.getch
|
|
53
|
+
return @continuation.call if peek?("\n")
|
|
54
|
+
|
|
55
|
+
SPECIAL.include?(@scanner.peek(1)) ? push(@scanner.getch) : push('\\')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A lone `$` that begins no valid reference stays a quoted literal dollar.
|
|
59
|
+
sig { void }
|
|
60
|
+
def dollar
|
|
61
|
+
segment = DollarScanner.new(@scanner).read(quoted: true)
|
|
62
|
+
segment ? @buffer.push(segment) : push('$')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { params(value: T.untyped).void }
|
|
66
|
+
def push(value)
|
|
67
|
+
@buffer.push(AST::LiteralSegment.new(value, true))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class Lexer
|
|
6
|
+
# Scans an unquoted here-document body into an AST::Word of literal / :param /
|
|
7
|
+
# :command segments. Unlike WordScanner there are no quote delimiters (quotes
|
|
8
|
+
# are literal) and no terminator (it consumes the whole body); a backslash
|
|
9
|
+
# escapes only $, ` and \. Expansion itself is deferred to execution, so the
|
|
10
|
+
# body reflects the variable values at the time the command runs. Parameter
|
|
11
|
+
# references are read by the shared ParamScanner.
|
|
12
|
+
class HeredocBody
|
|
13
|
+
extend T::Sig
|
|
14
|
+
include ScannerPredicates
|
|
15
|
+
|
|
16
|
+
ESCAPES = { '$' => '$', '`' => '`', '\\' => '\\' }.freeze
|
|
17
|
+
ESCAPE_TABLE = EscapeTable.new(ESCAPES).freeze
|
|
18
|
+
|
|
19
|
+
sig { params(text: String).void }
|
|
20
|
+
def initialize(text)
|
|
21
|
+
@scanner = StringScanner.new(text)
|
|
22
|
+
@buffer = SegmentBuffer.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig { returns(AST::Word) }
|
|
26
|
+
def scan
|
|
27
|
+
loop do
|
|
28
|
+
char = @scanner.getch
|
|
29
|
+
return @buffer.word unless char
|
|
30
|
+
|
|
31
|
+
step(char)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
sig { params(char: String).void }
|
|
38
|
+
def step(char)
|
|
39
|
+
return dollar if char == '$'
|
|
40
|
+
return backtick if char == '`'
|
|
41
|
+
return escape if char == '\\'
|
|
42
|
+
|
|
43
|
+
@buffer << char
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { void }
|
|
47
|
+
def dollar
|
|
48
|
+
peek?('(') ? dollar_paren : param
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { void }
|
|
52
|
+
def dollar_paren
|
|
53
|
+
@scanner.getch
|
|
54
|
+
reader = SubstitutionReader.new(@scanner)
|
|
55
|
+
peek?('(') ? arithmetic(reader) : command_sub(reader)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(reader: SubstitutionReader).void }
|
|
59
|
+
def command_sub(reader)
|
|
60
|
+
@buffer.push(AST::CommandSegment.new(reader.parens, false))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig { params(reader: SubstitutionReader).void }
|
|
64
|
+
def arithmetic(reader)
|
|
65
|
+
@scanner.getch
|
|
66
|
+
@buffer.push(AST::ArithSegment.new(reader.arithmetic, false))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { void }
|
|
70
|
+
def backtick
|
|
71
|
+
@buffer.push(AST::CommandSegment.new(SubstitutionReader.new(@scanner).backticks, false))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A here-doc body is a quoted context for its ${...} references: dash
|
|
75
|
+
# treats their operator words as if inside double quotes.
|
|
76
|
+
sig { void }
|
|
77
|
+
def param
|
|
78
|
+
ref = ParamScanner.new(@scanner, quoted: true).read
|
|
79
|
+
ref ? @buffer.push(AST::ParamSegment.new(ref, true)) : (@buffer << '$')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig { void }
|
|
83
|
+
def escape
|
|
84
|
+
char = @scanner.getch
|
|
85
|
+
return if char == "\n" # line continuation, as inside double quotes
|
|
86
|
+
|
|
87
|
+
@buffer << ESCAPE_TABLE.escape(char)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class Lexer
|
|
6
|
+
# Reads here-document bodies from the lexer's scanner once the command line's
|
|
7
|
+
# terminating newline is reached. For each pending `<<word` holder it gathers
|
|
8
|
+
# the following lines up to the delimiter line, strips leading tabs for the
|
|
9
|
+
# `<<-` form, and parses the body for expansion unless the delimiter was
|
|
10
|
+
# quoted (a quoted delimiter makes the body literal).
|
|
11
|
+
class HeredocReader
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
sig { params(scanner: StringScanner, interactive: T::Boolean).void }
|
|
15
|
+
def initialize(scanner, interactive:)
|
|
16
|
+
@scanner = scanner
|
|
17
|
+
@interactive = interactive
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fill each holder, in the order the `<<`s appeared, with its body.
|
|
21
|
+
sig { params(holders: T::Array[HereDoc]).void }
|
|
22
|
+
def fill(holders)
|
|
23
|
+
holders.each { |holder| holder.fill(read(holder)) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
sig { params(holder: HereDoc).returns(AST::Word) }
|
|
29
|
+
def read(holder)
|
|
30
|
+
build_body(holder, gather(holder, +''))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig { params(holder: HereDoc, out: String).returns(String) }
|
|
34
|
+
def gather(holder, out)
|
|
35
|
+
line = heredoc_line(holder)
|
|
36
|
+
return out unless line
|
|
37
|
+
|
|
38
|
+
gather(holder, out << line)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(holder: HereDoc).returns(T.nilable(String)) }
|
|
42
|
+
def heredoc_line(holder)
|
|
43
|
+
return eof_line if @scanner.eos?
|
|
44
|
+
|
|
45
|
+
line = read_line
|
|
46
|
+
return if delimiter?(holder, line)
|
|
47
|
+
|
|
48
|
+
strip_tabs(holder, line)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { returns(T.nilable(String)) }
|
|
52
|
+
def eof_line
|
|
53
|
+
raise IncompleteInput, 'unterminated here-document' if @interactive
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sig { returns(String) }
|
|
57
|
+
def read_line
|
|
58
|
+
@scanner.scan_until(/\n/) || rest_line
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
sig { returns(String) }
|
|
62
|
+
def rest_line
|
|
63
|
+
line = @scanner.rest
|
|
64
|
+
@scanner.terminate
|
|
65
|
+
line
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sig { params(holder: HereDoc, line: String).returns(T::Boolean) }
|
|
69
|
+
def delimiter?(holder, line)
|
|
70
|
+
strip_tabs(holder, line).chomp == holder.delimiter
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sig { params(holder: HereDoc, line: String).returns(String) }
|
|
74
|
+
def strip_tabs(holder, line)
|
|
75
|
+
holder.strip ? line.sub(/\A\t+/, '') : line
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A quoted delimiter (<<'EOF') makes the body literal; an unquoted one is
|
|
79
|
+
# parsed for expansion ($var, $(...), `...`), applied later at execution.
|
|
80
|
+
sig { params(holder: HereDoc, text: String).returns(AST::Word) }
|
|
81
|
+
def build_body(holder, text)
|
|
82
|
+
return literal_word(text) if holder.quoted
|
|
83
|
+
|
|
84
|
+
HeredocBody.new(text).scan
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
sig { params(text: String).returns(AST::Word) }
|
|
88
|
+
def literal_word(text)
|
|
89
|
+
AST::Word.new([AST::LiteralSegment.new(text, false)])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class Lexer
|
|
6
|
+
# Tracks command position (POSIX Grammar Rule 1) plus the for-header and case
|
|
7
|
+
# modes (Rules 4-6): the for NAME and `in`, and the case subject / `in` /
|
|
8
|
+
# pattern / `esac` positions. A compound stack lets nested for/case/if/etc.
|
|
9
|
+
# temporarily use their own header modes inside a case body, then return to
|
|
10
|
+
# the surrounding case body so `;;` and `esac` are classified correctly.
|
|
11
|
+
class LexState
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
REDIRECT_OPS = T.let(['<', '>', :DGREAT, :LESSGREAT, :CLOBBER].freeze, T::Array[T.any(String, Symbol)])
|
|
15
|
+
INTRODUCERS = T.let([
|
|
16
|
+
:NEWLINE, ';', '&', '|', '(', ')', :AND_IF, :OR_IF,
|
|
17
|
+
:If, :Then, :Else, :Elif, :Lbrace, :Bang, :While, :Until, :Do, :DSEMI
|
|
18
|
+
].freeze, T::Array[T.any(String, Symbol)])
|
|
19
|
+
NEUTRAL = T.let(%i[ASSIGNMENT_WORD IO_NUMBER].freeze, T::Array[Symbol])
|
|
20
|
+
OPENERS = T.let({ For: :Done, While: :Done, Until: :Done, If: :Fi,
|
|
21
|
+
Case: :Esac, Lbrace: :Rbrace, '(' => ')' }.freeze,
|
|
22
|
+
T::Hash[T.any(String, Symbol), T.any(String, Symbol)])
|
|
23
|
+
TRANSITIONS = {
|
|
24
|
+
%i[normal For] => :for_name, %i[case_body For] => :for_name,
|
|
25
|
+
%i[normal Case] => :case_subject, %i[case_body Case] => :case_subject,
|
|
26
|
+
%i[for_name NAME] => :for_in,
|
|
27
|
+
%i[for_in In] => :normal, %i[for_in Do] => :normal,
|
|
28
|
+
%i[for_in NEWLINE] => :normal, [:for_in, ';'] => :normal, [:for_in, '&'] => :normal,
|
|
29
|
+
%i[case_subject WORD] => :case_in, %i[case_in In] => :case_arm,
|
|
30
|
+
%i[case_arm WORD] => :case_pat, %i[case_arm Esac] => :normal,
|
|
31
|
+
[:case_pat, ')'] => :case_body, %i[case_body DSEMI] => :case_arm
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
sig { void }
|
|
35
|
+
def initialize
|
|
36
|
+
@command_position = true
|
|
37
|
+
@expect_filename = false
|
|
38
|
+
@mode = :normal
|
|
39
|
+
@compounds = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { returns(T::Boolean) }
|
|
43
|
+
def expects_command?
|
|
44
|
+
@command_position && !@expect_filename
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A command word here is a real command name, so alias substitution applies:
|
|
48
|
+
# the normal mode and a case arm's command list. The for-header and case
|
|
49
|
+
# subject/pattern modes do not expand aliases, even when a newline resets
|
|
50
|
+
# command position.
|
|
51
|
+
sig { returns(T::Boolean) }
|
|
52
|
+
def command_mode?
|
|
53
|
+
@mode == :normal || @mode == :case_body
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sig { returns(T::Boolean) }
|
|
57
|
+
def for_name?
|
|
58
|
+
@mode == :for_name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
sig { returns(T::Boolean) }
|
|
62
|
+
def for_in?
|
|
63
|
+
@mode == :for_in
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { returns(T::Boolean) }
|
|
67
|
+
def case_subject?
|
|
68
|
+
@mode == :case_subject
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
sig { returns(T::Boolean) }
|
|
72
|
+
def case_in?
|
|
73
|
+
@mode == :case_in
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sig { returns(T::Boolean) }
|
|
77
|
+
def case_arm?
|
|
78
|
+
@mode == :case_arm
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
sig { returns(T::Boolean) }
|
|
82
|
+
def case_pat?
|
|
83
|
+
@mode == :case_pat
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
sig { params(symbol: T.any(String, Symbol)).void }
|
|
87
|
+
def advance(symbol)
|
|
88
|
+
transition(symbol)
|
|
89
|
+
update_position(symbol)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
sig { params(symbol: T.any(String, Symbol)).void }
|
|
95
|
+
def transition(symbol)
|
|
96
|
+
start_compound(symbol)
|
|
97
|
+
@mode = TRANSITIONS.fetch([@mode, symbol], @mode)
|
|
98
|
+
@mode = T.must(@compounds.pop).last if @compounds.last&.first == symbol
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
sig { params(symbol: T.any(String, Symbol)).void }
|
|
102
|
+
def update_position(symbol)
|
|
103
|
+
return @expect_filename = true if REDIRECT_OPS.include?(symbol)
|
|
104
|
+
return reset if INTRODUCERS.include?(symbol)
|
|
105
|
+
return @expect_filename = false if NEUTRAL.include?(symbol)
|
|
106
|
+
|
|
107
|
+
consume_word
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
sig { params(symbol: T.any(String, Symbol)).void }
|
|
111
|
+
def start_compound(symbol)
|
|
112
|
+
close = OPENERS.fetch(symbol, nil)
|
|
113
|
+
@compounds << [close, @mode] if close && expects_command? && command_mode?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
sig { void }
|
|
117
|
+
def reset
|
|
118
|
+
@command_position = true
|
|
119
|
+
@expect_filename = false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
sig { void }
|
|
123
|
+
def consume_word
|
|
124
|
+
@expect_filename ? (@expect_filename = false) : (@command_position = false)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class Lexer
|
|
6
|
+
# Maximal-munch operator table: alternatives are tried longest-first so `>>`
|
|
7
|
+
# beats `>`, `&&` beats `&`, `>|` beats `>`, and the fd-dup `>&`/`<&` beat
|
|
8
|
+
# `>`/`<` then `&`. Single-character operators are their own token (matching
|
|
9
|
+
# the grammar's '<' '>' '|' '&' ';'); multi-char operators map to named tokens.
|
|
10
|
+
module OperatorTable
|
|
11
|
+
OPERATORS = {
|
|
12
|
+
'<<-' => :DLESSDASH, '&&' => :AND_IF, '||' => :OR_IF, '>>' => :DGREAT,
|
|
13
|
+
'<<' => :DLESS, ';;' => :DSEMI, '<>' => :LESSGREAT, '>|' => :CLOBBER,
|
|
14
|
+
'>&' => :GREATAND, '<&' => :LESSAND,
|
|
15
|
+
'<' => '<', '>' => '>', '|' => '|', '&' => '&', ';' => ';', '(' => '(', ')' => ')'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# POSIX 2.2.1 removes a backslash-newline before tokenization — even between
|
|
19
|
+
# the characters of one operator: dash splices it at the character read
|
|
20
|
+
# (pgetc_eatbnl), so `&\<newline>&` lexes as `&&`. Each alternative therefore
|
|
21
|
+
# admits continuation pairs between its characters; the lexer splices them
|
|
22
|
+
# back out of the match before looking the operator up.
|
|
23
|
+
CONTINUATION = /\\\n/
|
|
24
|
+
|
|
25
|
+
splice = "(?:#{CONTINUATION.source})*"
|
|
26
|
+
longest_first = OPERATORS.keys.sort_by { |op| -op.length }
|
|
27
|
+
PATTERN = Regexp.new(
|
|
28
|
+
longest_first.map { |op| op.chars.map { |char| Regexp.escape(char) }.join(splice) }.join('|')
|
|
29
|
+
).freeze
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Rush
|
|
5
|
+
class Lexer
|
|
6
|
+
# Reads the parameter reference after a consumed `$` at the scanner head:
|
|
7
|
+
# $name / $digit / $special, or the braced ${...} forms (whose body the
|
|
8
|
+
# BracedReader matches to the closing brace). Returns nil when no reference
|
|
9
|
+
# begins, so the caller keeps the `$` literal. An unterminated ${ raises
|
|
10
|
+
# the given error class: IncompleteInput lets an interactive word ask for
|
|
11
|
+
# another line, while heredoc bodies and prompt values are already complete
|
|
12
|
+
# and fail as plain parse errors. `quoted` names the surrounding context —
|
|
13
|
+
# inside double quotes and here-doc bodies a single quote is ordinary.
|
|
14
|
+
class ParamScanner
|
|
15
|
+
extend T::Sig
|
|
16
|
+
|
|
17
|
+
PARAM = /[a-zA-Z_]\w*|\d|[@*#?$!\-0]/
|
|
18
|
+
|
|
19
|
+
sig { params(scanner: StringScanner, quoted: T::Boolean, error: T.class_of(ParseError)).void }
|
|
20
|
+
def initialize(scanner, quoted:, error: ParseError)
|
|
21
|
+
@scanner = scanner
|
|
22
|
+
@error = error
|
|
23
|
+
@quoted = quoted
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { returns(T.nilable(AST::ParamRef)) }
|
|
27
|
+
def read
|
|
28
|
+
return braced if @scanner.peek(1) == '{'
|
|
29
|
+
|
|
30
|
+
name = @scanner.scan(PARAM)
|
|
31
|
+
name && AST::ParamRef.simple(name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
sig { returns(AST::ParamRef) }
|
|
37
|
+
def braced
|
|
38
|
+
@scanner.getch
|
|
39
|
+
AST::ParamRef.parse(BracedReader.new(@scanner, error: @error, quoted: @quoted).read)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|