rush-shell 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +94 -0
  4. data/exe/rush +10 -0
  5. data/grammar/shell.y +272 -0
  6. data/grammar/shell.y.output +2354 -0
  7. data/lib/rush/alias_table.rb +48 -0
  8. data/lib/rush/assignments.rb +48 -0
  9. data/lib/rush/ast/and_or.rb +37 -0
  10. data/lib/rush/ast/assignment.rb +20 -0
  11. data/lib/rush/ast/brace_group.rb +25 -0
  12. data/lib/rush/ast/case_node.rb +41 -0
  13. data/lib/rush/ast/condition_loop.rb +34 -0
  14. data/lib/rush/ast/for_node.rb +34 -0
  15. data/lib/rush/ast/function_def.rb +26 -0
  16. data/lib/rush/ast/if_node.rb +29 -0
  17. data/lib/rush/ast/list.rb +47 -0
  18. data/lib/rush/ast/node.rb +17 -0
  19. data/lib/rush/ast/param_ref.rb +59 -0
  20. data/lib/rush/ast/pipeline.rb +45 -0
  21. data/lib/rush/ast/redirect.rb +21 -0
  22. data/lib/rush/ast/redirected.rb +26 -0
  23. data/lib/rush/ast/simple_command.rb +62 -0
  24. data/lib/rush/ast/subshell.rb +24 -0
  25. data/lib/rush/ast/until_node.rb +18 -0
  26. data/lib/rush/ast/while_node.rb +18 -0
  27. data/lib/rush/ast/word.rb +44 -0
  28. data/lib/rush/ast/word_segment.rb +203 -0
  29. data/lib/rush/background_runner.rb +68 -0
  30. data/lib/rush/bracket_expression.rb +107 -0
  31. data/lib/rush/bracket_scanner.rb +64 -0
  32. data/lib/rush/builtins/alias_.rb +74 -0
  33. data/lib/rush/builtins/base.rb +96 -0
  34. data/lib/rush/builtins/break_.rb +19 -0
  35. data/lib/rush/builtins/cd.rb +127 -0
  36. data/lib/rush/builtins/colon.rb +16 -0
  37. data/lib/rush/builtins/command.rb +137 -0
  38. data/lib/rush/builtins/command_options.rb +103 -0
  39. data/lib/rush/builtins/continue_.rb +19 -0
  40. data/lib/rush/builtins/declare.rb +27 -0
  41. data/lib/rush/builtins/defaults.rb +28 -0
  42. data/lib/rush/builtins/dot.rb +52 -0
  43. data/lib/rush/builtins/echo.rb +37 -0
  44. data/lib/rush/builtins/eval.rb +25 -0
  45. data/lib/rush/builtins/exec.rb +54 -0
  46. data/lib/rush/builtins/exit.rb +45 -0
  47. data/lib/rush/builtins/export.rb +20 -0
  48. data/lib/rush/builtins/false_.rb +16 -0
  49. data/lib/rush/builtins/fd_operand.rb +87 -0
  50. data/lib/rush/builtins/getopts.rb +96 -0
  51. data/lib/rush/builtins/hash.rb +64 -0
  52. data/lib/rush/builtins/job_resume.rb +107 -0
  53. data/lib/rush/builtins/jobs.rb +86 -0
  54. data/lib/rush/builtins/kill.rb +152 -0
  55. data/lib/rush/builtins/local.rb +40 -0
  56. data/lib/rush/builtins/loop_jump.rb +45 -0
  57. data/lib/rush/builtins/printf.rb +39 -0
  58. data/lib/rush/builtins/printf_formatter.rb +111 -0
  59. data/lib/rush/builtins/pwd.rb +17 -0
  60. data/lib/rush/builtins/read.rb +93 -0
  61. data/lib/rush/builtins/read_input.rb +146 -0
  62. data/lib/rush/builtins/readonly.rb +20 -0
  63. data/lib/rush/builtins/registry.rb +36 -0
  64. data/lib/rush/builtins/return_.rb +24 -0
  65. data/lib/rush/builtins/set.rb +101 -0
  66. data/lib/rush/builtins/shift.rb +35 -0
  67. data/lib/rush/builtins/test_.rb +48 -0
  68. data/lib/rush/builtins/test_context.rb +24 -0
  69. data/lib/rush/builtins/test_expr.rb +74 -0
  70. data/lib/rush/builtins/test_grammar.rb +117 -0
  71. data/lib/rush/builtins/test_operators.rb +94 -0
  72. data/lib/rush/builtins/test_tokens.rb +69 -0
  73. data/lib/rush/builtins/times.rb +35 -0
  74. data/lib/rush/builtins/trap.rb +85 -0
  75. data/lib/rush/builtins/true_.rb +16 -0
  76. data/lib/rush/builtins/type_.rb +28 -0
  77. data/lib/rush/builtins/ulimit.rb +258 -0
  78. data/lib/rush/builtins/umask.rb +81 -0
  79. data/lib/rush/builtins/unalias.rb +46 -0
  80. data/lib/rush/builtins/unset.rb +40 -0
  81. data/lib/rush/builtins/wait.rb +127 -0
  82. data/lib/rush/cli.rb +31 -0
  83. data/lib/rush/command_assignments.rb +56 -0
  84. data/lib/rush/command_lookup.rb +228 -0
  85. data/lib/rush/command_resolution.rb +115 -0
  86. data/lib/rush/command_runner.rb +152 -0
  87. data/lib/rush/command_text.rb +140 -0
  88. data/lib/rush/environment.rb +134 -0
  89. data/lib/rush/errexit_context.rb +68 -0
  90. data/lib/rush/error_policy.rb +64 -0
  91. data/lib/rush/errors.rb +100 -0
  92. data/lib/rush/escape_table.rb +22 -0
  93. data/lib/rush/escaped_bracket.rb +49 -0
  94. data/lib/rush/executor.rb +164 -0
  95. data/lib/rush/exit_trap.rb +109 -0
  96. data/lib/rush/expansion/arithmetic/evaluator.rb +43 -0
  97. data/lib/rush/expansion/arithmetic/nodes.rb +180 -0
  98. data/lib/rush/expansion/arithmetic/number.rb +104 -0
  99. data/lib/rush/expansion/arithmetic/parser.rb +155 -0
  100. data/lib/rush/expansion/arithmetic/tokenizer.rb +56 -0
  101. data/lib/rush/expansion/arithmetic_expander.rb +38 -0
  102. data/lib/rush/expansion/command_substitution.rb +87 -0
  103. data/lib/rush/expansion/field_part.rb +12 -0
  104. data/lib/rush/expansion/field_splitter.rb +23 -0
  105. data/lib/rush/expansion/glob_expander.rb +48 -0
  106. data/lib/rush/expansion/ifs.rb +62 -0
  107. data/lib/rush/expansion/ifs_scanner.rb +168 -0
  108. data/lib/rush/expansion/parameter_expander.rb +204 -0
  109. data/lib/rush/expansion/parameter_forms.rb +22 -0
  110. data/lib/rush/expansion/pattern_removal.rb +63 -0
  111. data/lib/rush/expansion/pipeline.rb +124 -0
  112. data/lib/rush/expansion/read_char.rb +13 -0
  113. data/lib/rush/expansion/read_field_scanner.rb +159 -0
  114. data/lib/rush/expansion/read_splitter.rb +40 -0
  115. data/lib/rush/expansion/resolver.rb +23 -0
  116. data/lib/rush/expansion/tilde_expander.rb +102 -0
  117. data/lib/rush/external.rb +75 -0
  118. data/lib/rush/fd_entry.rb +60 -0
  119. data/lib/rush/for_runner.rb +50 -0
  120. data/lib/rush/function_frame.rb +30 -0
  121. data/lib/rush/function_runner.rb +37 -0
  122. data/lib/rush/function_table.rb +35 -0
  123. data/lib/rush/getopts_parser.rb +195 -0
  124. data/lib/rush/getopts_state.rb +104 -0
  125. data/lib/rush/here_doc.rb +57 -0
  126. data/lib/rush/interactive_signals.rb +26 -0
  127. data/lib/rush/invocation.rb +190 -0
  128. data/lib/rush/io_table.rb +94 -0
  129. data/lib/rush/job_control.rb +197 -0
  130. data/lib/rush/job_report.rb +45 -0
  131. data/lib/rush/job_spec.rb +51 -0
  132. data/lib/rush/job_table/control.rb +103 -0
  133. data/lib/rush/job_table/interruptible_wait.rb +70 -0
  134. data/lib/rush/job_table/job.rb +181 -0
  135. data/lib/rush/job_table.rb +194 -0
  136. data/lib/rush/lexer/alias_expander.rb +129 -0
  137. data/lib/rush/lexer/braced_reader.rb +86 -0
  138. data/lib/rush/lexer/case_frame.rb +114 -0
  139. data/lib/rush/lexer/case_tracker.rb +146 -0
  140. data/lib/rush/lexer/dollar_scanner.rb +53 -0
  141. data/lib/rush/lexer/double_quote_scanner.rb +71 -0
  142. data/lib/rush/lexer/heredoc_body.rb +91 -0
  143. data/lib/rush/lexer/heredoc_reader.rb +93 -0
  144. data/lib/rush/lexer/lex_state.rb +128 -0
  145. data/lib/rush/lexer/operator_table.rb +32 -0
  146. data/lib/rush/lexer/param_scanner.rb +43 -0
  147. data/lib/rush/lexer/paren_reader.rb +113 -0
  148. data/lib/rush/lexer/paren_regions.rb +74 -0
  149. data/lib/rush/lexer/quote_skips.rb +77 -0
  150. data/lib/rush/lexer/quoted_word.rb +67 -0
  151. data/lib/rush/lexer/scanner_predicates.rb +20 -0
  152. data/lib/rush/lexer/source_lines.rb +43 -0
  153. data/lib/rush/lexer/substitution_reader.rb +77 -0
  154. data/lib/rush/lexer/token_classifier.rb +144 -0
  155. data/lib/rush/lexer/token_predicates.rb +23 -0
  156. data/lib/rush/lexer/word_scanner.rb +134 -0
  157. data/lib/rush/lexer.rb +174 -0
  158. data/lib/rush/loop_control_handling.rb +29 -0
  159. data/lib/rush/loop_nesting.rb +50 -0
  160. data/lib/rush/loop_runner.rb +55 -0
  161. data/lib/rush/option_cluster.rb +39 -0
  162. data/lib/rush/options.rb +76 -0
  163. data/lib/rush/param_text.rb +39 -0
  164. data/lib/rush/parser.rb +1213 -0
  165. data/lib/rush/parser_support.rb +112 -0
  166. data/lib/rush/pattern_scanner.rb +35 -0
  167. data/lib/rush/pending_signals.rb +57 -0
  168. data/lib/rush/pipeline_runner.rb +173 -0
  169. data/lib/rush/pipeline_statuses.rb +45 -0
  170. data/lib/rush/positional.rb +45 -0
  171. data/lib/rush/posix_pattern.rb +78 -0
  172. data/lib/rush/program_input.rb +89 -0
  173. data/lib/rush/program_reader.rb +69 -0
  174. data/lib/rush/program_session.rb +83 -0
  175. data/lib/rush/prompt.rb +52 -0
  176. data/lib/rush/redirect_scope.rb +64 -0
  177. data/lib/rush/redirection/dup_redirect.rb +58 -0
  178. data/lib/rush/redirection/file_redirect.rb +50 -0
  179. data/lib/rush/redirection/here_doc_redirect.rb +17 -0
  180. data/lib/rush/redirection/registry.rb +53 -0
  181. data/lib/rush/repl.rb +133 -0
  182. data/lib/rush/runtime_type_checks.rb +16 -0
  183. data/lib/rush/scope.rb +79 -0
  184. data/lib/rush/segment_buffer.rb +47 -0
  185. data/lib/rush/shell_parameters.rb +77 -0
  186. data/lib/rush/shell_pattern.rb +80 -0
  187. data/lib/rush/shell_state.rb +163 -0
  188. data/lib/rush/shell_variables.rb +67 -0
  189. data/lib/rush/signal_report.rb +41 -0
  190. data/lib/rush/signals.rb +68 -0
  191. data/lib/rush/source.rb +82 -0
  192. data/lib/rush/source_line_counter.rb +41 -0
  193. data/lib/rush/source_runner.rb +47 -0
  194. data/lib/rush/startup.rb +75 -0
  195. data/lib/rush/status.rb +93 -0
  196. data/lib/rush/stop_relay.rb +32 -0
  197. data/lib/rush/subshell_runner.rb +70 -0
  198. data/lib/rush/system_calls/collation.rb +155 -0
  199. data/lib/rush/system_calls/file_tests.rb +95 -0
  200. data/lib/rush/system_calls/process_control.rb +165 -0
  201. data/lib/rush/system_calls/process_identity.rb +43 -0
  202. data/lib/rush/system_calls/resource_limits.rb +51 -0
  203. data/lib/rush/system_calls.rb +206 -0
  204. data/lib/rush/terminal.rb +126 -0
  205. data/lib/rush/trap_runner.rb +169 -0
  206. data/lib/rush/trap_table.rb +49 -0
  207. data/lib/rush/umask_mode.rb +133 -0
  208. data/lib/rush/version.rb +6 -0
  209. data/lib/rush.rb +190 -0
  210. metadata +527 -0
@@ -0,0 +1,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