rubycc 1.0.0 → 1.1.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/README.md +26 -14
  4. data/data/verified_gems.json +85 -63
  5. data/exe/rubycc-ar +11 -3
  6. data/include/libc/sys/cdefs.h +12 -0
  7. data/lib/rubycc/backend/aarch64.rb +705 -117
  8. data/lib/rubycc/backend/slot_residency.rb +169 -0
  9. data/lib/rubycc/backend/x86_64.rb +924 -137
  10. data/lib/rubycc/command_line.rb +339 -0
  11. data/lib/rubycc/compile_error.rb +6 -3
  12. data/lib/rubycc/compiler.rb +17 -2
  13. data/lib/rubycc/diagnostics.rb +105 -0
  14. data/lib/rubycc/doctor/gemfile.rb +12 -3
  15. data/lib/rubycc/doctor/verified_gems.rb +5 -1
  16. data/lib/rubycc/driver.rb +66 -10
  17. data/lib/rubycc/front/ast.rb +18 -7
  18. data/lib/rubycc/front/constant_evaluator.rb +12 -0
  19. data/lib/rubycc/front/lexeme_reader.rb +3 -1
  20. data/lib/rubycc/front/parser.rb +51 -18
  21. data/lib/rubycc/ir/analysis.rb +82 -0
  22. data/lib/rubycc/ir/call_convention.rb +74 -7
  23. data/lib/rubycc/ir/generator.rb +319 -9
  24. data/lib/rubycc/ir/ir.rb +39 -1
  25. data/lib/rubycc/ir/promotion.rb +255 -0
  26. data/lib/rubycc/ir/simplify.rb +570 -0
  27. data/lib/rubycc/link/library_resolver.rb +17 -5
  28. data/lib/rubycc/link/partial_linker.rb +8 -1
  29. data/lib/rubycc/link/shared_linker.rb +2 -2
  30. data/lib/rubycc/mkmf_shim.rb +178 -12
  31. data/lib/rubycc/objfile/ar_archive.rb +13 -2
  32. data/lib/rubycc/objfile/elf_reader.rb +13 -2
  33. data/lib/rubycc/pkgconf/parser.rb +4 -0
  34. data/lib/rubycc/pkgconf/resolver.rb +3 -1
  35. data/lib/rubycc/pkgconf/system_path_filter.rb +8 -2
  36. data/lib/rubycc/preprocess/preprocessor.rb +161 -37
  37. data/lib/rubycc/preprocess/scanner.rb +69 -14
  38. data/lib/rubycc/preprocess/token_converter.rb +11 -1
  39. data/lib/rubycc/rmake/cli.rb +32 -4
  40. data/lib/rubycc/rmake/executor.rb +157 -226
  41. data/lib/rubycc/rmake/makefile.rb +35 -13
  42. data/lib/rubycc/rmake/parser.rb +8 -2
  43. data/lib/rubycc/rmake/rmake.rb +1 -0
  44. data/lib/rubycc/rmake/tool_command.rb +69 -0
  45. data/lib/rubycc/shell.rb +510 -0
  46. data/lib/rubycc/type.rb +23 -7
  47. data/lib/rubycc/version.rb +1 -1
  48. data/lib/rubycc.rb +12 -0
  49. data/lib/rubygems_plugin.rb +31 -3
  50. metadata +14 -3
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycc
4
+ # The base error is normally provided by lib/rubycc.rb. This file is loadable
5
+ # on its own (the mkmf shim pulls it in without the compiler behind it), so
6
+ # define a stand-in only when the full library has not been required yet.
7
+ Error = Class.new(StandardError) unless defined?(Error)
8
+
9
+ # Splitting a command line the way a POSIX shell would — and *only* the part a
10
+ # shell does before it execs: word splitting and quote removal. The point is to
11
+ # run a command that was written as one string without /bin/sh, which the
12
+ # minimal target environment does not have (DESIGN R5).
13
+ #
14
+ # Two callers share this: rmake, which interprets Makefile recipe lines
15
+ # (connectors, redirections, `VAR=value` prefixes and all), and the mkmf shim,
16
+ # which turns the single conftest command string mkmf builds into an argv array
17
+ # before mkmf spawns it. Both need exactly the same word-splitting rules, so
18
+ # the splitter lives here rather than in either of them.
19
+ #
20
+ # Nothing here expands anything: no variables, no globs, no command
21
+ # substitution, and no grammar above one command's words — `for` and `if`
22
+ # belong to the layer above (Rubycc::Shell), which calls this one on the
23
+ # simple commands it has already cut out of a line. A construct that would
24
+ # need a shell to interpret raises UnsupportedSyntaxError — the caller reports
25
+ # it. Falling back to a shell is not an option we keep in reserve: a fallback
26
+ # would make the result depend on whether a shell happens to exist, which is
27
+ # the very thing this code exists to remove.
28
+ module CommandLine
29
+ # A construct the splitter does not interpret (a pipe, background `&`,
30
+ # command substitution, an unterminated quote, ...). It names the construct
31
+ # and the line it was found in; callers wrap it in their own error type with
32
+ # whatever context they have (rmake adds the target, the mkmf shim writes the
33
+ # reason to mkmf.log).
34
+ class UnsupportedSyntaxError < Rubycc::Error
35
+ attr_reader :construct, :command
36
+
37
+ def initialize(construct, command)
38
+ @construct = construct
39
+ @command = command
40
+ super("unsupported shell construct (#{construct}): #{command.inspect}")
41
+ end
42
+ end
43
+
44
+ # One redirection parsed off a command: which stream (:stdout/:stderr),
45
+ # whether it truncates or appends, and the target path (relative to the
46
+ # command's cwd).
47
+ Redirection = Struct.new(:stream, :mode, :path)
48
+
49
+ # One simple command: leading `VAR=value` assignments, the argv words and
50
+ # the redirections that apply to it.
51
+ SimpleCommand = Struct.new(:assignments, :argv, :redirections)
52
+
53
+ # Characters after which a backslash inside double quotes keeps its
54
+ # special meaning (POSIX quote removal, XCU 2.2 / 2.2.3): only these five
55
+ # make the backslash consume — and remove itself along with — the next
56
+ # character; before a newline both vanish (line continuation). Before any
57
+ # other character the backslash inside double quotes is left in the word
58
+ # verbatim: `"a\b"` stays `a\b`, while `"a\"b"` becomes `a"b`. Verified
59
+ # against /bin/sh (dash).
60
+ DQUOTE_BACKSLASH_SPECIAL = ["$", "`", '"', "\\", "\n"].freeze
61
+
62
+ # The word that names each connector in a diagnostic.
63
+ CONNECTOR_NAMES = { and: "&&", or: "||", semi: ";" }.freeze
64
+
65
+ # Shell reserved words that introduce a compound command Rubycc::Shell has a
66
+ # grammar for (the `for` loop, `if`, the brace group). They are still not
67
+ # something *this* module can make sense of — it splits one command's words,
68
+ # and these words are the seams between commands — so the single-command
69
+ # entry point (#argv) keeps refusing them. #parse does not: it is what Shell
70
+ # calls once it has cut a simple command out of the line, by which point the
71
+ # keywords are gone.
72
+ COMPOUND_WORDS = %w[for do done if then elif else fi { }].freeze
73
+
74
+ # Reserved words nothing here interprets: no layer of rubycc has a grammar
75
+ # for `case`, `while`, `until` or `!`, so they are refused wherever they
76
+ # appear in the command-name position. Only the unambiguous ones are listed:
77
+ # `in`, `time` and `select` are left out because they double as ordinary
78
+ # words often enough that flagging them would misfire. Checked only in the
79
+ # command-name position (see #parse) — `echo while` is a plain word.
80
+ RESERVED_WORDS = %w[case esac while until !].freeze
81
+
82
+ module_function
83
+
84
+ # Split +text+ into tokens: :word (quote-stripped), the connectors
85
+ # :and/:or/:semi and :redirect markers. Single quotes protect everything
86
+ # verbatim — backslash has no special meaning inside them. Double quotes
87
+ # strip a backslash only before `$`/`` ` ``/`"`/`\`/newline
88
+ # (DQUOTE_BACKSLASH_SPECIAL); elsewhere the backslash stays in the word.
89
+ # Outside any quote, a backslash preserves the literal value of the
90
+ # following character and disappears itself, except before a newline
91
+ # where both vanish (line continuation) — this is POSIX quote removal,
92
+ # verified against /bin/sh. mkmf relies on the outside-quotes rule: it
93
+ # writes `-DSYSCONFDIR=\"...\"` expecting the shell to unescape the
94
+ # backslash-quotes into a literal `"` in the word. Genuinely unhandled
95
+ # shell syntax (pipe, background, substitution, subshell) stops the split.
96
+ def tokenize(text)
97
+ tokenize_spans(text).map(&:first)
98
+ end
99
+
100
+ # #tokenize's split, with each token paired with the `start...stop` range it
101
+ # occupied in +text+. Rubycc::Shell needs to get the *source* of a word back
102
+ # after the split: a POSIX shell recognises words first and only then expands
103
+ # parameters in them (XCU 2.1), and whether `$x` is inside quotes decides
104
+ # whether its value is field-split. Quote removal has already thrown that
105
+ # away by the time #tokenize returns a word, so Shell parses the structure
106
+ # from these tokens, cuts each simple command out of +text+ by its span,
107
+ # expands it, and calls #parse on the result.
108
+ def tokenize_spans(text)
109
+ tokens = []
110
+ word = nil
111
+ word_start = nil
112
+ i = 0
113
+ n = text.length
114
+ # Close off the word being accumulated, if any, at +stop+.
115
+ flush = lambda do |stop|
116
+ next if word.nil?
117
+
118
+ tokens << [[:word, word], word_start...stop]
119
+ word = nil
120
+ word_start = nil
121
+ end
122
+ # Start (or continue) a word at the current position.
123
+ begin_word = lambda { word_start ||= i }
124
+
125
+ while i < n
126
+ c = text[i]
127
+ case c
128
+ when "'"
129
+ close = text.index(c, i + 1)
130
+ unsupported!("unterminated quote", text) if close.nil?
131
+ begin_word.call
132
+ word = (word || +"") + text[(i + 1)...close]
133
+ i = close + 1
134
+ when '"'
135
+ begin_word.call
136
+ segment, i = scan_double_quoted(text, i)
137
+ word = (word || +"") + segment
138
+ when "\\"
139
+ nxt = text[i + 1]
140
+ begin_word.call
141
+ if nxt.nil?
142
+ # A lone trailing backslash with nothing to escape is kept
143
+ # literally (verified against /bin/sh).
144
+ word = (word || +"") + c
145
+ i += 1
146
+ elsif nxt == "\n"
147
+ i += 2 # line continuation: backslash and newline both vanish
148
+ # Nothing was added to the word; if it is still empty the span must
149
+ # not claim to have started here.
150
+ word_start = nil if word.nil?
151
+ else
152
+ word = (word || +"") + nxt
153
+ i += 2
154
+ end
155
+ when " ", "\t"
156
+ flush.call(i)
157
+ i += 1
158
+ when "&"
159
+ flush.call(i)
160
+ unsupported!("background '&'", text) unless text[i + 1] == "&"
161
+ tokens << [[:and], i...(i + 2)]
162
+ i += 2
163
+ when "|"
164
+ flush.call(i)
165
+ unsupported!("pipe '|'", text) unless text[i + 1] == "|"
166
+ tokens << [[:or], i...(i + 2)]
167
+ i += 2
168
+ when ";"
169
+ flush.call(i)
170
+ tokens << [[:semi], i...(i + 1)]
171
+ i += 1
172
+ when ">"
173
+ flush.call(i)
174
+ if text[i + 1] == ">"
175
+ tokens << [[:redirect, :stdout, :append], i...(i + 2)]
176
+ i += 2
177
+ else
178
+ tokens << [[:redirect, :stdout, :truncate], i...(i + 1)]
179
+ i += 1
180
+ end
181
+ when "<", "`", "(", ")"
182
+ unsupported!("shell metacharacter '#{c}'", text)
183
+ else
184
+ if word.nil? && (c == "1" || c == "2") && text[i + 1] == ">"
185
+ stream = c == "2" ? :stderr : :stdout
186
+ if text[i + 2] == ">"
187
+ tokens << [[:redirect, stream, :append], i...(i + 3)]
188
+ i += 3
189
+ else
190
+ tokens << [[:redirect, stream, :truncate], i...(i + 2)]
191
+ i += 2
192
+ end
193
+ else
194
+ begin_word.call
195
+ word = (word || +"") + c
196
+ i += 1
197
+ end
198
+ end
199
+ end
200
+ flush.call(n)
201
+ tokens
202
+ end
203
+
204
+ # Scan a double-quoted segment starting at +i+ (text[i] == '"'). Applies
205
+ # the backslash-removal rule that is special to double quotes (see
206
+ # DQUOTE_BACKSLASH_SPECIAL) and returns [content, index_after_closing_quote].
207
+ def scan_double_quoted(text, i)
208
+ n = text.length
209
+ j = i + 1
210
+ buf = +""
211
+ loop do
212
+ unsupported!("unterminated quote", text) if j >= n
213
+
214
+ c = text[j]
215
+ if c == '"'
216
+ j += 1
217
+ break
218
+ elsif c == "\\" && j + 1 < n && DQUOTE_BACKSLASH_SPECIAL.include?(text[j + 1])
219
+ nxt = text[j + 1]
220
+ buf << nxt unless nxt == "\n" # backslash-newline vanishes entirely
221
+ j += 2
222
+ else
223
+ buf << c
224
+ j += 1
225
+ end
226
+ end
227
+ [buf, j]
228
+ end
229
+
230
+ # Turn +text+ into [[connector, SimpleCommand], ...]: the simple commands it
231
+ # holds, each tagged with the connector that precedes it (:first for the
232
+ # leading one, then :and/:or/:semi). A leading run of `VAR=value` words
233
+ # becomes that command's environment; a redirect marker consumes the
234
+ # following word as its target path.
235
+ def parse(text)
236
+ tokens = tokenize(text)
237
+ commands = []
238
+ connector = :first
239
+ assignments = []
240
+ argv = []
241
+ redirections = []
242
+ i = 0
243
+
244
+ flush = lambda do
245
+ unless assignments.empty? && argv.empty? && redirections.empty?
246
+ commands << [connector, SimpleCommand.new(assignments, argv, redirections)]
247
+ end
248
+ assignments = []
249
+ argv = []
250
+ redirections = []
251
+ end
252
+
253
+ while i < tokens.length
254
+ tok = tokens[i]
255
+ case tok[0]
256
+ when :word
257
+ w = tok[1]
258
+ if argv.empty? && w =~ /\A[A-Za-z_][A-Za-z0-9_]*=/
259
+ assignments << w
260
+ else
261
+ # A reserved word in the command-name position needs a shell
262
+ # grammar to make sense of. The ones a grammar now exists for
263
+ # (COMPOUND_WORDS) never reach here — Rubycc::Shell consumes them
264
+ # while cutting the line into simple commands, and calls this on
265
+ # what is left. The rest have no interpreter anywhere in rubycc, so
266
+ # they are refused: running one as a plain command would exec
267
+ # `while` itself and fail like a missing tool rather than like
268
+ # unsupported syntax, and approximating the construct is the
269
+ # shell-fallback risk this module exists to remove (see the file
270
+ # banner and mkmf-shell-free-conftest-1).
271
+ if argv.empty? && RESERVED_WORDS.include?(w)
272
+ unsupported!("shell reserved word '#{w}'", text)
273
+ end
274
+ argv << w
275
+ end
276
+ when :and, :or, :semi
277
+ flush.call
278
+ connector = tok[0]
279
+ when :redirect
280
+ nxt = tokens[i + 1]
281
+ unsupported!("redirection without a target", text) if nxt.nil? || nxt[0] != :word
282
+ redirections << Redirection.new(tok[1], tok[2], nxt[1])
283
+ i += 1
284
+ end
285
+ i += 1
286
+ end
287
+ flush.call
288
+ commands
289
+ end
290
+
291
+ # The argv array of +text+ read as one plain command — a program word and
292
+ # its arguments, nothing else. Anything a shell would have to interpret
293
+ # beyond word splitting and quote removal (a connector, a redirection, an
294
+ # environment assignment, an empty command) raises UnsupportedSyntaxError
295
+ # rather than being approximated. This is the mkmf shim's entry point: the
296
+ # conftest commands mkmf builds are plain commands, and one that is not has
297
+ # to be reported, not guessed at.
298
+ def argv(text)
299
+ commands = parse(text)
300
+ unsupported!("empty command", text) if commands.empty?
301
+ if commands.length > 1
302
+ unsupported!("command connector '#{CONNECTOR_NAMES[commands[1][0]]}'", text)
303
+ end
304
+
305
+ command = commands.first[1]
306
+ # A compound keyword is refused here even though #parse now lets it
307
+ # through: this entry point promises *one plain command*, and a caller
308
+ # that has no shell grammar (the mkmf shim) must not exec a `for`.
309
+ unsupported!("shell reserved word '#{command.argv.first}'", text) \
310
+ if COMPOUND_WORDS.include?(command.argv.first)
311
+ unsupported!("redirection", text) unless command.redirections.empty?
312
+ unsupported!("environment assignment '#{command.assignments.first}'", text) \
313
+ unless command.assignments.empty?
314
+
315
+ command.argv
316
+ end
317
+
318
+ # The inverse of the splitter for one word: +word+ spelled so that #argv
319
+ # (and a POSIX shell, and Shellwords, which is what RubyGems splits
320
+ # `ENV["MAKE"]` with) reads it back as this exact single word. Needed because
321
+ # a tool command has to be carried as *one string* — `CC = <ruby> <exe>` in a
322
+ # Makefile, `ENV["MAKE"]` for RubyGems — and an installation path is allowed
323
+ # to contain a space.
324
+ #
325
+ # A word of ordinary path characters is left alone, so the common case stays
326
+ # readable; anything else is single-quoted (which protects every character
327
+ # but a single quote), and a word containing a single quote is spelled with
328
+ # the `'\''` idiom every one of those splitters understands.
329
+ def quote(word)
330
+ return word if word.match?(/\A[A-Za-z0-9_@%+=:,.\/-]+\z/)
331
+
332
+ "'" + word.gsub("'", %q('"'"')) + "'"
333
+ end
334
+
335
+ def unsupported!(construct, text)
336
+ raise UnsupportedSyntaxError.new(construct, text)
337
+ end
338
+ end
339
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "diagnostics"
4
+
3
5
  module Rubycc
4
6
  # Base error class (also defined in rubycc.rb; reopened here so this file can
5
7
  # be required standalone).
@@ -27,10 +29,11 @@ module Rubycc
27
29
 
28
30
  private
29
31
 
32
+ # Rendered by the shared diagnostic formatter, so an error and a warning
33
+ # (Diagnostics.warn) differ only in the severity word.
30
34
  def build_message
31
- header = "#{filename}:#{line}:#{column}: error: #{description}"
32
- caret = "#{" " * (column - 1)}^"
33
- "#{header}\n#{source_line}\n#{caret}"
35
+ Diagnostics.render("error", description, filename: filename, line: line,
36
+ column: column, source_line: source_line)
34
37
  end
35
38
  end
36
39
  end
@@ -5,6 +5,8 @@ require_relative "compile_error"
5
5
  require_relative "preprocess/preprocessor"
6
6
  require_relative "front/parser"
7
7
  require_relative "ir/generator"
8
+ require_relative "ir/analysis"
9
+ require_relative "ir/simplify"
8
10
  require_relative "backend/x86_64"
9
11
  require_relative "backend/aarch64"
10
12
  require_relative "objfile/elf_writer"
@@ -148,7 +150,19 @@ module Rubycc
148
150
  defined_names = Set.new
149
151
  relocations = []
150
152
  ir_program.functions.each do |ir_func|
151
- result = backend.compile(ir_func)
153
+ # The local rewrites (IR::Simplify) sit here, between the generator and
154
+ # the backend, rather than inside either: the generator's output stays
155
+ # the plain lowering of the source, which is what makes it readable and
156
+ # testable, and a backend keeps receiving one well-defined IR rather
157
+ # than having to run the pass itself. Both backends lower everything the
158
+ # pass can produce, so the seam needs no per-target condition.
159
+ #
160
+ # The census the pass took on its way through travels with the function
161
+ # (IR::Analysis), because the backend needs the same counts — the
162
+ # transient set, and through IR::Promotion the occurrence weights — and
163
+ # counting the list again is the one thing this seam can save it.
164
+ analysis = IR::Analysis.simplified(ir_func)
165
+ result = backend.compile(analysis.function, analysis)
152
166
  # Align each function to 16 bytes with the target's NOP filler, keeping
153
167
  # the output deterministic and every entry point aligned.
154
168
  pad_to_alignment(text, 16, entry[:machine].text_padding)
@@ -293,7 +307,8 @@ module Rubycc
293
307
  system_includes: true, target: "x86_64",
294
308
  libc: Preprocess::Preprocessor.host_libc,
295
309
  default_visibility: :default)
296
- source = File.read(input_path)
310
+ # Bytes, not locale-encoded text: see Scanner's class comment.
311
+ source = File.binread(input_path)
297
312
  binary = new.compile(source, filename: input_path, include_paths: include_paths,
298
313
  pic: pic, defines: defines, system_includes: system_includes,
299
314
  target: target, libc: libc,
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycc
4
+ # The compiler's diagnostic channel: one renderer for every severity, and the
5
+ # stream the non-fatal ones are written to.
6
+ #
7
+ # Until `#warning` there was only one severity here. A diagnostic was a raised
8
+ # CompileError and nothing else, so "report it" and "give up on this
9
+ # translation unit" were the same act, and several places in the front end say
10
+ # so ("a warning is not a channel this compiler has"). `#warning` needs the
11
+ # other half: a located message that is reported and then *returned from*, so
12
+ # the compile continues and exits 0 (C23 6.10.2p2, and gcc's long-standing
13
+ # extension of the same shape).
14
+ #
15
+ # The two halves share #render, so a warning is spelled exactly like an error
16
+ # apart from the severity word — one place to change the format, not two.
17
+ # CompileError renders itself through it (see compile_error.rb); a warning is
18
+ # written straight to the stream, because there is nothing to raise.
19
+ #
20
+ # The stream is process-wide state rather than a parameter threaded through
21
+ # every component, for the reason the first caller shows: the warning is
22
+ # raised deep inside the preprocessor's directive walk, whose callers
23
+ # (Compiler, and the parser and IR generator that will want this channel next)
24
+ # take no stream and have no business growing one. Concurrency does not argue
25
+ # against it here — rmake's `-j` isolates each step in a forked child (see
26
+ # Rmake::Executor), so no two translation units share this state.
27
+ module Diagnostics
28
+ # The stream state when nobody has chosen one: warnings go to the process's
29
+ # $stderr, looked up at emit time so a test (or a caller) that swaps $stderr
30
+ # is honored. It is a distinct object from nil, which means "discard".
31
+ INHERIT = Object.new
32
+ private_constant :INHERIT
33
+
34
+ # The stream lives in thread-local storage rather than in a class variable.
35
+ # Nothing in this compiler runs two Drivers in one process today (rmake's
36
+ # `-j` forks each step, and a forked worker runs its Driver in-process but
37
+ # alone), so a shared slot would be correct as things stand. Thread-local
38
+ # costs one lookup and removes the whole class of bug the moment somebody
39
+ # does thread a second compile through here.
40
+ KEY = :rubycc_diagnostics_stream
41
+ private_constant :KEY
42
+
43
+ # Thread-local storage cannot tell "set to nil" from "never set", and the
44
+ # difference matters here: nil is a caller asking to discard, an absent slot
45
+ # means inherit $stderr. DISCARD carries the request instead.
46
+ DISCARD = Object.new
47
+ private_constant :DISCARD
48
+
49
+ class << self
50
+ # A gcc-style diagnostic: the "file:line:column: severity: text" header,
51
+ # the offending source line, and a caret under the offending column.
52
+ # `severity` is the bare word ("error", "warning").
53
+ #
54
+ # The message is assembled as *bytes*. Two of its pieces come from outside
55
+ # and carry whatever encoding their origin gave them: the source line is
56
+ # ASCII-8BIT (the scanner reads bytes, see Preprocess::Scanner), while the
57
+ # file name — and, through a header name or a macro spelling, sometimes the
58
+ # description — comes from the command line and is tagged with the locale's
59
+ # encoding. Splicing two such strings with plain interpolation raises
60
+ # Encoding::CompatibilityError as soon as both hold a byte past 0x7F
61
+ # (compiling "日本.c" under a UTF-8 locale, with a comment in Japanese on
62
+ # the offending line), which would replace the diagnostic with a Ruby
63
+ # backtrace — the very failure this renderer exists to avoid. Re-tagging
64
+ # each piece as bytes removes the question: nothing is transcoded, the
65
+ # bytes reach the terminal as they were spelled in the source, and the
66
+ # caret still counts in characters (see Scanner#column_at).
67
+ def render(severity, description, filename:, line:, column:, source_line:)
68
+ message = +"".b
69
+ message << filename.to_s.b << ":#{line}:#{column}: #{severity}: " << description.to_s.b
70
+ message << "\n" << source_line.to_s.b << "\n" << (" " * (column - 1)) << "^"
71
+ end
72
+
73
+ # Reports a warning at a source position and returns; the compile goes on.
74
+ # Nothing is written when warnings are being discarded (see .to).
75
+ def warn(description, filename:, line:, column:, source_line:)
76
+ target = stream
77
+ return if target.nil?
78
+
79
+ target.puts render("warning", description, filename: filename, line: line,
80
+ column: column, source_line: source_line)
81
+ end
82
+
83
+ # Runs the block with warnings written to `stream` — an IO, or nil to
84
+ # discard them (what the driver's `-w` selects). The previous target is
85
+ # restored afterwards, so a nested run (rmake invoking the Driver
86
+ # in-process) leaves nothing behind.
87
+ def to(stream)
88
+ previous = Thread.current.thread_variable_get(KEY) || INHERIT
89
+ Thread.current.thread_variable_set(KEY, stream.nil? ? DISCARD : stream)
90
+ yield
91
+ ensure
92
+ Thread.current.thread_variable_set(KEY, previous)
93
+ end
94
+
95
+ private
96
+
97
+ def stream
98
+ current = Thread.current.thread_variable_get(KEY) || INHERIT
99
+ return $stderr if current.equal?(INHERIT)
100
+
101
+ current.equal?(DISCARD) ? nil : current
102
+ end
103
+ end
104
+ end
105
+ end
@@ -32,12 +32,15 @@ module Rubycc
32
32
  # Load gem information from +dir+ (or an explicit --gemfile path). A
33
33
  # Gemfile.lock next to the Gemfile is preferred; otherwise the Gemfile
34
34
  # itself is parsed. Returns a Result, or nil when neither file exists.
35
+ #
36
+ # Read as bytes (lib/rubycc.rb): a Gemfile that comments in its author's
37
+ # own language is ordinary, and the doctor is the first command anyone runs.
35
38
  def load(gemfile_path)
36
39
  lock = "#{gemfile_path}.lock"
37
40
  if File.file?(lock)
38
- parse_lock(File.read(lock), path: lock)
41
+ parse_lock(File.binread(lock), path: lock)
39
42
  elsif File.file?(gemfile_path)
40
- parse_gemfile(File.read(gemfile_path), path: gemfile_path)
43
+ parse_gemfile(File.binread(gemfile_path), path: gemfile_path)
41
44
  end
42
45
  end
43
46
 
@@ -47,7 +50,11 @@ module Rubycc
47
50
  # 4-space indent (`name (version)`), each optionally trailed by its own
48
51
  # dependencies at 6-space indent (`dep (constraint)`). DEPENDENCIES lists
49
52
  # the directly-declared gems at 2-space indent.
53
+ #
54
+ # Bytes in (lib/rubycc.rb): every construct named above is spelled in
55
+ # ASCII, and the rest is carried through.
50
56
  def parse_lock(text, path: nil)
57
+ text = text.b unless text.encoding == Encoding::BINARY
51
58
  entries = []
52
59
  direct = []
53
60
  section = nil # current top-level section header
@@ -96,8 +103,10 @@ module Rubycc
96
103
  # declaration and an optional literal version argument. This never evaluates
97
104
  # the file, so conditionals, variables and computed names are invisible —
98
105
  # the caller reports the reduced fidelity. Group/platform blocks are not
99
- # tracked; every declared gem is returned.
106
+ # tracked; every declared gem is returned. Bytes in, like #parse_lock: the
107
+ # comment this strips is exactly where a Gemfile's non-ASCII lives.
100
108
  def parse_gemfile(text, path: nil)
109
+ text = text.b unless text.encoding == Encoding::BINARY
101
110
  entries = []
102
111
  text.each_line do |raw|
103
112
  line = raw.sub(/#.*/, "")
@@ -46,8 +46,12 @@ module Rubycc
46
46
  end
47
47
 
48
48
  # Load the database from +path+ (defaults to the shipped file).
49
+ #
50
+ # The encoding is named rather than inherited from the locale: JSON *is*
51
+ # UTF-8 (RFC 8259 §8.1), so there is nothing to guess. The shipped file's
52
+ # notes carry non-ASCII punctuation, which a locale-tagged read rejects.
49
53
  def self.load(path = DEFAULT_PATH)
50
- new(JSON.parse(File.read(path)))
54
+ new(JSON.parse(File.read(path, encoding: Encoding::UTF_8)))
51
55
  end
52
56
 
53
57
  def initialize(raw)