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,113 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # Reads the body of a $( ... ) after the consumed `$(`, up to the closing
7
+ # `)` (POSIX 2.6.3: any valid shell script may appear inside). Plain
8
+ # parentheses nest; quoted strings, backslash escapes, backticks, ${...}
9
+ # references and `#` comments are skipped whole (ParenRegions); and a
10
+ # `case` pattern's unbalanced `)` (CaseTracker) does not count. The body
11
+ # keeps its raw spelling — it is re-parsed at expansion time.
12
+ class ParenReader
13
+ extend T::Sig
14
+ include ParenRegions
15
+
16
+ # The longest run with no syntactic weight for paren matching.
17
+ PLAIN = /[^\s()'"\\`;&|<>#$]+/
18
+
19
+ # Which CaseTracker event each operator character raises.
20
+ OPERATORS = {
21
+ '&' => :separator, '|' => :separator, "\n" => :newline,
22
+ '<' => :redirect, '>' => :redirect
23
+ }.freeze
24
+
25
+ sig { params(scanner: StringScanner).void }
26
+ def initialize(scanner)
27
+ @scanner = scanner
28
+ @tracker = T.let(CaseTracker.new, CaseTracker)
29
+ @word_start = T.let(true, T::Boolean)
30
+ end
31
+
32
+ sig { returns(String) }
33
+ def read
34
+ body = +''
35
+ body << step while @tracker.open?
36
+ body
37
+ end
38
+
39
+ private
40
+
41
+ sig { returns(String) }
42
+ def step
43
+ blank || word || region || punct || closer
44
+ end
45
+
46
+ sig { returns(T.nilable(String)) }
47
+ def blank
48
+ text = @scanner.scan(/[^\S\n]+/)
49
+ @word_start = true if text
50
+ text
51
+ end
52
+
53
+ sig { returns(T.nilable(String)) }
54
+ def word
55
+ text = @scanner.scan(PLAIN)
56
+ return unless text
57
+
58
+ @tracker.word(@word_start ? text : '')
59
+ @word_start = false
60
+ text
61
+ end
62
+
63
+ sig { returns(T.nilable(String)) }
64
+ def punct
65
+ semi || operator || open_paren
66
+ end
67
+
68
+ sig { returns(T.nilable(String)) }
69
+ def semi
70
+ return unless @scanner.scan(';')
71
+
72
+ @word_start = true
73
+ return dsemi if @scanner.scan(';')
74
+
75
+ @tracker.separator
76
+ ';'
77
+ end
78
+
79
+ sig { returns(String) }
80
+ def dsemi
81
+ @tracker.dsemi
82
+ ';;'
83
+ end
84
+
85
+ sig { returns(T.nilable(String)) }
86
+ def operator
87
+ char = @scanner.scan(/[&|<>\n]/)
88
+ return unless char
89
+
90
+ @tracker.public_send(OPERATORS.fetch(char))
91
+ @word_start = true
92
+ char
93
+ end
94
+
95
+ sig { returns(T.nilable(String)) }
96
+ def open_paren
97
+ return unless @scanner.scan('(')
98
+
99
+ @tracker.open_paren
100
+ @word_start = true
101
+ '('
102
+ end
103
+
104
+ sig { returns(String) }
105
+ def closer
106
+ raise IncompleteInput, 'unterminated $(' unless @scanner.scan(')')
107
+
108
+ @word_start = true
109
+ @tracker.close_paren? ? ')' : ''
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,74 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # The skip-whole regions of a $( ... ) body (ParenReader's mixin): quoted
7
+ # strings, backslash escapes, backticks, ${...} references, and `#`
8
+ # comments. A region reads as one ordinary — never keyword — word to the
9
+ # CaseTracker, and its text returns with the delimiters so the body keeps
10
+ # its raw spelling for the expansion-time re-parse.
11
+ module ParenRegions
12
+ extend T::Sig
13
+ include QuoteSkips
14
+
15
+ private
16
+
17
+ sig { returns(T.nilable(String)) }
18
+ def region
19
+ squote || dquote || backtick || escape || dollar || comment
20
+ end
21
+
22
+ sig { returns(T.nilable(String)) }
23
+ def squote
24
+ skipped(single) if @scanner.scan("'")
25
+ end
26
+
27
+ sig { returns(T.nilable(String)) }
28
+ def dquote
29
+ skipped(double) if @scanner.scan('"')
30
+ end
31
+
32
+ sig { returns(T.nilable(String)) }
33
+ def backtick
34
+ skipped("`#{SubstitutionReader.new(@scanner).backticks}`") if @scanner.scan('`')
35
+ end
36
+
37
+ sig { returns(T.nilable(String)) }
38
+ def escape
39
+ skipped("\\#{must_char}") if @scanner.scan('\\')
40
+ end
41
+
42
+ sig { returns(T.nilable(String)) }
43
+ def dollar
44
+ return unless @scanner.scan('$')
45
+ return skipped('$') unless @scanner.scan('{')
46
+
47
+ skipped("${#{braced}}")
48
+ end
49
+
50
+ sig { returns(String) }
51
+ def braced
52
+ BracedReader.new(@scanner, error: IncompleteInput, quoted: false).read
53
+ end
54
+
55
+ # A `#` at the start of a word begins a comment running to the line
56
+ # end (kept raw, so a `)` inside it does not count); mid-word it is an
57
+ # ordinary character.
58
+ sig { returns(T.nilable(String)) }
59
+ def comment
60
+ return unless peek?('#')
61
+ return skipped(must_char) unless @word_start
62
+
63
+ @scanner.scan(/#[^\n]*/)
64
+ end
65
+
66
+ sig { params(text: String).returns(String) }
67
+ def skipped(text)
68
+ @tracker.word('')
69
+ @word_start = false
70
+ text
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,77 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # Quote-region skipping shared by the readers that must find a closing
7
+ # delimiter in raw source (BracedReader's `}`, ParenReader's `)`). Each
8
+ # helper consumes one region after its opening character and returns it
9
+ # with its delimiters, so the body keeps its raw spelling; a backslash
10
+ # always protects exactly the next character. Includers provide @scanner
11
+ # and may override #error_class to raise their configured error.
12
+ module QuoteSkips
13
+ extend T::Sig
14
+ include Kernel
15
+ include ScannerPredicates
16
+
17
+ private
18
+
19
+ # The error for unterminated input; an unfinished region can continue
20
+ # on the next interactive line.
21
+ sig { overridable.returns(T.class_of(ParseError)) }
22
+ def error_class
23
+ IncompleteInput
24
+ end
25
+
26
+ sig { returns(String) }
27
+ def must_char
28
+ @scanner.getch || raise(error_class, 'unterminated escape')
29
+ end
30
+
31
+ sig { returns(String) }
32
+ def single
33
+ body = @scanner.scan(/[^']*/)
34
+ raise error_class, 'unterminated single quote' unless @scanner.scan("'")
35
+
36
+ "'#{body}'"
37
+ end
38
+
39
+ sig { returns(String) }
40
+ def double
41
+ body = +'"'
42
+ body << double_chunk until peek?('"')
43
+ @scanner.getch
44
+ body << '"'
45
+ end
46
+
47
+ sig { returns(String) }
48
+ def double_chunk
49
+ @scanner.scan(/[^"\\$`]+/) || double_special
50
+ end
51
+
52
+ # `\`, `$` and backticks stay live inside double quotes: an embedded
53
+ # $( ... ) or ${ ... } is skipped whole, so its `)` and `}` never count.
54
+ sig { returns(String) }
55
+ def double_special
56
+ return "\\#{must_char}" if @scanner.scan('\\')
57
+ return "`#{SubstitutionReader.new(@scanner).backticks}`" if @scanner.scan('`')
58
+
59
+ double_dollar
60
+ end
61
+
62
+ sig { returns(String) }
63
+ def double_dollar
64
+ raise error_class, 'unterminated double quote' unless @scanner.scan('$')
65
+ return "${#{quoted_braced}}" if @scanner.scan('{')
66
+ return '$' unless @scanner.scan('(')
67
+
68
+ "$(#{SubstitutionReader.new(@scanner).parens})"
69
+ end
70
+
71
+ sig { returns(String) }
72
+ def quoted_braced
73
+ BracedReader.new(@scanner, error: error_class, quoted: true).read
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,67 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'strscan'
5
+
6
+ module Rush
7
+ class Lexer
8
+ # Scans the operator word of a ${...} that appeared in a quoted context
9
+ # (double quotes or a here-doc body) into an AST::Word, following dash:
10
+ # $ and ` stay live, a backslash escapes " \ $ ` and the } it protected
11
+ # from closing the reference, embedded double quotes are removed (their
12
+ # content was already quoted), and single quotes are ordinary characters.
13
+ class QuotedWord
14
+ extend T::Sig
15
+ include ScannerPredicates
16
+
17
+ ESCAPES = { '"' => '"', '\\' => '\\', '$' => '$', '`' => '`', '}' => '}' }.freeze
18
+ ESCAPE_TABLE = EscapeTable.new(ESCAPES).freeze
19
+ DISPATCH = { '$' => :dollar, '`' => :backtick, '\\' => :escape }.freeze
20
+
21
+ sig { params(text: String).void }
22
+ def initialize(text)
23
+ @scanner = StringScanner.new(text)
24
+ @buffer = SegmentBuffer.new
25
+ end
26
+
27
+ sig { returns(AST::Word) }
28
+ def word
29
+ step until @scanner.eos?
30
+ @buffer.word
31
+ end
32
+
33
+ private
34
+
35
+ sig { void }
36
+ def step
37
+ char = T.must(@scanner.getch)
38
+ return if char == '"'
39
+
40
+ handler = DISPATCH.fetch(char, nil)
41
+ handler ? send(handler) : (@buffer << char)
42
+ end
43
+
44
+ # A lone `$` that begins no valid reference stays a literal dollar.
45
+ sig { void }
46
+ def dollar
47
+ @scanner.unscan
48
+ segment = DollarScanner.new(@scanner).read(quoted: true)
49
+ segment ? @buffer.push(segment) : (@buffer << '$')
50
+ end
51
+
52
+ sig { void }
53
+ def backtick
54
+ @scanner.unscan
55
+ @buffer.push(DollarScanner.new(@scanner).read_backtick(quoted: true))
56
+ end
57
+
58
+ sig { void }
59
+ def escape
60
+ char = @scanner.getch
61
+ return if char == "\n" # line continuation, as inside double quotes
62
+
63
+ @buffer << ESCAPE_TABLE.escape(char)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,20 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # Small typed adapter for the StringScanner one-character lookahead pattern
7
+ # shared by the shell word scanners. StringScanner#peek is a library boundary;
8
+ # callers use this predicate instead of repeating the comparison everywhere.
9
+ module ScannerPredicates
10
+ extend T::Sig
11
+
12
+ private
13
+
14
+ sig { params(char: String).returns(T::Boolean) }
15
+ def peek?(char)
16
+ @scanner.peek(1) == char
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,43 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # Tracks source line numbers for tokens produced from a StringScanner. The
7
+ # lexer itself owns tokenization; this helper only maps consumed scanner text
8
+ # to absolute source lines.
9
+ class SourceLines
10
+ extend T::Sig
11
+
12
+ sig { params(scanner: StringScanner, offset: Integer).void }
13
+ def initialize(scanner, offset)
14
+ @scanner = scanner
15
+ @line = offset + 1
16
+ end
17
+
18
+ sig { params(blk: T.proc.returns(AST::Word)).returns(AST::Word) }
19
+ def word(&blk)
20
+ line = @line
21
+ start = @scanner.pos
22
+ yield.tap { count_newlines_since(start) }.then { |word| AST::Word.new(word.segments, source_line: line) }
23
+ end
24
+
25
+ sig { params(start: Integer).void }
26
+ def heredoc_newline(start)
27
+ @line += consumed_since(start).count("\n") + 1
28
+ end
29
+
30
+ private
31
+
32
+ sig { params(start: Integer).void }
33
+ def count_newlines_since(start)
34
+ @line += consumed_since(start).count("\n")
35
+ end
36
+
37
+ sig { params(start: Integer).returns(String) }
38
+ def consumed_since(start)
39
+ @scanner.string[start...@scanner.pos].to_s
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,77 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # Reads the raw body of a command substitution from the shared scanner:
7
+ # $( ... ) up to the matching quote-aware `)` (via ParenReader), or
8
+ # ` ... ` up to the next backtick. The body is re-parsed and executed at
9
+ # expansion time.
10
+ class SubstitutionReader
11
+ extend T::Sig
12
+
13
+ DEPTH_DELTA = { '(' => 1, ')' => -1 }.freeze
14
+
15
+ sig { params(scanner: StringScanner).void }
16
+ def initialize(scanner)
17
+ @scanner = scanner
18
+ @depth = 0
19
+ end
20
+
21
+ sig { returns(String) }
22
+ def parens
23
+ ParenReader.new(@scanner).read
24
+ end
25
+
26
+ sig { returns(String) }
27
+ def backticks
28
+ body = @scanner.scan(/[^`]*/) || ''
29
+ raise IncompleteInput, 'unterminated `' unless @scanner.scan('`')
30
+
31
+ body
32
+ end
33
+
34
+ # Read the body of $(( ... )) after the leading `((`, up to the matching
35
+ # `))`, allowing balanced inner parentheses.
36
+ sig { returns(String) }
37
+ def arithmetic
38
+ @depth = 0
39
+ collect(+'')
40
+ end
41
+
42
+ private
43
+
44
+ sig { params(body: String).returns(String) }
45
+ def collect(body)
46
+ piece = arith_char
47
+ return body unless piece
48
+
49
+ collect(body << piece)
50
+ end
51
+
52
+ sig { returns(T.nilable(String)) }
53
+ def arith_char
54
+ raise IncompleteInput, 'unterminated $((' if @scanner.eos?
55
+
56
+ adjust_arithmetic(@scanner.getch)
57
+ end
58
+
59
+ sig { params(token: T.nilable(String)).returns(T.nilable(String)) }
60
+ def adjust_arithmetic(token)
61
+ delta = DEPTH_DELTA.fetch(token, 0)
62
+ return arith_close if delta.negative?
63
+
64
+ @depth += delta
65
+ token
66
+ end
67
+
68
+ sig { returns(T.nilable(String)) }
69
+ def arith_close
70
+ return ')'.tap { @depth -= 1 } if @depth.nonzero?
71
+ raise(ParseError, 'arithmetic: malformed') unless @scanner.scan(')')
72
+
73
+ nil
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,144 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # Classifies a scanned word given the lexer state: a reserved word (only an
7
+ # unquoted single-literal word in command position), an ASSIGNMENT_WORD (an
8
+ # unquoted NAME= prefix), or a plain WORD. The assignment value keeps its
9
+ # quoted segments (x="a b").
10
+ class TokenClassifier
11
+ extend T::Sig
12
+
13
+ # The assignment-name capture plus the literal segment it came from.
14
+ class AssignmentHead
15
+ extend T::Sig
16
+
17
+ sig { returns(String) }
18
+ attr_reader :name, :literal
19
+
20
+ sig { params(name: String, literal: String, source_line: Integer).void }
21
+ def initialize(name, literal, source_line)
22
+ @name = name
23
+ @literal = literal
24
+ @source_line = source_line
25
+ end
26
+
27
+ sig { params(tail: T::Array[AST::WordSegment[T.untyped]]).returns(AST::Assignment) }
28
+ def assignment(tail)
29
+ AST::Assignment.new(name: name, value: word(tail))
30
+ end
31
+
32
+ private
33
+
34
+ sig { params(tail: T::Array[AST::WordSegment[T.untyped]]).returns(AST::Word) }
35
+ def word(tail)
36
+ remainder = AST::LiteralSegment.new(literal.delete_prefix("#{name}="), false)
37
+ AST::Word.new([remainder] + tail, source_line: @source_line)
38
+ end
39
+ end
40
+
41
+ NAME = /\A([a-zA-Z_]\w*)=/
42
+ # `esac` is a reserved word in command position (POSIX 2.4): it closes a
43
+ # case item whose last list omits `;;`, and is a syntax error elsewhere.
44
+ RESERVED = {
45
+ 'if' => :If, 'then' => :Then, 'else' => :Else, 'elif' => :Elif, 'fi' => :Fi,
46
+ 'while' => :While, 'until' => :Until, 'do' => :Do, 'done' => :Done,
47
+ 'for' => :For, 'case' => :Case, 'esac' => :Esac,
48
+ '{' => :Lbrace, '}' => :Rbrace, '!' => :Bang
49
+ }.freeze
50
+ FOR_IN = { 'in' => :In, 'do' => :Do }.freeze
51
+
52
+ sig { params(word: AST::Word, state: LexState).void }
53
+ def initialize(word, state)
54
+ @word = word
55
+ @state = state
56
+ end
57
+
58
+ sig { returns([Symbol, T.untyped]) }
59
+ def call
60
+ state_token || classify
61
+ end
62
+
63
+ private
64
+
65
+ sig { returns(T.nilable([Symbol, T.untyped])) }
66
+ def state_token
67
+ forced || header_token
68
+ end
69
+
70
+ sig { returns(T.nilable([Symbol, T.untyped])) }
71
+ def forced
72
+ return [:NAME, @word] if @state.for_name?
73
+ return [arm_token, @word] if @state.case_arm?
74
+
75
+ [:WORD, @word] if @state.case_subject? || @state.case_pat?
76
+ end
77
+
78
+ sig { returns(T.nilable([Symbol, T.untyped])) }
79
+ def header_token
80
+ keyword = for_header
81
+ return [keyword, @word] if @state.for_in? && keyword
82
+
83
+ [:In, @word] if @state.case_in? && @word.literal_name == 'in'
84
+ end
85
+
86
+ sig { returns(Symbol) }
87
+ def arm_token
88
+ @word.literal_name == 'esac' ? :Esac : :WORD
89
+ end
90
+
91
+ sig { returns([Symbol, T.untyped]) }
92
+ def classify
93
+ keyword = reserved
94
+ return [keyword, @word] if keyword
95
+
96
+ head = assignment_head
97
+ head ? assignment_token(head) : [:WORD, @word]
98
+ end
99
+
100
+ sig { returns(T.nilable(Symbol)) }
101
+ def for_header
102
+ lookup(FOR_IN)
103
+ end
104
+
105
+ sig { returns(T.nilable(Symbol)) }
106
+ def reserved
107
+ lookup(RESERVED) if @state.expects_command?
108
+ end
109
+
110
+ sig { params(table: T::Hash[String, Symbol]).returns(T.nilable(Symbol)) }
111
+ def lookup(table)
112
+ name = @word.literal_name
113
+ name ? table.fetch(name, nil) : nil
114
+ end
115
+
116
+ sig { returns(T.nilable(AssignmentHead)) }
117
+ def assignment_head
118
+ return unless @state.expects_command?
119
+
120
+ literal = assignment_literal
121
+ return unless literal
122
+
123
+ name = capture(literal)
124
+ name ? AssignmentHead.new(name, literal, @word.source_line) : nil
125
+ end
126
+
127
+ sig { returns(T.nilable(String)) }
128
+ def assignment_literal
129
+ @word.first_literal_value
130
+ end
131
+
132
+ sig { params(value: String).returns(T.nilable(String)) }
133
+ def capture(value)
134
+ match = NAME.match(value)
135
+ match && match[1]
136
+ end
137
+
138
+ sig { params(head: AssignmentHead).returns([Symbol, T.untyped]) }
139
+ def assignment_token(head)
140
+ [:ASSIGNMENT_WORD, head.assignment(@word.segments.drop(1))]
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,23 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Rush
5
+ class Lexer
6
+ # Typed token-shape predicates for the raw [kind, value] tuples returned to
7
+ # Racc. Keeping them here avoids repeating untyped tuple discriminator checks
8
+ # in the lexer hot path.
9
+ module TokenPredicates
10
+ extend T::Sig
11
+
12
+ private
13
+
14
+ sig { params(token: [T.untyped, T.untyped]).returns(T::Boolean) }
15
+ def word_token?(token)
16
+ case token.first
17
+ when :WORD then true
18
+ else false
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end