tayle 0.0.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4c3659dae92c4938d1fcd1b672acdaae573381d210e53c760960dac3ba03fe91
4
+ data.tar.gz: f0a81610b40f35d0aae755aebd0e48f87eee4facc711cc8bad4167b8ebc5bfa2
5
+ SHA512:
6
+ metadata.gz: 315cec2e7111c0e0ce81ee01df7fe0c15cb96e24b7f9d778214dcdd45d3d6f3e8a6bde57111ba85d08105425410d223987f2eb17f2d4d1418a0c0da5d24f7937
7
+ data.tar.gz: e5f7be6634d58b59df2dc222476b136ba481c388f7f022bad00a8f64240ef08442a9f937c9958568709e3e932cb160df8db39cfd257a85495fc1e8549934f956
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,476 @@
1
+ # =============================================================================
2
+ # RuboCop configuration
3
+ #
4
+ # Base: stock RuboCop defaults (verified against 1.91.0)
5
+ # Plugins: rubocop-claude (AI guardrails), rubocop-rspec, rubocop-performance
6
+ # (chain-hostile cops disabled), rubocop-rake
7
+ # Philosophy: idiomatic Ruby, pipeline-style chaining, strict for AI agents,
8
+ # readable for humans.
9
+ # =============================================================================
10
+
11
+ plugins:
12
+ - rubocop-claude
13
+ - rubocop-rspec
14
+ - rubocop-performance
15
+ - rubocop-rake
16
+
17
+ AllCops:
18
+ NewCops: enable
19
+ TargetRubyVersion: 4.0
20
+ Exclude:
21
+ - bin/*
22
+ - vendor/**/*
23
+
24
+ # =============================================================================
25
+ # Layout
26
+ # =============================================================================
27
+
28
+ # Consistent 2-space indent for a first argument on its own line, never
29
+ # aligned to a nested call.
30
+ #
31
+ # # bad
32
+ # foo = some_method(nested_call(
33
+ # nested_first_param
34
+ # ))
35
+ #
36
+ # # good
37
+ # foo = some_method(nested_call(
38
+ # nested_first_param
39
+ # ))
40
+ Layout/FirstArgumentIndentation:
41
+ EnforcedStyle: consistent
42
+
43
+ # Dot-aligned chaining: the dots form a visual column. This is stock's default,
44
+ # restated here because rubocop-claude switches it to `indented`.
45
+ #
46
+ # # bad
47
+ # ROOT = Pathname(__dir__)
48
+ # .parent
49
+ #
50
+ # # good
51
+ # ROOT = Pathname(__dir__)
52
+ # .parent
53
+ Layout/MultilineMethodCallIndentation:
54
+ EnforcedStyle: aligned
55
+
56
+ # =============================================================================
57
+ # Metrics
58
+ # =============================================================================
59
+
60
+ # Short blocks push toward small chained steps instead of fat lambdas.
61
+ # Stock default is 25. We want 8.
62
+ #
63
+ # # bad - 9 lines of body
64
+ # users.map do |user|
65
+ # name = user.full_name
66
+ # parts = name.split(" ")
67
+ # first = parts.first
68
+ # last = parts.last
69
+ # domain = user.email.split("@").last
70
+ # address = "#{first}.#{last}@#{domain}"
71
+ # normalized = address.downcase
72
+ # handle = normalized.strip
73
+ # handle.to_sym
74
+ # end
75
+ #
76
+ # # good - each step is one line
77
+ # users
78
+ # .map(&:full_name)
79
+ # .map { it.split(" ") }
80
+ # .map { "#{it.first}.#{it.last}" }
81
+ # .map(&:downcase)
82
+ #
83
+ # CountAsOne: a multiline array, hash, heredoc, or method call is one line.
84
+ #
85
+ # # good - the hash spans 10 lines but counts as 1
86
+ # users.each do |user|
87
+ # attributes = {
88
+ # id: user.id,
89
+ # name: user.name,
90
+ # email: user.email,
91
+ # role: user.role,
92
+ # plan: user.plan,
93
+ # status: user.status,
94
+ # created_at: user.created_at,
95
+ # updated_at: user.updated_at,
96
+ # }
97
+ # audit(attributes)
98
+ # end
99
+ #
100
+ # AllowedMethods: DSL blocks are exempt. Replaces stock's `refine` entry.
101
+ #
102
+ # # good - long, but `describe` is allowed
103
+ # describe Foo do
104
+ # # ... any number of lines
105
+ # end
106
+ #
107
+ # Exclude: the gemspec is one long block by construction.
108
+ Metrics/BlockLength:
109
+ Max: 8
110
+ CountAsOne:
111
+ - array
112
+ - hash
113
+ - heredoc
114
+ - method_call
115
+ AllowedMethods:
116
+ - command
117
+ - describe
118
+ - context
119
+ - namespace
120
+ - shared_examples
121
+ - shared_examples_for
122
+ - shared_context
123
+ Exclude:
124
+ - "tayle.gemspec"
125
+
126
+ # =============================================================================
127
+ # Style: strings
128
+ # =============================================================================
129
+
130
+ # Double quotes everywhere. One less decision to make.
131
+ #
132
+ # # bad
133
+ # name = 'Alice'
134
+ #
135
+ # # good
136
+ # name = "Alice"
137
+ # greeting = "Hello, #{name}"
138
+ Style/StringLiterals:
139
+ EnforcedStyle: double_quotes
140
+
141
+ # Same rule inside interpolation. Stock wants single quotes here.
142
+ #
143
+ # # bad
144
+ # status = "Tests #{success ? 'PASS' : 'FAIL'}"
145
+ #
146
+ # # good
147
+ # status = "Tests #{success ? "PASS" : "FAIL"}"
148
+ Style/StringLiteralsInInterpolation:
149
+ EnforcedStyle: double_quotes
150
+
151
+ # Frozen string literal is transitional cruft. Ruby 3.4 has chilled strings,
152
+ # and full default freeze is coming. `never` makes autocorrect strip existing
153
+ # magic comments.
154
+ #
155
+ # # bad - autocorrect will strip this
156
+ # # frozen_string_literal: true
157
+ #
158
+ # class Foo
159
+ # end
160
+ #
161
+ # # good
162
+ # class Foo
163
+ # end
164
+ Style/FrozenStringLiteralComment:
165
+ EnforcedStyle: never
166
+
167
+ # =============================================================================
168
+ # Style: literals
169
+ # =============================================================================
170
+
171
+ # Write arrays like arrays. Disabled cops flag the second form in stock.
172
+ #
173
+ # # allowed here, flagged by stock
174
+ # colors = ["red", "green", "blue"]
175
+ #
176
+ # # not used
177
+ # colors = %w[red green blue]
178
+ Style/WordArray:
179
+ Enabled: false
180
+
181
+ # # allowed here, flagged by stock
182
+ # statuses = [:active, :inactive, :pending]
183
+ #
184
+ # # not used
185
+ # statuses = %i[active inactive pending]
186
+ Style/SymbolArray:
187
+ Enabled: false
188
+
189
+ # Trailing commas in multiline literals and arguments. Cleaner diffs: adding an
190
+ # element doesn't touch the previous line, and appending saves a keystroke.
191
+ # `comma` applies only when each element sits on its own line.
192
+ #
193
+ # # bad
194
+ # method_call(
195
+ # arg1,
196
+ # arg2
197
+ # )
198
+ #
199
+ # # good
200
+ # method_call(
201
+ # arg1,
202
+ # arg2,
203
+ # )
204
+ Style/TrailingCommaInArguments:
205
+ EnforcedStyleForMultiline: comma
206
+
207
+ # # bad
208
+ # colors = [
209
+ # "red",
210
+ # "green"
211
+ # ]
212
+ #
213
+ # # good
214
+ # colors = [
215
+ # "red",
216
+ # "green",
217
+ # ]
218
+ Style/TrailingCommaInArrayLiteral:
219
+ EnforcedStyleForMultiline: comma
220
+
221
+ # # bad
222
+ # hash = {
223
+ # name: "Alice",
224
+ # age: 30
225
+ # }
226
+ #
227
+ # # good
228
+ # hash = {
229
+ # name: "Alice",
230
+ # age: 30,
231
+ # }
232
+ Style/TrailingCommaInHashLiteral:
233
+ EnforcedStyleForMultiline: comma
234
+
235
+ # Only literal mutable values get flagged. The Zeitwerk loader is mutable by
236
+ # design — it keeps registering autoloads after LOADER is assigned.
237
+ #
238
+ # # bad
239
+ # COLORS = ["red", "green", "blue"]
240
+ #
241
+ # # good
242
+ # COLORS = ["red", "green", "blue"].freeze
243
+ #
244
+ # # good - not a literal, so `strict` would be wrong here
245
+ # LOADER = Zeitwerk::Loader.for_gem.tap(&:setup)
246
+ Style/MutableConstant:
247
+ EnforcedStyle: literals
248
+
249
+ # =============================================================================
250
+ # Style: blocks and pipelines
251
+ # =============================================================================
252
+
253
+ # Pipeline style. Chaining multiline blocks is the whole point. Stock forbids
254
+ # a multiline block followed by another call.
255
+ #
256
+ # # allowed here, flagged by stock
257
+ # users
258
+ # .select do |user|
259
+ # user.active?
260
+ # user.confirmed?
261
+ # end
262
+ # .map do |user|
263
+ # user.name
264
+ # end
265
+ Style/MultilineBlockChain:
266
+ Enabled: false
267
+
268
+ # Block delimiters are a taste call. Braces for functional transforms, do/end
269
+ # for side effects. No cop captures this nuance.
270
+ #
271
+ # # allowed here, flagged by stock (multiline braces)
272
+ # names = users.map { |user|
273
+ # user.name.downcase
274
+ # }
275
+ #
276
+ # # good in both
277
+ # users.each do |user|
278
+ # send_notification(user)
279
+ # log_activity(user)
280
+ # end
281
+ Style/BlockDelimiters:
282
+ Enabled: false
283
+
284
+ # =============================================================================
285
+ # Style: error handling
286
+ # =============================================================================
287
+
288
+ # Inline fallback is fine when the failure mode is obvious.
289
+ #
290
+ # # allowed here, flagged by stock
291
+ # port = Integer(ENV["PORT"]) rescue 3000
292
+ Style/RescueModifier:
293
+ Enabled: false
294
+
295
+ # An explicit begin/rescue/end scopes what's being rescued. Without it, the
296
+ # rescue reads as part of the method signature. Stock flags a begin that wraps
297
+ # the entire body.
298
+ #
299
+ # # allowed here, flagged by stock
300
+ # def process
301
+ # begin
302
+ # dangerous_call
303
+ # rescue NetworkError => e
304
+ # retry_later(e)
305
+ # end
306
+ # end
307
+ #
308
+ # # also fine, and needs no exemption: begin scopes only part of the body
309
+ # def process
310
+ # logger.info("starting")
311
+ # begin
312
+ # dangerous_call
313
+ # rescue NetworkError => e
314
+ # retry_later(e)
315
+ # end
316
+ # logger.info("done")
317
+ # end
318
+ Style/RedundantBegin:
319
+ Enabled: false
320
+
321
+ # =============================================================================
322
+ # Style: methods and documentation
323
+ # =============================================================================
324
+
325
+ # Named args are explicit and portable. Anonymous forwarding (*, **, &) breaks
326
+ # TruffleRuby, JRuby, and Ruby < 3.2. Stock wants `...` or anonymous.
327
+ #
328
+ # # allowed here, flagged by stock
329
+ # def process(*args, **kwargs, &block)
330
+ # other_method(*args, **kwargs, &block)
331
+ # end
332
+ Style/ArgumentsForwarding:
333
+ Enabled: false
334
+
335
+ # Same rationale as above. Stock wants a bare `&`.
336
+ #
337
+ # # bad
338
+ # def process(&)
339
+ # other_method(&)
340
+ # end
341
+ #
342
+ # # good
343
+ # def process(&block)
344
+ # other_method(&block)
345
+ # end
346
+ Naming/BlockForwarding:
347
+ EnforcedStyle: explicit
348
+
349
+ # Endless methods are a one-line trick. Every method gets def/end. Stock
350
+ # only forbids the multiline form.
351
+ #
352
+ # # bad
353
+ # def upstream = "http://#{host}:#{port}"
354
+ #
355
+ # # good
356
+ # def upstream
357
+ # "http://#{host}:#{port}"
358
+ # end
359
+ Style/EndlessMethod:
360
+ EnforcedStyle: disallow
361
+
362
+ # Classes get rdoc. Run `rake rdoc` and keep it honest. Already on in stock;
363
+ # the override only exempts specs and tests.
364
+ #
365
+ # # bad
366
+ # class UserService
367
+ # attr_reader :user
368
+ # end
369
+ #
370
+ # # good
371
+ # # Handles user lifecycle operations.
372
+ # class UserService
373
+ # attr_reader :user
374
+ # end
375
+ Style/Documentation:
376
+ Enabled: true
377
+ Exclude:
378
+ - "spec/**/*"
379
+ - "test/**/*"
380
+
381
+ # Compact class names are fine for small files and tests.
382
+ #
383
+ # # allowed here, flagged by stock
384
+ # class Tayle::Writer
385
+ # end
386
+ Style/ClassAndModuleChildren:
387
+ Enabled: false
388
+
389
+ # =============================================================================
390
+ # Overrides from rubocop-claude — loosen where pipeline style conflicts
391
+ # =============================================================================
392
+
393
+ # Safe navigation reads fine two links deep; past that it hides a nil bug.
394
+ Claude/NoOverlyDefensiveCode:
395
+ MaxSafeNavigationChain: 2
396
+
397
+ Style/SafeNavigation:
398
+ MaxChainLength: 2
399
+
400
+ # Allow `return a, b` for tuple-style returns.
401
+ Style/RedundantReturn:
402
+ AllowMultipleReturnValues: true
403
+
404
+ # The gemspec's `\x0`-delimited `git ls-files` reader is deliberate, not fancy,
405
+ # and the PTY specs have to send real control characters: ctrl+c and ctrl+z.
406
+ Claude/NoFancyUnicode:
407
+ AllowedUnicode:
408
+ - "\u0003" # ctrl+c
409
+ - "\u001A" # ctrl+z
410
+ Exclude:
411
+ - "tayle.gemspec"
412
+
413
+ # lib/ documents with rdoc, whose usage examples are indented code in comments.
414
+ # The cop only recognizes YARD's `@example`, so every rdoc example reads as
415
+ # commented-out code to it.
416
+ Claude/NoCommentedCode:
417
+ Exclude:
418
+ - "lib/**/*"
419
+
420
+ # =============================================================================
421
+ # Overrides from rubocop-performance — disable chain-hostile cops
422
+ # =============================================================================
423
+
424
+ Performance/ChainArrayAllocation:
425
+ Enabled: false
426
+
427
+ Performance/MapMethodChain:
428
+ Enabled: false
429
+
430
+ # =============================================================================
431
+ # RSpec — rubocop-rspec overrides
432
+ # =============================================================================
433
+
434
+ # spec/fixtures holds programs the specs run, not specs. They are ordinary
435
+ # Ruby and the RSpec cops have nothing to say about them.
436
+ RSpec:
437
+ Exclude:
438
+ - "spec/fixtures/**/*"
439
+
440
+ # Not every describe block wraps a class.
441
+ RSpec/DescribeClass:
442
+ Enabled: false
443
+
444
+ # spec_helper gives every example `aggregate_failures`, so a second expectation
445
+ # still reports both failures instead of masking the later one.
446
+ RSpec/MultipleExpectations:
447
+ Max: 2
448
+
449
+ # These specs nest by process state: shell, then job control, then signal.
450
+ RSpec/NestedGroups:
451
+ Max: 5
452
+
453
+ # `in` for the shell a watcher runs in, `and` for a context that narrows the
454
+ # one above it.
455
+ RSpec/ContextWording:
456
+ Prefixes:
457
+ - when
458
+ - with
459
+ - without
460
+ - in
461
+ - and
462
+
463
+ # Subject placement is a readability call, not a rule.
464
+ RSpec/LeadingSubject:
465
+ Enabled: false
466
+
467
+ # Block style for expect { }.to change { } reads like a sentence.
468
+ RSpec/ExpectChange:
469
+ EnforcedStyle: block
470
+
471
+ RSpec/NamedSubject:
472
+ Enabled: false
473
+
474
+ # Shared contexts legitimately define many helpers.
475
+ RSpec/MultipleMemoizedHelpers:
476
+ Max: 10
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.7
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 David Gillis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # Tayle
2
+
3
+ Follow a file by name with GNU tail -F, across rotation and truncation
4
+
5
+ ## Installation
6
+
7
+ Install the gem and add to the application's Gemfile by executing:
8
+
9
+ $ bundle add tayle
10
+
11
+ If bundler is not being used to manage dependencies, install the gem by executing:
12
+
13
+ $ gem install tayle
14
+
15
+ ## Usage
16
+
17
+ `Tayle.new` follows a file by name, so it keeps going across log rotation and
18
+ truncation. Reading starts at the current end of the file:
19
+
20
+ ```ruby
21
+ Tayle.new("app.log") do |tayle|
22
+ tayle.each_line(chomp: true).lazy.map(&:upcase).each { puts it }
23
+ end
24
+ ```
25
+
26
+ Pass `position: :start` to read what the log already holds first, `writer_pid:`
27
+ to stop once the process writing it does, and `tail_path:` to run a `tail`
28
+ other than the first on `PATH`. See the rdoc on `Tayle` for the details.
29
+
30
+ Requires GNU coreutils `tail` 8.28 or newer; anything else raises
31
+ `Tayle::Unsupported`.
32
+
33
+ ## Development
34
+
35
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
36
+ `rake` to run the specs and RuboCop.
37
+
38
+ ## License
39
+
40
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ require "bundler/gem_tasks"
2
+
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ require "rubocop/rake_task"
8
+ RuboCop::RakeTask.new
9
+
10
+ require "gempilot/version_task"
11
+ Gempilot::VersionTask.new
12
+
13
+ require "gempilot/zeitwerk_task"
14
+ Gempilot::ZeitwerkTask.new
15
+
16
+ task default: [:spec, :rubocop]
@@ -0,0 +1,36 @@
1
+ class Tayle
2
+ POSITIONS = [:start, :end].freeze
3
+ private_constant :POSITIONS
4
+
5
+ Command = Data.define(:executable, :path, :writer, :position) do
6
+ def initialize(executable:, path:, writer:, position:)
7
+ unless POSITIONS.include?(position)
8
+ raise ArgumentError, "position: must be :start or :end, not #{position.inspect}"
9
+ end
10
+
11
+ super(executable:, path: Pathname(path), writer:, position:)
12
+ end
13
+
14
+ def argv
15
+ [
16
+ executable.to_s,
17
+ "-F",
18
+ "--sleep-interval=0.1",
19
+ *writer&.flag,
20
+ "--bytes=+#{first_byte}",
21
+ "--",
22
+ path.to_s,
23
+ ]
24
+ end
25
+
26
+ private
27
+
28
+ def first_byte
29
+ case position
30
+ in :start then 1
31
+ in :end then path.size?.to_i + 1
32
+ end
33
+ end
34
+ end
35
+ private_constant :Command
36
+ end
@@ -0,0 +1,37 @@
1
+ class Tayle
2
+ OLDEST_SUPPORTED_VERSION = [8, 28].freeze
3
+ private_constant :OLDEST_SUPPORTED_VERSION
4
+
5
+ GnuTail = Data.define(:executable, :version) do
6
+ def self.locate(command)
7
+ executable = list_candidates(command).find { it.file? && it.executable? }
8
+ raise Unsupported, "#{command} not found or not executable" unless executable
9
+
10
+ new(executable:, version: read_version(executable)).tap(&:check_supported)
11
+ end
12
+
13
+ def self.list_candidates(command)
14
+ return [Pathname(command)] if command.to_s.include?("/")
15
+
16
+ ENV
17
+ .fetch("PATH", "")
18
+ .split(File::PATH_SEPARATOR)
19
+ .map { Pathname(it).join(command) }
20
+ end
21
+
22
+ def self.read_version(executable)
23
+ IO.popen([executable.to_s, "--version"], err: File::NULL, &:read)
24
+ .match(/\Atail \(GNU coreutils\) (\d+)\.(\d+)/)
25
+ &.then { [Integer(it[1]), Integer(it[2])] }
26
+ end
27
+ private_class_method :list_candidates, :read_version
28
+
29
+ def check_supported
30
+ raise Unsupported, "#{executable} is not GNU coreutils tail" unless version
31
+ return unless (version <=> OLDEST_SUPPORTED_VERSION).negative?
32
+
33
+ raise Unsupported, "#{executable} is GNU coreutils tail #{version.join(".")}; Tayle needs 8.28 or newer"
34
+ end
35
+ end
36
+ private_constant :GnuTail
37
+ end
@@ -0,0 +1,3 @@
1
+ class Tayle
2
+ VERSION = "0.0.1".freeze
3
+ end
@@ -0,0 +1,23 @@
1
+ class Tayle
2
+ Writer = Data.define(:pid) do
3
+ def initialize(pid:)
4
+ super(pid: Integer(pid))
5
+ end
6
+
7
+ def flag
8
+ "--pid=#{pid}"
9
+ end
10
+
11
+ def gone?
12
+ begin
13
+ Process.kill(0, pid)
14
+ false
15
+ rescue Errno::EPERM
16
+ false
17
+ rescue Errno::ESRCH
18
+ true
19
+ end
20
+ end
21
+ end
22
+ private_constant :Writer
23
+ end
data/lib/tayle.rb ADDED
@@ -0,0 +1,128 @@
1
+ require "zeitwerk"
2
+
3
+ ##
4
+ # Follows a file by name with GNU <tt>tail -F</tt>, so it keeps going across log rotation and
5
+ # truncation. If this process dies without #close, +tail+ exits once nothing holds the read end
6
+ # of its pipe: at once with inotify, within 0.1s when polling.
7
+ #
8
+ # GNU tail's own limits still apply: records written to a log after it is rotated away are lost,
9
+ # and a log truncated and refilled past its old size before +tail+ notices is read from the old
10
+ # offset.
11
+ #
12
+ # Tayle.new("app.log") do |tayle|
13
+ # tayle.each_line(chomp: true).lazy.map(&:upcase).each { puts it }
14
+ # end
15
+ #
16
+ # Pass <tt>position: :start</tt> to read what the log already holds first, +writer_pid+ to stop
17
+ # when the process writing it does, and +tail_path+ to run a +tail+ other than the first on +PATH+:
18
+ #
19
+ # writer_pid = Integer(Pathname("app.pid").read)
20
+ # Tayle.new("app.log", position: :start, writer_pid:, tail_path: "/opt/coreutils/bin/tail") do |tayle|
21
+ # tayle.each { print it }
22
+ # end
23
+ class Tayle
24
+ include Enumerable
25
+
26
+ ##
27
+ # The Zeitwerk loader autoloading everything under +lib/tayle/+.
28
+ LOADER = Zeitwerk::Loader.for_gem.tap(&:setup)
29
+
30
+ ##
31
+ # Raised by Tayle.new unless +tail_path+ names an executable GNU coreutils +tail+ 8.28 or newer,
32
+ # the first release to exit by itself once nothing reads its output.
33
+ class Unsupported < StandardError
34
+ end
35
+
36
+ ##
37
+ # Raised by #each if +tail+ stops for any reason but its writer exiting: killed, say, or failed.
38
+ class Exited < StandardError
39
+ ##
40
+ # The Process::Status of +tail+, or +nil+ if something else reaped it first.
41
+ attr_reader :status
42
+
43
+ def initialize(status:)
44
+ @status = status
45
+ super("tail exited: #{status || "unknown status"}")
46
+ end
47
+ end
48
+
49
+ ##
50
+ # The number of lines #each has yielded, across every call; still readable after #close.
51
+ attr_reader :lines_read
52
+
53
+ ##
54
+ # Follows +path+ (a String or Pathname) from its current end, so only what is appended from now
55
+ # on is yielded; <tt>position: :start</tt> yields what is already there first. A missing +path+
56
+ # is read from its first byte once it appears, though GNU tail then polls instead of using
57
+ # inotify.
58
+ #
59
+ # With +writer_pid+, <tt>tail --pid</tt> exits within 0.1s of that process being reaped (an
60
+ # unreaped zombie still counts as alive), after one last read, and #each returns.
61
+ #
62
+ # +tail_path+ (a String or Pathname) names the +tail+ to run: a path if it contains a slash,
63
+ # otherwise a command looked up on +PATH+. Raises Unsupported unless that is an executable GNU
64
+ # coreutils +tail+ 8.28 or newer.
65
+ #
66
+ # With a block, yields the Tayle and closes it when the block exits.
67
+ def initialize(path, writer_pid: nil, tail_path: "tail", position: :end, &block)
68
+ @writer = writer_pid&.then { Writer.new(pid: it) }
69
+ @lines_read = 0
70
+ @owner_pid = Process.pid
71
+ gnu_tail = GnuTail.locate(tail_path)
72
+ @command = Command.new(executable: gnu_tail.executable, path:, writer: @writer, position:)
73
+ @pipe = IO.popen(@command.argv)
74
+ yield_and_close(&block) if block
75
+ end
76
+
77
+ ##
78
+ # The Pathname being followed.
79
+ def path
80
+ @command.path
81
+ end
82
+
83
+ ##
84
+ # Where reading began: +:start+ or +:end+ of #path. Not a live offset, since +tail+ moves on to
85
+ # a new file when the log is rotated.
86
+ def position
87
+ @command.position
88
+ end
89
+
90
+ ##
91
+ # Yields each line as it is appended, blocking until the next one arrives; +chomp+ strips the
92
+ # line ending. Returns an Enumerator without a block, or +self+ once +tail+ exits after the
93
+ # writer has; raises Exited if +tail+ stops any other way. As with any IO, #close from another
94
+ # thread raises IOError in the thread iterating.
95
+ def each(chomp: false)
96
+ return enum_for(__method__, chomp:) unless block_given?
97
+
98
+ @pipe.each_line(chomp:) do |line|
99
+ @lines_read += 1
100
+ yield line
101
+ end
102
+ @pipe.close
103
+ raise Exited.new(status: Process.last_status) unless @writer&.gone?
104
+
105
+ self
106
+ end
107
+ alias each_line each
108
+
109
+ ##
110
+ # Kills +tail+ with SIGKILL, which it cannot ignore, and reaps it; in a forked child, only drops
111
+ # the child's copy of the pipe. Safe to call more than once.
112
+ def close
113
+ return if @pipe.closed?
114
+
115
+ Process.kill(:KILL, @pipe.pid) if Process.pid == @owner_pid
116
+ @pipe.close
117
+ end
118
+
119
+ private
120
+
121
+ def yield_and_close
122
+ begin
123
+ yield self
124
+ ensure
125
+ close
126
+ end
127
+ end
128
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tayle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - David Gillis
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: zeitwerk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ email:
27
+ - david@flipmine.com
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - ".rspec"
33
+ - ".rubocop.yml"
34
+ - ".ruby-version"
35
+ - LICENSE.txt
36
+ - README.md
37
+ - Rakefile
38
+ - lib/tayle.rb
39
+ - lib/tayle/command.rb
40
+ - lib/tayle/gnu_tail.rb
41
+ - lib/tayle/version.rb
42
+ - lib/tayle/writer.rb
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ rubygems_mfa_required: 'true'
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 4.0.6
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 4.0.22
62
+ specification_version: 4
63
+ summary: Follow a file by name with GNU tail -F, across rotation and truncation
64
+ test_files: []