claricle 0.1.0 → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -1
  3. data/LICENSE +25 -0
  4. data/README.adoc +439 -157
  5. data/TODO.plan/00-overview.md +502 -0
  6. data/TODO.plan/01-core.md +296 -0
  7. data/TODO.plan/02-inspect.md +163 -0
  8. data/TODO.plan/03-conform.md +208 -0
  9. data/TODO.plan/04-convert.md +216 -0
  10. data/exe/claricle +1 -1
  11. data/lib/claricle/batch.rb +240 -0
  12. data/lib/claricle/cli.rb +514 -40
  13. data/lib/claricle/conformance_options.rb +49 -0
  14. data/lib/claricle/conversion_engine.rb +21 -0
  15. data/lib/claricle/convert.rb +21 -0
  16. data/lib/claricle/detector.rb +696 -0
  17. data/lib/claricle/emf_header.rb +33 -0
  18. data/lib/claricle/errors.rb +73 -0
  19. data/lib/claricle/fault.rb +69 -0
  20. data/lib/claricle/handlers/base.rb +169 -0
  21. data/lib/claricle/handlers/extension_points.rb +124 -0
  22. data/lib/claricle/handlers/metafile.rb +1011 -0
  23. data/lib/claricle/handlers/pdf.rb +961 -0
  24. data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
  25. data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
  26. data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
  27. data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
  28. data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
  29. data/lib/claricle/handlers/pdf_arlington.rb +38 -0
  30. data/lib/claricle/handlers/pdf_profiles.rb +66 -0
  31. data/lib/claricle/handlers/png.rb +1107 -0
  32. data/lib/claricle/handlers/postscript.rb +1357 -0
  33. data/lib/claricle/handlers/svg.rb +787 -0
  34. data/lib/claricle/image.rb +272 -0
  35. data/lib/claricle/lossiness/levels.rb +13 -0
  36. data/lib/claricle/lossiness.rb +864 -0
  37. data/lib/claricle/models/base.rb +460 -0
  38. data/lib/claricle/models/batch_item.rb +127 -0
  39. data/lib/claricle/models/conversion.rb +167 -0
  40. data/lib/claricle/models/format_capability.rb +40 -0
  41. data/lib/claricle/models/free_form_hash.rb +77 -0
  42. data/lib/claricle/models/inspection.rb +188 -0
  43. data/lib/claricle/models/issue.rb +23 -0
  44. data/lib/claricle/models/location.rb +51 -0
  45. data/lib/claricle/models/report.rb +54 -0
  46. data/lib/claricle/models/uncoerced_integer.rb +38 -0
  47. data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
  48. data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
  49. data/lib/claricle/postscript_fidelity.rb +160 -0
  50. data/lib/claricle/registry.rb +125 -0
  51. data/lib/claricle/version.rb +1 -1
  52. data/lib/claricle/writer.rb +468 -0
  53. data/lib/claricle.rb +244 -7
  54. metadata +201 -19
  55. data/claricle.gemspec +0 -42
  56. data/sig/claricle.rbs +0 -4
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Required here rather than from the entry point: claricle.rb does not
4
+ # require this file yet, so requiring it on its own is the only load path
5
+ # it has. registry.rb also carries its own requires, though for its own
6
+ # stated reason -- it IS required from claricle.rb:10.
7
+ require_relative "errors"
8
+
9
+ module Claricle
10
+ # Everything between "the bytes exist" and "the bytes are on disk or on
11
+ # stdout". Preflight is the constructor, so it cannot be skipped.
12
+ class Writer
13
+ STDOUT_DESTINATION = "-"
14
+
15
+ # The comparisons a name goes through, and the authorities that answer
16
+ # them. `normalize` folds Unicode spellings and is UNCONDITIONAL --
17
+ # comparing canonicalized strings costs nothing and needs no probe.
18
+ # `fold` additionally folds case, which is a filesystem property the
19
+ # probe must be asked about. Collapsing the two into one function and
20
+ # letting a single probe verdict settle both is R-10: on a case-
21
+ # sensitive, normalizing filesystem a pair differing only in Unicode
22
+ # spelling was silently accepted as distinct and one file overwrote the
23
+ # other, exit 0, no warning. `fold` calls `normalize` rather than
24
+ # repeating its guard, so the totality proof has one copy and cannot
25
+ # drift between the two. `raw_canonical` compares locations and
26
+ # `key_for` compares files -- neither is either of these.
27
+ module Naming
28
+ module_function
29
+
30
+ # TOTAL, and the property is what makes it total rather than a list
31
+ # of cases. `unicode_normalize` is defined for exactly those strings
32
+ # whose BYTES are valid UTF-8 read as UTF-8; everything else raises
33
+ # Encoding::CompatibilityError (a non-Unicode encoding) or
34
+ # ArgumentError (an invalid byte sequence). So the guard reinterprets
35
+ # the bytes as UTF-8 and tests validity THERE -- one predicate
36
+ # covering both failure modes. Testing the name AS TAGGED is not
37
+ # enough: "Logo.svg".b is valid ASCII-8BIT and still raises.
38
+ #
39
+ # The fallback returns the raw bytes UNCHANGED -- no case folding
40
+ # here. Case folding belongs to `fold` alone; doing it here too would
41
+ # let the two equivalences drift back into one, which is R-10.
42
+ def normalize(name)
43
+ utf8 = name.dup.force_encoding(Encoding::UTF_8)
44
+ return name.b unless utf8.valid_encoding?
45
+
46
+ utf8.unicode_normalize(:nfc)
47
+ end
48
+
49
+ # `normalize`'s guard-and-NFC half, plus case folding. The fallback
50
+ # ASCII-case-folds rather than returning the name raw. Returning it
51
+ # raw misses a collision the card forbids -- A\xFF.svg and a\xFF.svg
52
+ # are one file on a case-folding volume. Full Unicode folding is
53
+ # undefined on invalid bytes; ASCII case folding is defined on any
54
+ # byte string, and it cannot over-reject, because folding decides
55
+ # only GROUPING and a grouped pair is then handed to the probe, which
56
+ # answers "distinct" on a case-sensitive volume.
57
+ #
58
+ # `normalized.encoding == Encoding::BINARY` is the fallback
59
+ # discriminator, and it is faithful rather than clever: `normalize`
60
+ # returns UTF-8 on the Unicode path (`unicode_normalize` always does)
61
+ # and ASCII-8BIT on the fallback path (`name.b` always does), so the
62
+ # encoding records which branch ran and cannot disagree with it.
63
+ def fold(name)
64
+ normalized = normalize(name)
65
+ return normalized.tr("A-Z", "a-z") if normalized.encoding == Encoding::BINARY
66
+
67
+ normalized.downcase(:fold)
68
+ end
69
+
70
+ # File.realdirpath is unusable here: it resolved /var to /private/var
71
+ # but left an existing Foo spelled foo, so it canonicalizes the half
72
+ # that does not need it and not the half that does.
73
+ def raw_canonical(path)
74
+ File.join(resolved_dir(File.dirname(path)), File.basename(path))
75
+ end
76
+
77
+ # File.stat, NOT lstat, and the split is deliberate: the identity
78
+ # that matters here is the file a write would touch, so a symlink
79
+ # must key as its target. Preflight's existence and type checks use
80
+ # lstat, because there what matters is the directory entry occupying
81
+ # the name -- see `entry_at`.
82
+ #
83
+ # A String key means File.stat did not resolve. That is usually "does
84
+ # not exist", but also covers a dangling symlink, where the name IS
85
+ # occupied; that routing is correct, because a dangling-symlink pair
86
+ # is String-keyed on both sides, lands in one fold group, fires the
87
+ # probe and is refused.
88
+ def key_for(path)
89
+ stat = File.stat(path)
90
+ [stat.dev, stat.ino]
91
+ rescue SystemCallError
92
+ raw_canonical(path)
93
+ end
94
+
95
+ def resolved_dir(dir)
96
+ File.realpath(dir)
97
+ rescue SystemCallError
98
+ File.expand_path(dir)
99
+ end
100
+ end
101
+
102
+ # Interrogating one directory's case folding. Lazy: zero probes on an
103
+ # ordinary batch, at most one per directory that holds a raw-distinct
104
+ # collision candidate.
105
+ module CaseProbe
106
+ PROBE_PREFIX = ".claricle-case-probe-"
107
+ UNDECIDABLE = :undecidable
108
+
109
+ module_function
110
+
111
+ # Returns true (folds), false (distinguishes) or UNDECIDABLE.
112
+ #
113
+ # The BASENAME is upcased, never the whole path: upcasing the path
114
+ # uppercases every directory component, which on a case-sensitive
115
+ # filesystem does not exist, so the probe would report undecidable
116
+ # everywhere on Linux and refuse every legitimate batch.
117
+ #
118
+ # UNDECIDABLE REFUSES the batch upstream. Treating an unprobeable
119
+ # directory as case-sensitive would be fail-OPEN: it compares more
120
+ # names as distinct and lets a real collision through.
121
+ def measure(dir)
122
+ base = "#{PROBE_PREFIX}#{Process.pid}-#{Random.urandom(8).unpack1("H*")}"
123
+ created = create_probe(File.join(dir, base.upcase))
124
+ File.exist?(File.join(dir, base.downcase))
125
+ rescue SystemCallError
126
+ UNDECIDABLE
127
+ ensure
128
+ File.unlink(created) if created
129
+ end
130
+
131
+ # Returns the path ONLY once the EXCL open has returned, so a name
132
+ # this probe did not create never becomes something the caller then
133
+ # deletes. Armed before the open, an EEXIST clash would make the
134
+ # cleanup unlink a planted file.
135
+ def create_probe(path)
136
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL) do
137
+ # Created only to see whether the name folds; nothing is written.
138
+ end
139
+ path
140
+ end
141
+ end
142
+
143
+ # The collision rules. Everything a user can cause here is an
144
+ # InvocationError, which cli.rb maps to exit 2: the path is unusable
145
+ # and that is knowable before any bytes are generated.
146
+ class Preflight
147
+ def initialize(destinations, sources:, force: false)
148
+ @destinations = destinations
149
+ @sources = sources
150
+ @force = force
151
+ @probed = {}
152
+ end
153
+
154
+ def check
155
+ @destinations.each { |dest| examine(dest) }
156
+ # A list of pairs, never a Hash keyed by destination: a Hash
157
+ # collapses an exact repeat into one entry, and an exact repeat is
158
+ # precisely what pass 1 catches among destinations that do not yet
159
+ # exist. Measured -- ["a.svg", "a.svg"] was accepted.
160
+ keys = @destinations.map { |dest| [dest, Naming.key_for(dest)] }
161
+ refuse_sources(keys)
162
+ refuse_duplicates(keys)
163
+ refuse_normalization_collisions(keys)
164
+ refuse_fold_collisions(keys)
165
+ end
166
+
167
+ private
168
+
169
+ # File.dirname and File.basename silently drop a trailing separator,
170
+ # so "build/" was examined as "build", accepted whenever nothing of
171
+ # that name existed, and then failed out of File.link with the STAGE
172
+ # FILENAME in the message -- the leak the directory refusal exists to
173
+ # prevent, reached by a shape that skipped it. That one was already
174
+ # exit 2, since cli.rb:308 maps ENOENT; the neighbouring EISDIR case
175
+ # is the one that reached exit 4. An empty destination leaked the
176
+ # same way. Both are refused here, before any bytes exist.
177
+ #
178
+ # Guard on the NAME, never on the object: a caller may pass a
179
+ # Pathname -- image.rb converts one for the same reason -- and
180
+ # Pathname#empty? tests whether the FILE is empty rather than the
181
+ # string, while Pathname has no #end_with? at all. Guarding on the
182
+ # object refused a zero-byte destination as "empty" and crashed on a
183
+ # missing one with NoMethodError, both of which worked before.
184
+ #
185
+ # File.path, not to_s: it is Ruby's own path coercion, so it accepts
186
+ # exactly what File.dirname already accepted -- a String or anything
187
+ # with #to_path -- and raises the same TypeError for anything else.
188
+ # to_s is lax where this must be strict: it turns nil into "" and an
189
+ # Array into its inspect, which reported a CALLER's bug as the USER's
190
+ # invocation mistake at exit 2.
191
+ def examine(dest)
192
+ name = File.path(dest)
193
+ raise InvocationError, "output path is empty" if name.empty?
194
+ raise InvocationError, "output path names a directory: #{name}" if name.end_with?(File::SEPARATOR)
195
+
196
+ parent = File.dirname(name)
197
+ raise InvocationError, "output directory is not a directory: #{parent}" unless directory_stat(parent).directory?
198
+
199
+ entry = entry_at(dest)
200
+ return if entry.nil?
201
+
202
+ raise InvocationError, "output path is a directory: #{dest}" if entry.directory?
203
+ raise InvocationError, "output file exists: #{dest} (use --force to replace it)" unless @force
204
+ end
205
+
206
+ # THREE distinguishable outcomes, not two. File.directory? cannot do
207
+ # this: it swallows EACCES and returns false, so an unreadable
208
+ # directory that does exist was reported as missing, sending the user
209
+ # to create something already there.
210
+ def directory_stat(dir)
211
+ File.stat(dir)
212
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ELOOP
213
+ raise InvocationError, "output directory does not exist: #{dir}"
214
+ rescue SystemCallError => e
215
+ raise InvocationError, "output directory cannot be examined: #{dir} (#{e.class})"
216
+ end
217
+
218
+ # lstat, not exist?: a dangling symlink occupies the name and is
219
+ # refused by both File.link and an EXCL open, while exist? calls it
220
+ # absent. ENOENT alone means absent; every other errno means the name
221
+ # cannot be examined. A parent at 0644 is statable but not
222
+ # searchable, so the parent check passes and this raises EACCES --
223
+ # the second of two reachable permission routes, and it answers the
224
+ # same way as the first rather than falling through to a bare errno.
225
+ def entry_at(dest)
226
+ File.lstat(dest)
227
+ rescue Errno::ENOENT
228
+ nil
229
+ rescue SystemCallError => e
230
+ raise InvocationError, "output path cannot be examined: #{dest} (#{e.class})"
231
+ end
232
+
233
+ def refuse_sources(keys)
234
+ by_key = @sources.to_h { |source| [Naming.key_for(source), source] }
235
+ keys.each do |dest, key|
236
+ source = by_key[key]
237
+ next unless source
238
+
239
+ raise InvocationError, "output would overwrite an input: #{dest} (same file as #{source})"
240
+ end
241
+ end
242
+
243
+ # Pass 1, by inode. Catches every destination that ALREADY EXISTS --
244
+ # hardlinks, symlinks to one file, case aliases the filesystem itself
245
+ # resolves -- plus exact string repeats among those that do not.
246
+ # Both passes are required: this one misses Logo.svg/logo.svg before
247
+ # either exists, and pass 2 misses hardlinked existing destinations.
248
+ def refuse_duplicates(keys)
249
+ keys.group_by { |_dest, key| key }.each_value do |group|
250
+ next if group.size == 1
251
+
252
+ raise InvocationError, "two outputs are the same file: #{group.map(&:first).join(", ")}"
253
+ end
254
+ end
255
+
256
+ # Pass 2, by NORMALIZED spelling. UNCONDITIONAL -- no probe is
257
+ # consulted and none is possible: the case probe answers a question
258
+ # about CASE, and a pair that differs only in Unicode spelling is not
259
+ # distinguished by case at all. Collapsing this into the fold pass
260
+ # (pass 3) and letting one probe verdict settle both is R-10: it let
261
+ # a file be silently overwritten on a case-sensitive, normalizing
262
+ # filesystem.
263
+ def refuse_normalization_collisions(keys)
264
+ grouped_by(keys) { |name| Naming.normalize(name) }.each_value do |group|
265
+ next if group.size == 1
266
+
267
+ raise InvocationError,
268
+ "two outputs are the same name in different Unicode spellings: " \
269
+ "#{group.map(&:first).join(", ")}"
270
+ end
271
+ end
272
+
273
+ def refuse_fold_collisions(keys)
274
+ fold_groups(keys).each do |(dir, _folded), group|
275
+ next if group.size == 1
276
+
277
+ refuse_group(dir, group.map(&:first))
278
+ end
279
+ end
280
+
281
+ # The dirname comes from the KEY, which `key_for` has already
282
+ # realpath-resolved -- NOT from the raw destination. Grouping by the
283
+ # raw dirname makes both passes miss the same file: out/Q.svg and
284
+ # linkdir/q.svg where linkdir -> out land in different groups, and
285
+ # the second write destroys the first.
286
+ #
287
+ # No filtering beyond `not_on_disk`: pass 2 already refused the whole
288
+ # batch on any normalization collision, so everything reaching here
289
+ # is normalization-distinct by construction.
290
+ def fold_groups(keys)
291
+ grouped_by(keys) { |name| Naming.fold(name) }
292
+ end
293
+
294
+ # Shared between pass 2 and pass 3 (fold_groups): filters to entries
295
+ # not yet on disk, then groups by [resolved dirname, transformed
296
+ # basename]. The transform -- normalize for pass 2, fold for pass 3
297
+ # -- is the one thing that differs between them, so it is the one
298
+ # thing left to the caller.
299
+ def grouped_by(keys)
300
+ not_on_disk(keys).group_by { |_dest, key| [File.dirname(key), yield(File.basename(key))] }
301
+ end
302
+
303
+ # A String key means File.stat did not resolve, which is what "not
304
+ # yet on disk" means here.
305
+ def not_on_disk(keys)
306
+ keys.select { |_dest, key| key.is_a?(String) }
307
+ end
308
+
309
+ # fetch with a block, never `||=`: `measure` returns FALSE on a
310
+ # case-sensitive volume, so `||=` would re-probe on every group there
311
+ # -- which is the very bug this memo removes, wearing the shape of
312
+ # its own fix. Measured before the memo: 4 colliding groups in one
313
+ # directory produced 4 probe files on a case-sensitive volume and 1
314
+ # on a folding one, because a folding volume refuses at the first.
315
+ def refuse_group(dir, names)
316
+ verdict = @probed.fetch(dir) { @probed[dir] = CaseProbe.measure(dir) }
317
+ if verdict == CaseProbe::UNDECIDABLE
318
+ raise InvocationError,
319
+ "cannot determine whether #{dir} distinguishes case, so refusing: #{names.join(", ")}"
320
+ end
321
+
322
+ raise InvocationError, "two outputs collide on a case-insensitive filesystem: #{names.join(", ")}" if verdict
323
+ end
324
+ end
325
+
326
+ # Staging, publishing, the mode, and cleanup. Publisher rather than
327
+ # Stage: it does four things and only one of them is staging.
328
+ class Publisher
329
+ def initialize(path, force: false)
330
+ @path = path
331
+ @force = force
332
+ end
333
+
334
+ def publish(bytes)
335
+ @replaced_mode = @force ? mode_of_replaced : nil
336
+ write_stage(bytes)
337
+ publish_stage
338
+ ensure
339
+ # rename consumes the stage and clears the name, so this does
340
+ # nothing there. link does not, so on that path this IS the "then
341
+ # unlink the stage" step rather than a failure path. It runs on
342
+ # success too, which is why the name is cleared rather than
343
+ # unlinked unconditionally.
344
+ File.unlink(@staged) if @staged
345
+ end
346
+
347
+ private
348
+
349
+ # The stage name is recorded INSIDE the block, so it is recorded only
350
+ # after the EXCL open has returned: armed before it, an EEXIST clash
351
+ # on the stage name would make the cleanup unlink a file this Writer
352
+ # never created. Closing the block flushes, which is what makes a
353
+ # truncated publish structurally impossible -- an unflushed stage
354
+ # publishes an EMPTY file for any payload under 8 KiB. Do not
355
+ # simplify this to the non-block form; Style/FileOpen forbids it too.
356
+ def write_stage(bytes)
357
+ path = File.join(File.dirname(@path), stage_basename)
358
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, stage_mode) do |file|
359
+ @staged = path
360
+ file.binmode
361
+ file.write(bytes)
362
+ end
363
+ end
364
+
365
+ def stage_basename
366
+ ".#{File.basename(@path)}.claricle-#{Process.pid}-#{Random.urandom(8).unpack1("H*")}"
367
+ end
368
+
369
+ # Restrictive-create-then-chmod, and only when something is being
370
+ # replaced. On the force path the stage briefly holds the replacement
371
+ # bytes, so creating it 0666 left them world-readable while the file
372
+ # being replaced was private. Creating it AT the destination's mode
373
+ # does not fix that: umask strips bits, so 0666 becomes 0644 and a
374
+ # chmod is still needed. Staging 0600 and chmod'ing up is the safe
375
+ # direction; the window is never wider than the final state.
376
+ #
377
+ # A no-force publish creates a file that did not exist, so there is
378
+ # nothing to disclose and no window. It stages 0666 & ~umask, which
379
+ # is also what keeps the published mode independent of `force`
380
+ # without reading the umask.
381
+ def stage_mode
382
+ @replaced_mode ? 0o600 : 0o666
383
+ end
384
+
385
+ def publish_stage
386
+ return link_stage unless @force
387
+
388
+ File.chmod(@replaced_mode, @staged) if @replaced_mode
389
+ File.rename(@staged, @path)
390
+ @staged = nil
391
+ end
392
+
393
+ # EEXIST here is genuinely exit 4 becoming exit 2: a destination that
394
+ # appeared after preflight is still the user's argument being wrong.
395
+ # link, not an EXCL open on the destination, because link publishes
396
+ # the name only once the content is complete.
397
+ def link_stage
398
+ File.link(@staged, @path)
399
+ rescue Errno::EEXIST
400
+ raise InvocationError, "output file exists: #{@path} (use --force to replace it)"
401
+ end
402
+
403
+ # lstat decides the TYPE, so a symlink is not "file" and the
404
+ # published file never inherits the symlink target's mode; stat reads
405
+ # the mode. Regular files only, and & 0o777 rather than & 0o7777, or
406
+ # a setuid bit survives onto content claricle entirely replaced.
407
+ #
408
+ # rename gives the destination the stage's mode, which is why this
409
+ # exists at all. It also gives the destination the stage's OWNER, so
410
+ # replacing another user's file changes its owner -- inherent to the
411
+ # stage-and-rename the card mandates, not something this can fix.
412
+ def mode_of_replaced
413
+ return nil unless File.lstat(@path).ftype == "file"
414
+
415
+ File.stat(@path).mode & 0o777
416
+ rescue SystemCallError
417
+ nil
418
+ end
419
+ end
420
+
421
+ private_constant :Naming, :CaseProbe, :Preflight, :Publisher
422
+
423
+ # sources: is required rather than defaulted. Defaulting it defaults
424
+ # away the strongest rule the card states, and image.rb refuses rather
425
+ # than defaults in the same position.
426
+ def initialize(destinations, sources:, stdout: $stdout, force: false)
427
+ @stdout = stdout
428
+ @force = force
429
+ @planned = destinations.reject { |dest| stdout_destination?(dest) }
430
+ @planned_names = @planned.map { |dest| File.path(dest) }
431
+ Preflight.new(@planned, sources: sources, force: force).check
432
+ end
433
+
434
+ # Returns the destination exactly as the caller spelled it, or nil for
435
+ # stdout. The canonical form exists only to compare names: `Preflight`
436
+ # already normalizes with `File.path` (Pathname/String both answer it),
437
+ # so a destination planned as one and written as the other -- or a
438
+ # `Pathname.new("-")` standing in for `"-"` -- compares equal here too,
439
+ # rather than raising a false "not preflighted".
440
+ def write(bytes, to:)
441
+ return write_stdout(bytes) if stdout_destination?(to)
442
+
443
+ unless @planned_names.include?(File.path(to))
444
+ raise ArgumentError, "destination was not preflighted: #{to.inspect}"
445
+ end
446
+
447
+ Publisher.new(to, force: @force).publish(bytes)
448
+ to
449
+ end
450
+
451
+ private
452
+
453
+ def stdout_destination?(dest)
454
+ File.path(dest) == STDOUT_DESTINATION
455
+ end
456
+
457
+ # Never puts, which appends a newline to bytes that do not already end
458
+ # in one and corrupts binary output. binmode first, or a stdout with an
459
+ # encoding set raises Encoding::UndefinedConversionError on real bytes.
460
+ def write_stdout(bytes)
461
+ @stdout.binmode
462
+ @stdout.write(bytes)
463
+ nil
464
+ end
465
+ end
466
+
467
+ private_constant :Writer
468
+ end