linefeed 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8e42d833b1d76721fb8906bdc579c93f0b5ab46071d2f0168adb3ffdef69f39b
4
+ data.tar.gz: 5e63818116b3d40432460f2cb90b516f0617a6dbaa67556ce0b8b4637da80161
5
+ SHA512:
6
+ metadata.gz: 6767fa2a9741565af3dc7e92fe96f474a58d4aa250f8db67ab1b619c886fce099f0565b9c87a29d9cb9bbffd78ac5a2ec569b6749be88b30d5f391ddaca751a3
7
+ data.tar.gz: a58bba06bd570e0225f130c757ea7993809791b8cedd51ccb4f9ab59b77c68d6c941cd0e1e00e3bafc9ea61f94207e7bfdbf266bc3295470e675a08c4ad0e4a3
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 inopinatus
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # Linefeed
2
+
3
+ Linefeed turns a push-style byte stream, of any chunk size, into individually yielded lines.
4
+
5
+ * https://github.com/inopinatus/linefeed
6
+
7
+ ## Why?
8
+
9
+ When you're downstream of the read on a binary-mode chunked stream and can't easily turn that into a nice efficient `IO#readlines`.
10
+
11
+ Also, it has nice properties if you chain them together.
12
+
13
+ ## Install
14
+
15
+ ```console
16
+ gem install linefeed
17
+ ```
18
+
19
+ Or add it to your Gemfile:
20
+
21
+ ```ruby
22
+ gem "linefeed"
23
+ ```
24
+
25
+ ## Protocol
26
+
27
+ Including `linefeed` supplies two methods, `#<<` and `#close`. The idea is for external
28
+ producers to drive processing by calls to these methods.
29
+
30
+ - `#<<` accepts an arbitrary-size chunk of incoming data and yields each LF-terminated line
31
+ to a handler set by `linefeed { |line| ... }` as an 8-bit ASCII string. Lines yielded
32
+ will include the trailing LF.
33
+
34
+ - `#close` marks end-of-incoming-data; if any data persists in the buffer, this yields a
35
+ final unterminated string to the same handler.
36
+
37
+ These method names are intentionally IO-ish so that you can mingle regular output files
38
+ & IO streams with `linefeed` objects.
39
+
40
+ ## Usage
41
+
42
+ ```ruby
43
+ require "linefeed"
44
+
45
+ class Collector
46
+ include Linefeed
47
+
48
+ def initialize
49
+ @lines = []
50
+ linefeed { |line| @lines << line }
51
+ end
52
+ end
53
+
54
+ collector = Collector.new
55
+ collector << "hello\nwor"
56
+ collector << "ld\n"
57
+ collector.close
58
+
59
+ # @lines => ["hello\n", "world\n"]
60
+ ```
61
+
62
+ Write custom `#<<` and `#close` handlers by passing blocks to `super` blocks:
63
+
64
+ ```ruby
65
+ def <<(chunk)
66
+ super(chunk) do |line|
67
+ puts escape(line)
68
+ end
69
+ end
70
+
71
+ def close
72
+ super do |line|
73
+ puts escape(line) + "\n"
74
+ end
75
+ puts " -- all done."
76
+ end
77
+ ```
78
+
79
+ See `examples/` for more, like daisy-chaining, or updating a digest.
80
+
81
+ ## Examples
82
+
83
+ Run `examples/demo.rb` and review the numbered examples it includes.
84
+
85
+ If testing with cooked interactive input at the console, note that `linefeed`'s examples necessarily read in binary mode, so ^D may not be instant EOF.
86
+
87
+ ## License
88
+
89
+ MIT. Copyright (c) 2025 inopinatus.
90
+
91
+ ## Contributing
92
+
93
+ At https://github.com/inopinatus/linefeed.
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'demo'
3
+
4
+ # Simplest possible example
5
+ module Demo
6
+ class Logger
7
+ include Linefeed
8
+
9
+ def initialize(output)
10
+ line_no = 0
11
+ linefeed do |line|
12
+ output << "%.3d => %s" % [line_no += 1, line]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'demo'
3
+
4
+ # Per-line processing with headers & trailers
5
+ module Demo
6
+ class Canonicalize < Consumer
7
+ def initialize(*)
8
+ super
9
+ @output << "---------- START\r\n"
10
+ @output << "Canonicalized: yes\r\n"
11
+ @output << "\r\n"
12
+ end
13
+
14
+ def process_line(line)
15
+ canonicalize(line)
16
+ end
17
+
18
+ def canonicalize(line)
19
+ line.chomp.sub(/[ \t]+$/, "") + "\r\n"
20
+ end
21
+
22
+ def close
23
+ super
24
+ @output << "---------- END\r\n"
25
+ @output.close
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'demo'
3
+
4
+ # Handling the protocol via super
5
+ module Demo
6
+ class Escaped < Consumer
7
+ def escape(line)
8
+ line.sub(/^(-|From )/, "- \\1")
9
+ end
10
+
11
+ def <<(chunk)
12
+ super(chunk) do |line|
13
+ @output << escape(line)
14
+ end
15
+ end
16
+
17
+ def close
18
+ super do |line|
19
+ @output << escape(line) + "\n"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'demo'
3
+ require "digest"
4
+
5
+ # Only outputs at close
6
+ module Demo
7
+ class LineDigest
8
+ include Linefeed
9
+
10
+ def initialize(output)
11
+ @output = output
12
+ @line_digest = Digest("SHA256").new
13
+
14
+ linefeed do |line|
15
+ @line_digest.update(line)
16
+ end
17
+ end
18
+
19
+ def close
20
+ super
21
+ @output << @line_digest.hexdigest
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'demo'
3
+ require "digest"
4
+
5
+ # Not actually using Linefeed, but speaking the same protocol,
6
+ # consuming entire chunks.
7
+ #
8
+ # Should give the same digest as LineDigest.
9
+ module Demo
10
+ class ChunkDigest
11
+ def initialize(output)
12
+ @output = output
13
+ @digest = Digest("SHA256").new
14
+ end
15
+
16
+ def <<(chunk)
17
+ @digest << chunk
18
+ end
19
+
20
+ def close
21
+ @output << @digest.hexdigest
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'demo'
3
+ require "delegate"
4
+
5
+ # Easy chaining
6
+ module Demo
7
+ class CanonicalizedDigest < DelegateClass(Consumer)
8
+ def initialize(output)
9
+ super(Canonicalize.new(LineDigest.new(output)))
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'demo'
3
+
4
+ # Intentionally fails to setup the feed and suffers for it.
5
+ # Don't do this.
6
+ module Demo
7
+ class Null
8
+ include Linefeed
9
+
10
+ def initialize(output)
11
+ @output = output
12
+ @count = 0
13
+ end
14
+
15
+ def <<(*)
16
+ super
17
+ rescue ArgumentError
18
+ @count += 1
19
+ end
20
+
21
+ def close
22
+ super
23
+ rescue ArgumentError
24
+ @output << "rescued #{@count += 1} time(s)"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # simple base for demos
4
+ module Demo
5
+ class Consumer
6
+ include Linefeed
7
+
8
+ def initialize(output)
9
+ @output = output
10
+
11
+ linefeed do |line|
12
+ output << process_line(line)
13
+ end
14
+ end
15
+ end
16
+ end
data/examples/demo.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../lib/linefeed"
3
+ require_relative "demo_helper"
4
+
5
+ if $0 == __FILE__
6
+ example_files = Dir[File.join(__dir__, "[0-9][0-9]_*.rb")].sort
7
+ example_files.each do |path|
8
+ require_relative File.basename(path)
9
+ end
10
+ end
11
+
12
+ def run
13
+ recipients = Demo.setup_examples
14
+ maxlen = 8192
15
+ chunk = "".b
16
+
17
+ while $stdin.read(maxlen, chunk)
18
+ recipients.each do |r|
19
+ r << chunk
20
+ end
21
+ end
22
+ recipients.each(&:close)
23
+ end
24
+
25
+ at_exit { run } unless @at_exit_installed; @at_exit_installed = true
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'consumer'
3
+
4
+ module Demo
5
+ # IO trap
6
+ class Output
7
+ def initialize(klass)=@prefix = klass.to_s
8
+ def <<(o)=puts "#{@prefix}: #{o.inspect}"
9
+ def close()=puts "#{@prefix} closed."
10
+ end
11
+
12
+ # Example registry
13
+ @example_classes = []
14
+ class << self
15
+ def const_added(const_name)
16
+ super
17
+ if const_get(const_name, false) in Class => klass
18
+ register(klass)
19
+ end
20
+ end
21
+
22
+ def register(klass)
23
+ @example_classes << klass
24
+ end
25
+
26
+ def setup_examples
27
+ @example_classes.map { |k| k.new(Output.new(k)) }
28
+ end
29
+ end
30
+ end
31
+
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Linefeed
4
+ VERSION = "0.2.0"
5
+ end
data/lib/linefeed.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "linefeed/version"
4
+
5
+ module Linefeed
6
+ class Error < StandardError; end
7
+
8
+ def linefeed(&default_proc)
9
+ @__linefeed_default = default_proc
10
+ __linefeed_reset
11
+ self
12
+ end
13
+
14
+ def __linefeed_reset
15
+ @__linefeed_buffer = +"".b
16
+ @__linefeed_closed = false
17
+ self
18
+ end
19
+
20
+ # Called by push-type source to write to us.
21
+ def <<(chunk, &per_line)
22
+ per_line ||= @__linefeed_default
23
+ raise Error, "already closed" if @__linefeed_closed
24
+ raise ArgumentError, "no line handler" unless per_line
25
+
26
+ @__linefeed_buffer ||= +"".b
27
+ @__linefeed_buffer << chunk
28
+
29
+ while (eol = @__linefeed_buffer.index("\n"))
30
+ per_line.call(@__linefeed_buffer.slice!(0..eol)) # includes the "\n"
31
+ end
32
+
33
+ self
34
+ end
35
+
36
+ # Called at end-of-stream.
37
+ def close(&per_line)
38
+ per_line ||= @__linefeed_default
39
+ raise Error, "already closed" if @__linefeed_closed
40
+ raise ArgumentError, "no line handler" unless per_line
41
+ @__linefeed_closed = true
42
+ return self if !@__linefeed_buffer || @__linefeed_buffer.empty?
43
+
44
+ per_line.call(@__linefeed_buffer.slice!(0, @__linefeed_buffer.bytesize)) # final unterminated line
45
+ self
46
+ end
47
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "../lib/linefeed"
5
+
6
+ class LinefeedTest < Minitest::Test
7
+ class StandardReceiver
8
+ include Linefeed
9
+ attr_reader :lines
10
+
11
+ def initialize
12
+ @lines = []
13
+ linefeed { |line| @lines << line }
14
+ end
15
+ end
16
+
17
+ class CustomReceiver
18
+ include Linefeed
19
+ attr_reader :lines
20
+
21
+ def initialize
22
+ @lines = []
23
+ end
24
+
25
+ def <<(chunk)
26
+ super do |line|
27
+ @lines << "line:#{line}"
28
+ end
29
+ end
30
+
31
+ def close
32
+ super do |line|
33
+ @lines << "eof:#{line}"
34
+ end
35
+ end
36
+ end
37
+
38
+ def test_basic_yield
39
+ receiver = StandardReceiver.new
40
+ receiver << "a\nb\n"
41
+
42
+ assert_equal ["a\n", "b\n"], receiver.lines
43
+ end
44
+
45
+ def test_works_across_chunks
46
+ receiver = StandardReceiver.new
47
+ receiver << "a"
48
+ receiver << "\n"
49
+ receiver << "b"
50
+ receiver << "\n"
51
+
52
+ assert_equal ["a\n", "b\n"], receiver.lines
53
+ end
54
+
55
+ def test_flush_unterminated
56
+ receiver = StandardReceiver.new
57
+ receiver << "tail"
58
+ receiver.close
59
+
60
+ assert_equal ["tail"], receiver.lines
61
+ end
62
+
63
+ def test_empty_close_does_nothing
64
+ receiver = StandardReceiver.new
65
+ receiver.close
66
+
67
+ assert_equal [], receiver.lines
68
+ end
69
+
70
+ def test_you_forget_the_handlers
71
+ obj = Object.new
72
+ obj.extend(Linefeed)
73
+
74
+ assert_raises(ArgumentError) { obj << "a\n" }
75
+ assert_raises(ArgumentError) { obj.close }
76
+ end
77
+
78
+ def test_raise_after_close
79
+ receiver = StandardReceiver.new
80
+ receiver.close
81
+
82
+ assert_raises(Linefeed::Error) { receiver << "a\n" }
83
+ assert_raises(Linefeed::Error) { receiver.close }
84
+ end
85
+
86
+ def test_custom_handlers
87
+ receiver = CustomReceiver.new
88
+
89
+ receiver << "a"
90
+ receiver << "\n"
91
+ receiver << "b"
92
+ receiver.close
93
+ assert_equal ["line:a\n", "eof:b"], receiver.lines
94
+ end
95
+ end
metadata ADDED
@@ -0,0 +1,99 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: linefeed
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Joshua Goodall
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: irb
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: Linefeed turns a push-style byte stream, of any chunk size, into individually
55
+ yielded lines.
56
+ email: inopinatus@hey.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - LICENSE
62
+ - README.md
63
+ - examples/01_logger.rb
64
+ - examples/02_canonicalize.rb
65
+ - examples/03_escaped.rb
66
+ - examples/04_line_digest.rb
67
+ - examples/05_chunk_digest.rb
68
+ - examples/06_canonicalized_digest.rb
69
+ - examples/07_null.rb
70
+ - examples/consumer.rb
71
+ - examples/demo.rb
72
+ - examples/demo_helper.rb
73
+ - lib/linefeed.rb
74
+ - lib/linefeed/version.rb
75
+ - test/test_linefeed.rb
76
+ homepage: https://github.com/inopinatus/linefeed
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ homepage_uri: https://github.com/inopinatus/linefeed
81
+ source_code_uri: https://github.com/inopinatus/linefeed
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '3.4'
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ requirements: []
96
+ rubygems_version: 4.0.3
97
+ specification_version: 4
98
+ summary: Yield lines from arbitrarily chunked byte streams
99
+ test_files: []