rubycc 1.0.0

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 (159) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +56 -0
  3. data/LICENSE.txt +21 -0
  4. data/NOTICE +52 -0
  5. data/README.md +208 -0
  6. data/data/README.md +117 -0
  7. data/data/r10_corpus_scan.json +4082 -0
  8. data/data/r10_manual_classification.json +5342 -0
  9. data/data/r10_verification_m4a.json +1531 -0
  10. data/data/r10_verification_m4b.json +1818 -0
  11. data/data/r10_verification_m4c.json +1489 -0
  12. data/data/r10_verification_m4d.json +318 -0
  13. data/data/r10_verification_rbs.json +192 -0
  14. data/data/verified_gems.json +397 -0
  15. data/exe/rmake +16 -0
  16. data/exe/rubycc +9 -0
  17. data/exe/rubycc-ar +114 -0
  18. data/exe/rubycc-doctor +14 -0
  19. data/exe/rubycc-pkgconf +8 -0
  20. data/include/float.h +87 -0
  21. data/include/iso646.h +18 -0
  22. data/include/libc/alloca.h +18 -0
  23. data/include/libc/arpa/inet.h +61 -0
  24. data/include/libc/assert.h +43 -0
  25. data/include/libc/dirent.h +72 -0
  26. data/include/libc/dlfcn.h +58 -0
  27. data/include/libc/features.h +205 -0
  28. data/include/libc/glibc/aarch64/ctype.h +108 -0
  29. data/include/libc/glibc/aarch64/endian.h +54 -0
  30. data/include/libc/glibc/aarch64/errno.h +154 -0
  31. data/include/libc/glibc/aarch64/fcntl.h +144 -0
  32. data/include/libc/glibc/aarch64/inttypes.h +182 -0
  33. data/include/libc/glibc/aarch64/limits.h +63 -0
  34. data/include/libc/glibc/aarch64/pthread.h +131 -0
  35. data/include/libc/glibc/aarch64/setjmp.h +84 -0
  36. data/include/libc/glibc/aarch64/stdint.h +174 -0
  37. data/include/libc/glibc/aarch64/sys/epoll.h +88 -0
  38. data/include/libc/glibc/aarch64/sys/fcntl.h +19 -0
  39. data/include/libc/glibc/aarch64/sys/select.h +75 -0
  40. data/include/libc/glibc/aarch64/sys/stat.h +136 -0
  41. data/include/libc/glibc/aarch64/sys/syscall.h +167 -0
  42. data/include/libc/glibc/aarch64/sys/time.h +74 -0
  43. data/include/libc/glibc/aarch64/sys/types.h +127 -0
  44. data/include/libc/glibc/aarch64/time.h +113 -0
  45. data/include/libc/glibc/x86_64/ctype.h +108 -0
  46. data/include/libc/glibc/x86_64/endian.h +54 -0
  47. data/include/libc/glibc/x86_64/errno.h +154 -0
  48. data/include/libc/glibc/x86_64/fcntl.h +134 -0
  49. data/include/libc/glibc/x86_64/inttypes.h +182 -0
  50. data/include/libc/glibc/x86_64/limits.h +61 -0
  51. data/include/libc/glibc/x86_64/pthread.h +111 -0
  52. data/include/libc/glibc/x86_64/setjmp.h +82 -0
  53. data/include/libc/glibc/x86_64/stdint.h +163 -0
  54. data/include/libc/glibc/x86_64/sys/epoll.h +91 -0
  55. data/include/libc/glibc/x86_64/sys/fcntl.h +19 -0
  56. data/include/libc/glibc/x86_64/sys/select.h +75 -0
  57. data/include/libc/glibc/x86_64/sys/stat.h +130 -0
  58. data/include/libc/glibc/x86_64/sys/syscall.h +188 -0
  59. data/include/libc/glibc/x86_64/sys/time.h +74 -0
  60. data/include/libc/glibc/x86_64/sys/types.h +123 -0
  61. data/include/libc/glibc/x86_64/time.h +113 -0
  62. data/include/libc/grp.h +50 -0
  63. data/include/libc/langinfo.h +126 -0
  64. data/include/libc/link.h +18 -0
  65. data/include/libc/locale.h +83 -0
  66. data/include/libc/math.h +191 -0
  67. data/include/libc/netinet/in.h +126 -0
  68. data/include/libc/netinet/tcp.h +44 -0
  69. data/include/libc/poll.h +46 -0
  70. data/include/libc/pwd.h +58 -0
  71. data/include/libc/regex.h +53 -0
  72. data/include/libc/sched.h +35 -0
  73. data/include/libc/signal.h +204 -0
  74. data/include/libc/stdio.h +157 -0
  75. data/include/libc/stdlib.h +92 -0
  76. data/include/libc/string.h +74 -0
  77. data/include/libc/strings.h +25 -0
  78. data/include/libc/sys/cdefs.h +126 -0
  79. data/include/libc/sys/inotify.h +111 -0
  80. data/include/libc/sys/ioctl.h +36 -0
  81. data/include/libc/sys/mman.h +65 -0
  82. data/include/libc/sys/param.h +41 -0
  83. data/include/libc/sys/resource.h +109 -0
  84. data/include/libc/sys/socket.h +167 -0
  85. data/include/libc/sys/statfs.h +82 -0
  86. data/include/libc/sys/timerfd.h +55 -0
  87. data/include/libc/sys/uio.h +40 -0
  88. data/include/libc/sys/un.h +25 -0
  89. data/include/libc/sys/utsname.h +35 -0
  90. data/include/libc/sys/wait.h +135 -0
  91. data/include/libc/termios.h +179 -0
  92. data/include/libc/unistd.h +194 -0
  93. data/include/stdalign.h +16 -0
  94. data/include/stdarg.h +31 -0
  95. data/include/stdatomic.h +158 -0
  96. data/include/stdbool.h +15 -0
  97. data/include/stdckdint.h +28 -0
  98. data/include/stddef.h +60 -0
  99. data/include/stdnoreturn.h +18 -0
  100. data/include/x86intrin.h +16 -0
  101. data/lib/rubycc/backend/aarch64.rb +1724 -0
  102. data/lib/rubycc/backend/x86_64.rb +1369 -0
  103. data/lib/rubycc/compile_error.rb +36 -0
  104. data/lib/rubycc/compiler.rb +305 -0
  105. data/lib/rubycc/doctor/builder.rb +151 -0
  106. data/lib/rubycc/doctor/cli.rb +190 -0
  107. data/lib/rubycc/doctor/fetcher.rb +84 -0
  108. data/lib/rubycc/doctor/gemfile.rb +115 -0
  109. data/lib/rubycc/doctor/verified_gems.rb +114 -0
  110. data/lib/rubycc/doctor.rb +16 -0
  111. data/lib/rubycc/driver.rb +463 -0
  112. data/lib/rubycc/front/ast.rb +528 -0
  113. data/lib/rubycc/front/constant_evaluator.rb +631 -0
  114. data/lib/rubycc/front/initializer_resolver.rb +592 -0
  115. data/lib/rubycc/front/lexeme_reader.rb +460 -0
  116. data/lib/rubycc/front/lexer.rb +232 -0
  117. data/lib/rubycc/front/parser.rb +4122 -0
  118. data/lib/rubycc/front/token.rb +49 -0
  119. data/lib/rubycc/ir/call_convention.rb +486 -0
  120. data/lib/rubycc/ir/generator.rb +6036 -0
  121. data/lib/rubycc/ir/ir.rb +417 -0
  122. data/lib/rubycc/link/compat_runtime.rb +89 -0
  123. data/lib/rubycc/link/errors.rb +13 -0
  124. data/lib/rubycc/link/executable_linker.rb +405 -0
  125. data/lib/rubycc/link/library_resolver.rb +437 -0
  126. data/lib/rubycc/link/partial_linker.rb +546 -0
  127. data/lib/rubycc/link/shared_linker.rb +1732 -0
  128. data/lib/rubycc/mkmf_shim.rb +83 -0
  129. data/lib/rubycc/objfile/ar_archive.rb +437 -0
  130. data/lib/rubycc/objfile/elf_reader.rb +646 -0
  131. data/lib/rubycc/objfile/elf_writer.rb +891 -0
  132. data/lib/rubycc/objfile/relocatable_writer.rb +376 -0
  133. data/lib/rubycc/pkgconf/cli.rb +139 -0
  134. data/lib/rubycc/pkgconf/errors.rb +39 -0
  135. data/lib/rubycc/pkgconf/model.rb +28 -0
  136. data/lib/rubycc/pkgconf/parser.rb +109 -0
  137. data/lib/rubycc/pkgconf/pkgconf.rb +15 -0
  138. data/lib/rubycc/pkgconf/resolver.rb +70 -0
  139. data/lib/rubycc/pkgconf/search_path.rb +38 -0
  140. data/lib/rubycc/pkgconf/system_path_filter.rb +131 -0
  141. data/lib/rubycc/preprocess/constant_expression.rb +219 -0
  142. data/lib/rubycc/preprocess/glibc_version.rb +124 -0
  143. data/lib/rubycc/preprocess/pp_token.rb +62 -0
  144. data/lib/rubycc/preprocess/preprocessor.rb +2020 -0
  145. data/lib/rubycc/preprocess/scanner.rb +290 -0
  146. data/lib/rubycc/preprocess/token_converter.rb +157 -0
  147. data/lib/rubycc/rmake/cli.rb +143 -0
  148. data/lib/rubycc/rmake/errors.rb +71 -0
  149. data/lib/rubycc/rmake/executor.rb +818 -0
  150. data/lib/rubycc/rmake/expander.rb +251 -0
  151. data/lib/rubycc/rmake/makefile.rb +352 -0
  152. data/lib/rubycc/rmake/model.rb +137 -0
  153. data/lib/rubycc/rmake/parser.rb +226 -0
  154. data/lib/rubycc/rmake/rmake.rb +15 -0
  155. data/lib/rubycc/type.rb +1236 -0
  156. data/lib/rubycc/version.rb +5 -0
  157. data/lib/rubycc.rb +36 -0
  158. data/lib/rubygems_plugin.rb +102 -0
  159. metadata +219 -0
@@ -0,0 +1,818 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "errors"
5
+
6
+ module Rubycc
7
+ module Rmake
8
+ # Runs an execution Plan (from Makefile#plan) without a shell. The minimal
9
+ # target environment has no /bin/sh (DESIGN R5), so each recipe line is
10
+ # interpreted here directly: split into words (honouring quotes), joined by
11
+ # the connectors make's recipes use (`&&`, `||`, `;`), with `>`/`>>`/`2>`
12
+ # redirections and `VAR=value` / `cd` prefixes applied to the command they
13
+ # front. The vocabulary is fixed by the mkmf corpus (test/fixtures/mkmf): only
14
+ # the constructs those Makefiles actually emit are accepted, and anything else
15
+ # raises UnsupportedRecipeError instead of being run through a shell that does
16
+ # not exist.
17
+ #
18
+ # The utilities the recipes invoke (rm, mkdir, install, echo, ...) are
19
+ # reimplemented on top of FileUtils — the "no external tool" implementation is
20
+ # authoritative, so `/usr/bin/install` and a bare `install` behave identically
21
+ # and no process is spawned for them. Everything the runner does not recognise
22
+ # as a builtin (the compiler and linker, `$(CC)`/`$(LDSHARED)`) is exec'd
23
+ # directly with an argv array; the runner never builds a shell command string.
24
+ #
25
+ # B3 adds two things on top of that. First, in-process tool substitution: when
26
+ # a set of program names is passed as +tools+, a command whose argv[0] is one
27
+ # of them (the first word of `$(CC)`/`$(LDSHARED)`) is not exec'd but run by
28
+ # rubycc's own Driver — inside a forked child, so a compiler crash cannot take
29
+ # rmake down and the Driver's per-invocation state stays isolated. Second, a
30
+ # `-j` scheduler that forks independent stale steps up to +jobs+ at a time,
31
+ # honouring the plan's dependency edges and buffering each worker's output to
32
+ # flush it whole when the step finishes (make -O's un-interleaved output).
33
+ class Executor
34
+ # The utilities reimplemented in-process, keyed by the command's basename so
35
+ # that `/usr/bin/mkdir` and `mkdir` resolve to the same builtin. `:` is
36
+ # make's $(NULLCMD); `exit` is how mkmf's `TOUCH = exit >` stamps a
37
+ # timestamp file (the `>` creates it, `exit` succeeds).
38
+ BUILTINS = %w[cd rm mkdir rmdir cp install echo touch true : exit].freeze
39
+
40
+ # Per-recipe-line mutable state: the working directory a `cd` in the same
41
+ # line has moved to, and the reason string of the most recent failure (used
42
+ # to enrich CommandFailedError). Each recipe line starts from the Makefile's
43
+ # base directory afresh, matching make running every line in its own shell.
44
+ class LineState
45
+ attr_accessor :cwd, :failure_reason
46
+
47
+ def initialize(cwd)
48
+ @cwd = cwd
49
+ @failure_reason = nil
50
+ end
51
+ end
52
+
53
+ # One redirection parsed off a command: which stream (:stdout/:stderr),
54
+ # whether it truncates or appends, and the target path (relative to the
55
+ # command's cwd).
56
+ Redirection = Struct.new(:stream, :mode, :path)
57
+
58
+ # One simple command: leading `VAR=value` assignments, the argv words and
59
+ # the redirections that apply to it.
60
+ SimpleCommand = Struct.new(:assignments, :argv, :redirections)
61
+
62
+ def initialize(dir:, out: $stdout, err: $stderr, dry_run: false, env: ENV,
63
+ tools: [], jobs: 1)
64
+ @dir = File.expand_path(dir)
65
+ @out = out
66
+ @err = err
67
+ @dry_run = dry_run
68
+ @env = env
69
+ @tools = Array(tools)
70
+ @jobs = [jobs.to_i, 1].max
71
+ # In sequential mode a substituted tool is fork-isolated for its own sake;
72
+ # a parallel worker is already a forked step child, so it runs the Driver
73
+ # in-process rather than forking a second time.
74
+ @isolate_tool = true
75
+ end
76
+
77
+ # Run every step of +plan+. With +jobs+ == 1 (or under -n) this is a
78
+ # straight sequential walk: prerequisites already precede their dependents
79
+ # in the plan, so the order is a valid build order. With +jobs+ > 1 the
80
+ # steps are dispatched by the parallel scheduler instead. Returns the plan;
81
+ # raises CommandFailedError / UnsupportedRecipeError at the first command
82
+ # that fails (and is not `-`-prefixed) or cannot be interpreted.
83
+ def execute(plan)
84
+ if @jobs > 1 && !@dry_run
85
+ execute_parallel(plan)
86
+ else
87
+ plan.steps.each do |step|
88
+ step.commands.each { |command| run_line(step.target, command) }
89
+ end
90
+ end
91
+ plan
92
+ end
93
+
94
+ private
95
+
96
+ # Execute one recipe line (a Command carrying its `@`/`-` attributes). make
97
+ # echoes the line before running it unless it is silent (`@`); under -n it
98
+ # echoes every line and runs nothing.
99
+ def run_line(target, command)
100
+ echo_command(command)
101
+ return if @dry_run
102
+
103
+ state = LineState.new(@dir)
104
+ ok = run_and_or_list(target, command.text, state)
105
+ return if ok || command.ignore_error?
106
+
107
+ raise CommandFailedError.new(target: target, command: command.text, reason: state.failure_reason)
108
+ end
109
+
110
+ def echo_command(command)
111
+ @out.puts(command.text) if @dry_run || !command.silent?
112
+ end
113
+
114
+ # Interpret a recipe line as an and-or list: simple commands joined by
115
+ # `&&` (run next only after success), `||` (run next only after failure)
116
+ # and `;` (always run next). A single left-to-right status carries the
117
+ # result, exactly as an sh and-or list evaluates. Returns the final success.
118
+ def run_and_or_list(target, text, state)
119
+ commands = parse_line(target, text)
120
+ success = true
121
+ commands.each do |connector, cmd|
122
+ run = case connector
123
+ when :first, :semi then true
124
+ when :and then success
125
+ when :or then !success
126
+ end
127
+ success = run_simple(target, cmd, state, text) if run
128
+ end
129
+ success
130
+ end
131
+
132
+ # --- lexing ----------------------------------------------------------
133
+
134
+ # Characters after which a backslash inside double quotes keeps its
135
+ # special meaning (POSIX quote removal, XCU 2.2 / 2.2.3): only these five
136
+ # make the backslash consume — and remove itself along with — the next
137
+ # character; before a newline both vanish (line continuation). Before any
138
+ # other character the backslash inside double quotes is left in the word
139
+ # verbatim: `"a\b"` stays `a\b`, while `"a\"b"` becomes `a"b`. Verified
140
+ # against /bin/sh (dash).
141
+ DQUOTE_BACKSLASH_SPECIAL = ["$", "`", '"', "\\", "\n"].freeze
142
+
143
+ # Split a recipe line into tokens: :word (quote-stripped), the connectors
144
+ # :and/:or/:semi and :redirect markers. Single quotes protect everything
145
+ # verbatim — backslash has no special meaning inside them. Double quotes
146
+ # strip a backslash only before `$`/`` ` ``/`"`/`\`/newline
147
+ # (DQUOTE_BACKSLASH_SPECIAL); elsewhere the backslash stays in the word.
148
+ # Outside any quote, a backslash preserves the literal value of the
149
+ # following character and disappears itself, except before a newline
150
+ # where both vanish (line continuation) — this is POSIX quote removal,
151
+ # verified against /bin/sh. mkmf relies on the outside-quotes rule: it
152
+ # writes `-DSYSCONFDIR=\"...\"` expecting the shell to unescape the
153
+ # backslash-quotes into a literal `"` in the word. Genuinely unhandled
154
+ # shell syntax (pipe, background, substitution, subshell) stops the run.
155
+ def tokenize(target, text)
156
+ tokens = []
157
+ word = nil
158
+ i = 0
159
+ n = text.length
160
+ while i < n
161
+ c = text[i]
162
+ case c
163
+ when "'"
164
+ close = text.index(c, i + 1)
165
+ unsupported!("unterminated quote", target, text) if close.nil?
166
+ word = (word || +"") + text[(i + 1)...close]
167
+ i = close + 1
168
+ when '"'
169
+ segment, i = scan_double_quoted(target, text, i)
170
+ word = (word || +"") + segment
171
+ when "\\"
172
+ nxt = text[i + 1]
173
+ if nxt.nil?
174
+ # A lone trailing backslash with nothing to escape is kept
175
+ # literally (verified against /bin/sh).
176
+ word = (word || +"") + c
177
+ i += 1
178
+ elsif nxt == "\n"
179
+ i += 2 # line continuation: backslash and newline both vanish
180
+ else
181
+ word = (word || +"") + nxt
182
+ i += 2
183
+ end
184
+ when " ", "\t"
185
+ tokens << [:word, word] if word
186
+ word = nil
187
+ i += 1
188
+ when "&"
189
+ tokens << [:word, word] if word
190
+ word = nil
191
+ unsupported!("background '&'", target, text) unless text[i + 1] == "&"
192
+ tokens << [:and]
193
+ i += 2
194
+ when "|"
195
+ tokens << [:word, word] if word
196
+ word = nil
197
+ unsupported!("pipe '|'", target, text) unless text[i + 1] == "|"
198
+ tokens << [:or]
199
+ i += 2
200
+ when ";"
201
+ tokens << [:word, word] if word
202
+ word = nil
203
+ tokens << [:semi]
204
+ i += 1
205
+ when ">"
206
+ tokens << [:word, word] if word
207
+ word = nil
208
+ if text[i + 1] == ">"
209
+ tokens << [:redirect, :stdout, :append]
210
+ i += 2
211
+ else
212
+ tokens << [:redirect, :stdout, :truncate]
213
+ i += 1
214
+ end
215
+ when "<", "`", "(", ")"
216
+ unsupported!("shell metacharacter '#{c}'", target, text)
217
+ else
218
+ if word.nil? && (c == "1" || c == "2") && text[i + 1] == ">"
219
+ stream = c == "2" ? :stderr : :stdout
220
+ if text[i + 2] == ">"
221
+ tokens << [:redirect, stream, :append]
222
+ i += 3
223
+ else
224
+ tokens << [:redirect, stream, :truncate]
225
+ i += 2
226
+ end
227
+ else
228
+ word = (word || +"") + c
229
+ i += 1
230
+ end
231
+ end
232
+ end
233
+ tokens << [:word, word] if word
234
+ tokens
235
+ end
236
+
237
+ # Scan a double-quoted segment starting at +i+ (text[i] == '"'). Applies
238
+ # the backslash-removal rule that is special to double quotes (see
239
+ # DQUOTE_BACKSLASH_SPECIAL) and returns [content, index_after_closing_quote].
240
+ def scan_double_quoted(target, text, i)
241
+ n = text.length
242
+ j = i + 1
243
+ buf = +""
244
+ loop do
245
+ unsupported!("unterminated quote", target, text) if j >= n
246
+
247
+ c = text[j]
248
+ if c == '"'
249
+ j += 1
250
+ break
251
+ elsif c == "\\" && j + 1 < n && DQUOTE_BACKSLASH_SPECIAL.include?(text[j + 1])
252
+ nxt = text[j + 1]
253
+ buf << nxt unless nxt == "\n" # backslash-newline vanishes entirely
254
+ j += 2
255
+ else
256
+ buf << c
257
+ j += 1
258
+ end
259
+ end
260
+ [buf, j]
261
+ end
262
+
263
+ # --- parsing ---------------------------------------------------------
264
+
265
+ # Turn the token stream into [[connector, SimpleCommand], ...]. A leading
266
+ # run of `VAR=value` words become that command's environment; a redirect
267
+ # marker consumes the following word as its target path.
268
+ def parse_line(target, text)
269
+ tokens = tokenize(target, text)
270
+ commands = []
271
+ connector = :first
272
+ assignments = []
273
+ argv = []
274
+ redirections = []
275
+ i = 0
276
+
277
+ flush = lambda do
278
+ unless assignments.empty? && argv.empty? && redirections.empty?
279
+ commands << [connector, SimpleCommand.new(assignments, argv, redirections)]
280
+ end
281
+ assignments = []
282
+ argv = []
283
+ redirections = []
284
+ end
285
+
286
+ while i < tokens.length
287
+ tok = tokens[i]
288
+ case tok[0]
289
+ when :word
290
+ w = tok[1]
291
+ if argv.empty? && w =~ /\A[A-Za-z_][A-Za-z0-9_]*=/
292
+ assignments << w
293
+ else
294
+ argv << w
295
+ end
296
+ when :and, :or, :semi
297
+ flush.call
298
+ connector = tok[0]
299
+ when :redirect
300
+ nxt = tokens[i + 1]
301
+ unsupported!("redirection without a target", target, text) if nxt.nil? || nxt[0] != :word
302
+ redirections << Redirection.new(tok[1], tok[2], nxt[1])
303
+ i += 1
304
+ end
305
+ i += 1
306
+ end
307
+ flush.call
308
+ commands
309
+ end
310
+
311
+ # --- running a simple command ---------------------------------------
312
+
313
+ def run_simple(target, cmd, state, text)
314
+ argv = expand_globs(cmd.argv, state.cwd)
315
+ # A bare `VAR=value` with no command (or an empty command) is a no-op that
316
+ # succeeds, as it would in sh.
317
+ return true if argv.empty?
318
+
319
+ name = File.basename(argv[0])
320
+ if BUILTINS.include?(name)
321
+ run_builtin(target, name, argv, cmd, state, text)
322
+ elsif tool?(argv[0])
323
+ run_tool(argv, cmd, state)
324
+ else
325
+ run_external(target, argv, cmd, state)
326
+ end
327
+ end
328
+
329
+ # Whether argv[0] names one of the substituted tool programs (matched by the
330
+ # word as written and by its basename, so both `gcc` and `/usr/bin/gcc`
331
+ # resolve). Empty when substitution is off, which keeps the default path
332
+ # (exec every unknown command) untouched.
333
+ def tool?(arg0)
334
+ return false if @tools.empty?
335
+
336
+ @tools.include?(arg0) || @tools.include?(File.basename(arg0))
337
+ end
338
+
339
+ # Run a substituted compiler/linker command through rubycc's Driver, which
340
+ # takes the gcc-style argv minus its program word. The compile line and the
341
+ # `-shared` link line map through the same Driver entry point (it selects
342
+ # its mode from the flags), so the two need no special-casing here. Sequential
343
+ # runs fork for crash isolation; a parallel worker is already isolated and
344
+ # runs the Driver in-process.
345
+ def run_tool(argv, cmd, state)
346
+ driver_argv = argv.drop(1)
347
+ ok, reason = @isolate_tool ? fork_driver(driver_argv, state.cwd, cmd) \
348
+ : inline_driver(driver_argv, state.cwd, cmd)
349
+ state.failure_reason = reason unless ok
350
+ ok
351
+ end
352
+
353
+ # rubycc's Driver, loaded on first use so rmake stays loadable on its own
354
+ # (the whole compiler/linker stack it drags in is not needed for a plain
355
+ # Makefile parse/plan). The umbrella `rubycc` is required rather than just
356
+ # `rubycc/driver` so every constant the link path reaches — the ELF reader's
357
+ # error classes the archive writer rescues among them — is defined; loading
358
+ # only the driver leaves some of those unresolved.
359
+ def driver_class
360
+ require "rubycc" unless defined?(Rubycc::Driver)
361
+ Rubycc::Driver
362
+ end
363
+
364
+ # Expand `*`/`?`/`[...]` globs against the command's cwd. A pattern that
365
+ # matches nothing is left verbatim (sh's default, and what lets `rm -f
366
+ # *.bak` be harmless when no backup files exist). Names come back relative
367
+ # to cwd, which is how the builtins resolve them.
368
+ def expand_globs(words, cwd)
369
+ words.flat_map do |w|
370
+ next [w] unless w =~ /[*?\[]/
371
+
372
+ matches = Dir.glob(w, base: cwd).sort
373
+ matches.empty? ? [w] : matches
374
+ end
375
+ end
376
+
377
+ # Open the command's redirections and yield the resulting stdout/stderr IO
378
+ # (nil when a stream is not redirected). Opening a `>` target creates and
379
+ # truncates it, which is also the whole effect of mkmf's `exit > stamp`.
380
+ def with_redirections(redirections, cwd)
381
+ opened = []
382
+ streams = { stdout: nil, stderr: nil }
383
+ redirections.each do |r|
384
+ path = r.path == "/dev/null" ? File::NULL : absolute(r.path, cwd)
385
+ io = File.open(path, r.mode == :append ? "a" : "w")
386
+ opened << io
387
+ streams[r.stream] = io
388
+ end
389
+ yield(streams[:stdout], streams[:stderr])
390
+ ensure
391
+ opened.each(&:close)
392
+ end
393
+
394
+ # Run an unknown command (the compiler/linker) as a real process with an
395
+ # argv array — never a shell string — honouring the command's env, cwd and
396
+ # redirections. A missing executable is a normal build failure.
397
+ def run_external(target, argv, cmd, state)
398
+ with_redirections(cmd.redirections, state.cwd) do |out_io, err_io|
399
+ options = { chdir: state.cwd }
400
+ options[:out] = out_io if out_io
401
+ options[:err] = err_io if err_io
402
+ begin
403
+ pid = Process.spawn(env_overrides(cmd.assignments), *argv, options)
404
+ _, status = Process.waitpid2(pid)
405
+ state.failure_reason = "exited with status #{status.exitstatus}" unless status.success?
406
+ status.success?
407
+ rescue Errno::ENOENT, Errno::EACCES => e
408
+ state.failure_reason = "cannot execute #{argv[0]}: #{e.message}"
409
+ false
410
+ end
411
+ end
412
+ end
413
+
414
+ def env_overrides(assignments)
415
+ assignments.each_with_object({}) do |a, h|
416
+ name, value = a.split("=", 2)
417
+ h[name] = value
418
+ end
419
+ end
420
+
421
+ # --- in-process tool invocation (Driver) -----------------------------
422
+
423
+ # Run the Driver in a forked child (sequential mode). The child chdirs,
424
+ # applies the command's `VAR=value` prefixes, points the Driver's streams at
425
+ # the command's redirections or a capture pipe, and exits with the Driver's
426
+ # status; the parent drains the pipe, waits, and forwards whatever the child
427
+ # printed to its own output. `exit!` is used so the child never runs Ruby's
428
+ # at_exit hooks (Minitest's reporter among them). Returns [ok?, reason].
429
+ def fork_driver(driver_argv, cwd, cmd)
430
+ reader, writer = IO.pipe
431
+ pid = fork do
432
+ reader.close
433
+ status = 1
434
+ begin
435
+ Dir.chdir(cwd)
436
+ env_overrides(cmd.assignments).each { |k, v| ENV[k] = v }
437
+ out_io, err_io = redirection_ios(cmd.redirections, cwd)
438
+ status = driver_class.run(driver_argv, stdout: out_io || writer, stderr: err_io || writer)
439
+ [out_io, err_io].compact.each(&:close)
440
+ rescue Exception => e # rubocop:disable Lint/RescueException
441
+ safe_puts(writer, "rmake: rubycc: #{e.class}: #{e.message}")
442
+ status = 1
443
+ end
444
+ writer.flush
445
+ exit!(status)
446
+ end
447
+ writer.close
448
+ output = reader.read
449
+ reader.close
450
+ _, status = Process.waitpid2(pid)
451
+ @out.write(output) unless output.empty?
452
+ status.success? ? [true, nil] : [false, tool_reason(status)]
453
+ end
454
+
455
+ # Run the Driver in the current process (parallel mode: the caller is
456
+ # already an isolated step worker). chdir is block-scoped so a step's later
457
+ # commands are unaffected. Returns [ok?, reason].
458
+ def inline_driver(driver_argv, cwd, cmd)
459
+ out_io, err_io = redirection_ios(cmd.redirections, cwd)
460
+ status = 1
461
+ Dir.chdir(cwd) do
462
+ with_env(env_overrides(cmd.assignments)) do
463
+ status = driver_class.run(driver_argv, stdout: out_io || @out, stderr: err_io || @err)
464
+ end
465
+ end
466
+ [out_io, err_io].compact.each(&:close)
467
+ status.zero? ? [true, nil] : [false, "rubycc exited with status #{status}"]
468
+ rescue Exception => e # rubocop:disable Lint/RescueException
469
+ [false, "#{e.class}: #{e.message}"]
470
+ end
471
+
472
+ def tool_reason(status)
473
+ if status.signaled?
474
+ "rubycc terminated by signal #{status.termsig}"
475
+ else
476
+ "rubycc exited with status #{status.exitstatus}"
477
+ end
478
+ end
479
+
480
+ # Temporarily overlay ENV with +overrides+ for the block, restoring it after
481
+ # (used so a tool line's `VAR=value` prefix does not leak into later work).
482
+ def with_env(overrides)
483
+ return yield if overrides.empty?
484
+
485
+ saved = overrides.keys.to_h { |k| [k, ENV[k]] }
486
+ overrides.each { |k, v| ENV[k] = v }
487
+ begin
488
+ yield
489
+ ensure
490
+ saved.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
491
+ end
492
+ end
493
+
494
+ # Open a command's redirections and return [stdout_io, stderr_io] (nil for a
495
+ # stream that is not redirected). The non-block sibling of #with_redirections,
496
+ # used where the Driver needs the IO objects handed to it directly.
497
+ def redirection_ios(redirections, cwd)
498
+ out_io = err_io = nil
499
+ redirections.each do |r|
500
+ path = r.path == "/dev/null" ? File::NULL : absolute(r.path, cwd)
501
+ io = File.open(path, r.mode == :append ? "a" : "w")
502
+ r.stream == :stderr ? (err_io = io) : (out_io = io)
503
+ end
504
+ [out_io, err_io]
505
+ end
506
+
507
+ def safe_puts(io, message)
508
+ io.puts(message)
509
+ rescue StandardError
510
+ nil
511
+ end
512
+
513
+ # --- parallel scheduling (-j) ----------------------------------------
514
+
515
+ # Build +plan+ with up to @jobs step workers running at once. Each ready
516
+ # step (all its prerequisite steps finished) is forked; the worker runs the
517
+ # whole step in-process with its output captured, so a step's lines stay
518
+ # together and a compiler crash is contained. On a worker failure no new
519
+ # step is launched, the ones already running are drained, and then the
520
+ # failure is raised — make's default "-k off" behaviour.
521
+ def execute_parallel(plan)
522
+ remaining = plan.steps.dup
523
+ done = {}
524
+ running = {}
525
+ failure = nil
526
+
527
+ loop do
528
+ while failure.nil? && running.size < @jobs && (step = next_ready(remaining, done, running))
529
+ remaining.delete(step)
530
+ launch_step(step, running)
531
+ end
532
+ break if running.empty?
533
+
534
+ pid, status = Process.wait2
535
+ finished = running.delete(pid)
536
+ next unless finished
537
+
538
+ @out.write(finished[:thread].value)
539
+ if status.success?
540
+ done[finished[:step].target] = true
541
+ else
542
+ failure ||= [finished[:step], status]
543
+ end
544
+ end
545
+
546
+ return unless failure
547
+
548
+ step, status = failure
549
+ raise CommandFailedError.new(target: step.target,
550
+ command: "recipe for #{step.target}",
551
+ reason: tool_reason(status))
552
+ end
553
+
554
+ # The first remaining step all of whose prerequisite steps have finished and
555
+ # none of which is still running. nil when nothing is currently runnable
556
+ # (every remaining step waits on an in-flight one).
557
+ def next_ready(remaining, done, running)
558
+ pending = running.values.map { |r| r[:step].target }
559
+ remaining.find do |step|
560
+ step.prereqs.all? { |p| done[p] } && pending.none? { |t| step.prereqs.include?(t) }
561
+ end
562
+ end
563
+
564
+ # Fork a worker for +step+. The child funnels every stream (builtin echo,
565
+ # the Driver, any spawned helper) into a capture pipe by reopening its
566
+ # stdout/stderr, then runs the step's recipe lines with tool substitution
567
+ # inline (it is already isolated). The parent records the child's pid and a
568
+ # thread that drains the pipe so a large transcript cannot dead-lock the
569
+ # write.
570
+ def launch_step(step, running)
571
+ reader, writer = IO.pipe
572
+ pid = fork do
573
+ reader.close
574
+ run_step_worker(step, writer)
575
+ end
576
+ writer.close
577
+ running[pid] = { step: step, reader: reader, thread: Thread.new { reader.read } }
578
+ end
579
+
580
+ def run_step_worker(step, writer)
581
+ $stdout.reopen(writer)
582
+ $stderr.reopen(writer)
583
+ @out = $stdout
584
+ @err = $stderr
585
+ @isolate_tool = false
586
+ step.commands.each { |command| run_line(step.target, command) }
587
+ writer.flush
588
+ exit!(0)
589
+ rescue RmakeError => e
590
+ safe_puts(writer, e.message)
591
+ exit!(1)
592
+ rescue Exception => e # rubocop:disable Lint/RescueException
593
+ safe_puts(writer, "rmake: #{e.class}: #{e.message}")
594
+ exit!(1)
595
+ end
596
+
597
+ # --- builtins --------------------------------------------------------
598
+
599
+ def run_builtin(target, name, argv, cmd, state, text)
600
+ with_redirections(cmd.redirections, state.cwd) do |out_io, err_io|
601
+ out = out_io || @out
602
+ err = err_io || @err
603
+ case name
604
+ when "cd" then builtin_cd(argv, state)
605
+ when "rm" then builtin_rm(argv, state)
606
+ when "mkdir" then builtin_mkdir(target, argv, state, text)
607
+ when "rmdir" then builtin_rmdir(argv, state)
608
+ when "cp" then builtin_cp(target, argv, state, text)
609
+ when "install" then builtin_install(target, argv, state, text)
610
+ when "echo" then builtin_echo(argv, out)
611
+ when "touch" then builtin_touch(argv, state)
612
+ when "true", ":", "exit" then true
613
+ else
614
+ # BUILTINS listed it but no branch handles it — a programming error.
615
+ unsupported!("builtin '#{name}'", target, text)
616
+ end
617
+ rescue RmakeError
618
+ # Unsupported-syntax and other rmake-level errors must propagate; only
619
+ # a utility's incidental I/O failure is turned into a soft failure.
620
+ raise
621
+ rescue StandardError => e
622
+ # Turn a utility's own I/O error into a recorded failure so the line
623
+ # reports it (and `-`/`||` can absorb it), rather than crashing rmake.
624
+ state.failure_reason = e.message
625
+ err.puts("rmake: #{name}: #{e.message}") if err
626
+ false
627
+ end
628
+ end
629
+
630
+ # `cd DIR`: move the rest of this recipe line into DIR. A missing directory
631
+ # fails, so `cd x && cmd` skips cmd — the sh behaviour.
632
+ def builtin_cd(argv, state)
633
+ dir = argv[1]
634
+ return false if dir.nil?
635
+
636
+ target = absolute(dir, state.cwd)
637
+ return false unless File.directory?(target)
638
+
639
+ state.cwd = target
640
+ true
641
+ end
642
+
643
+ # `rm [-f] [-r] FILE...`. `-f` swallows missing files; `-r`/`-R` recurses.
644
+ def builtin_rm(argv, state)
645
+ force = recursive = false
646
+ files = []
647
+ argv.drop(1).each do |a|
648
+ if a.start_with?("-") && a != "-"
649
+ force ||= a.include?("f")
650
+ recursive ||= a.include?("r") || a.include?("R")
651
+ else
652
+ files << a
653
+ end
654
+ end
655
+
656
+ ok = true
657
+ files.each do |f|
658
+ path = absolute(f, state.cwd)
659
+ if File.exist?(path) || File.symlink?(path)
660
+ recursive ? FileUtils.rm_rf(path) : FileUtils.rm(path)
661
+ elsif !force
662
+ state.failure_reason = "no such file: #{f}"
663
+ ok = false
664
+ end
665
+ end
666
+ ok
667
+ end
668
+
669
+ # `mkdir [-p] DIR...`. Without -p a pre-existing directory is an error, as
670
+ # is a missing parent; the corpus always passes -p.
671
+ def builtin_mkdir(target, argv, state, text)
672
+ create_parents = false
673
+ dirs = []
674
+ argv.drop(1).each do |a|
675
+ if a == "-p" || a == "--parents"
676
+ create_parents = true
677
+ elsif a.start_with?("-")
678
+ unsupported!("mkdir option #{a}", target, text)
679
+ else
680
+ dirs << a
681
+ end
682
+ end
683
+
684
+ dirs.each do |d|
685
+ path = absolute(d, state.cwd)
686
+ create_parents ? FileUtils.mkdir_p(path) : FileUtils.mkdir(path)
687
+ end
688
+ true
689
+ end
690
+
691
+ # `rmdir [--ignore-fail-on-non-empty] [-p] DIR...`. Removes empty
692
+ # directories; -p also removes now-empty ancestors. A non-empty directory
693
+ # fails unless the ignore flag is set. No operands is a no-op success.
694
+ def builtin_rmdir(argv, state)
695
+ ignore_nonempty = remove_parents = false
696
+ dirs = []
697
+ argv.drop(1).each do |a|
698
+ case a
699
+ when "--ignore-fail-on-non-empty" then ignore_nonempty = true
700
+ when "-p", "--parents" then remove_parents = true
701
+ else dirs << a unless a.start_with?("-")
702
+ end
703
+ end
704
+
705
+ ok = true
706
+ dirs.each do |d|
707
+ ok = false unless remove_one_dir(absolute(d, state.cwd), remove_parents, ignore_nonempty, state)
708
+ end
709
+ ok
710
+ end
711
+
712
+ def remove_one_dir(path, remove_parents, ignore_nonempty, state)
713
+ loop do
714
+ return true unless File.directory?(path)
715
+
716
+ unless (Dir.children(path).empty?)
717
+ return true if ignore_nonempty
718
+
719
+ state.failure_reason = "directory not empty: #{path}"
720
+ return false
721
+ end
722
+ Dir.rmdir(path)
723
+ break unless remove_parents
724
+
725
+ path = File.dirname(path)
726
+ end
727
+ true
728
+ end
729
+
730
+ # `cp [-p] SRC... DEST`. When DEST is a directory each SRC is copied into
731
+ # it; otherwise a single SRC is copied to DEST.
732
+ def builtin_cp(target, argv, state, text)
733
+ _flags, operands = split_flags(argv.drop(1))
734
+ unsupported!("cp needs a source and destination", target, text) if operands.length < 2
735
+
736
+ dest = absolute(operands.pop, state.cwd)
737
+ operands.each do |src|
738
+ FileUtils.cp(absolute(src, state.cwd), dest)
739
+ end
740
+ true
741
+ end
742
+
743
+ # `install [-c] [-m MODE] SRC... DEST` — mkmf's $(INSTALL_PROG)/
744
+ # $(INSTALL_DATA). `-c` (copy) is the default here; `-m` sets the octal
745
+ # mode. DEST is treated as a directory when it exists as one.
746
+ def builtin_install(target, argv, state, text)
747
+ mode = nil
748
+ operands = []
749
+ rest = argv.drop(1)
750
+ i = 0
751
+ while i < rest.length
752
+ a = rest[i]
753
+ if a == "-m"
754
+ mode = rest[i + 1]
755
+ i += 2
756
+ elsif a == "-c"
757
+ i += 1
758
+ elsif a.start_with?("-") && a != "-"
759
+ unsupported!("install option #{a}", target, text)
760
+ else
761
+ operands << a
762
+ i += 1
763
+ end
764
+ end
765
+ unsupported!("install needs a source and destination", target, text) if operands.length < 2
766
+
767
+ dest = absolute(operands.pop, state.cwd)
768
+ operands.each do |src|
769
+ spath = absolute(src, state.cwd)
770
+ dpath = File.directory?(dest) ? File.join(dest, File.basename(src)) : dest
771
+ FileUtils.cp(spath, dpath)
772
+ File.chmod(mode.to_i(8), dpath) if mode
773
+ end
774
+ true
775
+ end
776
+
777
+ # `echo [-n] WORDS...` — join the arguments with single spaces. Writes to
778
+ # the redirected stream when one is present, else the runner's output.
779
+ def builtin_echo(argv, out)
780
+ args = argv.drop(1)
781
+ newline = true
782
+ newline = false if args.first == "-n" && (args = args.drop(1))
783
+ out.write(args.join(" "))
784
+ out.write("\n") if newline
785
+ true
786
+ end
787
+
788
+ # `touch FILE...` — create each file or update its timestamp.
789
+ def builtin_touch(argv, state)
790
+ argv.drop(1).reject { |a| a.start_with?("-") }.each do |f|
791
+ FileUtils.touch(absolute(f, state.cwd))
792
+ end
793
+ true
794
+ end
795
+
796
+ # --- helpers ---------------------------------------------------------
797
+
798
+ # Partition argv-tail words into option flags and positional operands
799
+ # (used by the utilities that take no option arguments of their own).
800
+ def split_flags(words)
801
+ flags = []
802
+ operands = []
803
+ words.each do |w|
804
+ (w.start_with?("-") && w != "-" ? flags : operands) << w
805
+ end
806
+ [flags, operands]
807
+ end
808
+
809
+ def absolute(path, cwd)
810
+ path.start_with?("/") ? path : File.expand_path(path, cwd)
811
+ end
812
+
813
+ def unsupported!(construct, target, command)
814
+ raise UnsupportedRecipeError.new(construct, target: target, command: command)
815
+ end
816
+ end
817
+ end
818
+ end