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.
- checksums.yaml +7 -0
- data/README.md +88 -0
- data/lib/not_a_pipe.rb +94 -0
- 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: []
|