not_a_pipe 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|