not_a_pipe 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.
Files changed (4) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +88 -0
  3. data/lib/not_a_pipe.rb +94 -0
  4. metadata +114 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7bce76fdfb113226a7d9df588a1b8f74cef8be5bac312cec5c0207645e3ff9a1
4
+ data.tar.gz: 2814f87bc83c48f10e035b9a372706834aa4480278bd1ec31efe1987bfa2759b
5
+ SHA512:
6
+ metadata.gz: 909b726c84f8f5711f515a28522e81f842e8d5bf38c2d1e402a015109c8170b013766ea71d725c2b1eff183e250123042fc78ef97d717007b548fca01fc6e8e8
7
+ data.tar.gz: 5ad91cdb551c346c6929e3ebc36b45d7b6e4e87c349b18ad14dba135d8a885f6bba63dfe10c84a63c00f4364a7e3d567c34204f7b6ae4d270ecf8b268b93cb40
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ <p align="center">
2
+ <a href="https://en.wikipedia.org/wiki/The_Treachery_of_Images"><img src="https://github.com/zverok/not_a_pipe/blob/main/img/une_pipe.jpg?raw=true"/></a>
3
+ </p>
4
+
5
+ # This is not a pipe
6
+
7
+ This is an experimental/demo Ruby implementation of Elixir-style pipes. It allows to write code like this:
8
+
9
+ ```ruby
10
+ require 'not_a_pipe'
11
+
12
+ extend NotAPipe
13
+
14
+ pipe def repos(username)
15
+ username >>
16
+ "https://api.github.com/users/#{_}/repos" >>
17
+ URI.open >>
18
+ _.read >>
19
+ JSON.parse(symbolize_names: true) >>
20
+ _.map { _1.dig(:full_name) }.first(10) >>
21
+ pp
22
+ end
23
+ ```
24
+
25
+ Basically:
26
+ * `not_a_pipe` is a decorator to mark methods inside which `>>` works as “pipe operator”;
27
+ * every step can reference `_` which would be a result of the previous step;
28
+ * but it also can omit the reference and just specify a method to call; the result of the previous step would be substituted as the _first argument_ of the method.
29
+
30
+ `not_a_pipe` works by _rewriting the AST_ and reevaluating the (rewritten) method code at the definition time and has no runtime penalty; thus achieving something akin to macros.
31
+
32
+ **It is not intended to use in production codebase**, but rather as an approach investigation/demonstration.
33
+
34
+ Inspired by a [Python’s library](https://github.com/Jordan-Kowal/pipe-operator?tab=readme-ov-file#-elixir-like-implementation) that uses the similar approach, and a [recent discussion](https://bugs.ruby-lang.org/issues/20770#note-34) in Ruby’s bug-tracker.
35
+
36
+ See also an [explanatory blog-post](https://zverok.space/blog/2024-11-16-elixir-pipes.html).
37
+
38
+ ## Usage
39
+
40
+ Don’t. Really. The code is really naive, tested only for simple cases, and is not intended as a library that will be relied upon. It is an experiment.
41
+
42
+ But if you want to play, you can install it as a gem:
43
+
44
+ ```bash
45
+ gem install not_a_pipe
46
+ ```
47
+
48
+ ...and then follow the example above.
49
+
50
+ ## Benchmarks
51
+
52
+ See `benchmark.rb`. Compared versions are:
53
+ * “naive” Ruby code which puts values into intermediate variables;
54
+ * `.then`-based Ruby version that chains everything in one statement;
55
+ * `not_a_pipe` version
56
+ * [pipe_envy](https://github.com/hopsoft/pipe_envy)-based solution, which is pretty simple (allows to join callable objects with `>>`)
57
+ * [pipe_operator](https://github.com/LendingHome/pipe_operator)-based solution, which is impressively witty looking but requires an extensive implementation with “proxy objects”
58
+
59
+ ```
60
+ ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [x86_64-linux]
61
+ Warming up --------------------------------------
62
+ naive 3.000 i/100ms
63
+ .then 3.000 i/100ms
64
+ not_a_pipe 4.000 i/100ms
65
+ pipe_operator 1.000 i/100ms
66
+ pipe_envy 1.000 i/100ms
67
+ Calculating -------------------------------------
68
+ naive 18.488 (± 5.4%) i/s (54.09 ms/i) - 93.000 in 5.067112s
69
+ .then 15.622 (± 6.4%) i/s (64.01 ms/i) - 78.000 in 5.019804s
70
+ not_a_pipe 18.140 (± 5.5%) i/s (55.13 ms/i) - 92.000 in 5.083882s
71
+ pipe_operator 1.520 (± 0.0%) i/s (657.81 ms/i) - 8.000 in 5.266537s
72
+ pipe_envy 7.296 (±13.7%) i/s (137.06 ms/i) - 37.000 in 5.098091s
73
+
74
+ Comparison:
75
+ naive: 18.5 i/s
76
+ not_a_pipe: 18.1 i/s - same-ish: difference falls within error
77
+ .then: 15.6 i/s - 1.18x slower
78
+ pipe_envy: 7.3 i/s - 2.53x slower
79
+ pipe_operator: 1.5 i/s - 12.16x slower
80
+ ```
81
+
82
+ Note that `not_a_pipe` is the _fastest_ version, on par only with “naive” verbose Ruby code with intermediate variables, and without `.then`-chaining (truth be told, on various runs `.then`-based version is frequently “sam-ish”). The rewrite-on-load approach is a rare way to introduce a DSL without _any_ performance penalty.
83
+
84
+ ## Credits
85
+
86
+ * Implementation: [Victor Shepelev aka zverok](https://zverok.space)
87
+ * The syntax is proposed by Alexandre Magro [on Ruby bug-tracker](https://bugs.ruby-lang.org/issues/20770#note-34)
88
+ * The AST-rewriting approach is inspired by Python’s [pipe_operator](https://github.com/Jordan-Kowal/pipe-operator) library
data/lib/not_a_pipe.rb ADDED
@@ -0,0 +1,94 @@
1
+ require 'parser/current'
2
+ require 'unparser'
3
+ require 'method_source'
4
+
5
+ module NotAPipe
6
+ # Usage:
7
+ #
8
+ # pipe def my_method
9
+ # value >> Some.method >> _.some_call >> [_, anything]
10
+ # end
11
+ #
12
+ # Will rewrite and reevaluate method code, replacing it with code like:
13
+ #
14
+ # def my_method
15
+ # _ = value
16
+ # _ = Some.method(_)
17
+ # _ = _.some_call
18
+ # _ = [_, anything]
19
+ # end
20
+ def pipe(name)
21
+ meth = is_a?(Module) ? instance_method(name) : method(name)
22
+ # Replacement is because method_source will read `pipe def foo; ...` from source including
23
+ # pipe decorator.
24
+ src = meth.source.sub(/\bpipe\s*?/, '')
25
+ src = NotAPipe.rewrite(src)
26
+
27
+ if is_a?(Module)
28
+ module_eval src
29
+ else
30
+ instance_eval src
31
+ end
32
+ end
33
+
34
+ class << self
35
+ def rewrite(code)
36
+ node = Unparser.parse(code)
37
+ node = rewrite_node(node)
38
+ # Without predefined `_` local var, parser will consider it a method. Forcibly replace all `_()` with `_`
39
+ Unparser.unparse(node).gsub(/\b_\(\)/, '_')
40
+ end
41
+
42
+ private
43
+
44
+ def rewrite_node(node)
45
+ case node
46
+ in [:send, left, :>>, right]
47
+ # foo >> bar >> baz is parsed as:
48
+ # left = foo >> bar
49
+ # right = baz
50
+ # `flatten_pipes` will make them into
51
+ # [foo, bar, baz]
52
+ # `rewrite_step` will substitute `Some.method(some, args)` with `Some.method(_, some, args)`, unless
53
+ # `_` is already a part of the expression
54
+ steps = [*flatten_pipes(left), right].then { |first, *rest| [first, *rest.map { rewrite_step(_1) }] }
55
+ # Now turn every foo, bar, baz into `_ = foo`, `_ = bar`, `_ = baz`
56
+ s(:begin, *steps.map { s(:lvasgn, :_, _1) })
57
+ in Parser::AST::Node
58
+ s(node.type, *node.children.map { rewrite_node(_1) })
59
+ else
60
+ node
61
+ end
62
+ end
63
+
64
+ def flatten_pipes(node)
65
+ case node
66
+ in [:send, left, :>>, right]
67
+ [*flatten_pipes(left), right]
68
+ else
69
+ [node]
70
+ end
71
+ end
72
+
73
+ def rewrite_step(node)
74
+ # This check is probably too naive, but this is a demo anyway.
75
+ # Basically, if the step of a "pipe" includes _any_ usage of a standalone `_`, we consider
76
+ # it doesn't need any rewriting.
77
+ return node if node.loc.expression.source.match?(/\b_\b/)
78
+
79
+ # Otherwise, we rewrite it
80
+ case node
81
+ in [:send, receiver, sym, *args]
82
+ # ...by changing every `foo` to `foo(_)`, any `bar(1, 2, 3)` to `bar(_, 1, 2, 3)` etc
83
+ s(:send, receiver, sym, s(:lvar, :_), *args)
84
+ else
85
+ # ...and that's it so far!
86
+ raise ArgumentError, "Unrewriteable step: #{node}"
87
+ end
88
+ end
89
+
90
+ def s(type, *children)
91
+ Parser::AST::Node.new(type, children)
92
+ end
93
+ end
94
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: not_a_pipe
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Victor Shepelev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-11-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: parser
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: unparser
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: method_source
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubygems-tasks
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: " Experimental/demo library. Not to be used in production.\n"
84
+ email: zverok.offline@gmail.com
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - README.md
90
+ - lib/not_a_pipe.rb
91
+ homepage: https://github.com/zverok/not_a_pipe
92
+ licenses:
93
+ - MIT
94
+ metadata: {}
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: 3.0.0
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubygems_version: 3.4.10
111
+ signing_key:
112
+ specification_version: 4
113
+ summary: Elixir-style pipes in Ruby (yes, again)
114
+ test_files: []