not_a_pipe 0.0.1

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