candor 0.1.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 +7 -0
- data/.yardopts +6 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +196 -0
- data/candor.gemspec +29 -0
- data/lib/candor/definer.rb +134 -0
- data/lib/candor/signature.rb +332 -0
- data/lib/candor/version.rb +6 -0
- data/lib/candor.rb +64 -0
- data/sig/candor/signature.rbs +22 -0
- data/sig/candor.rbs +28 -0
- metadata +58 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c57856ee0508cf6514f57073f0b68462e9cd8fc6f14c22bebe338dd3161558a3
|
|
4
|
+
data.tar.gz: f121e2ff9a2ca75a812f2e24991e10332ce5a81765f842c22572672928d3f32f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cdd277bdb4c503f974ead0e2be03da69361ceee33b586eefd187a761d03654b0084bc42a069d016578d174aa91cbf767f18a5885bdb4a7602a628b5c83f5d4af
|
|
7
|
+
data.tar.gz: e5275d7788db55cb88d258f391c45d10a4807b44616c332ace2f3a28a51a975357632a481d3974406147cd85a39a557145b377253033e23456a178eb5c2c8431
|
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-07-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `Candor.define(target, name, aliases:, via:, parameters:, body:)` — fabricates a real method whose arity, `parameters` kinds and `source_location` are the body's. Bodies may be a block, a `Proc`, a `Method` or an `UnboundMethod`; a `#call` object or any body with a `nil` `source_location` (a curried proc, a C-defined method) raises `TypeError`. With `via:` every call routes to that interceptor, which reaches the body through `Candor.body_name`; without it the wrapper forwards straight to the body. A wrong-arity call or an unknown keyword raises `ArgumentError` at the wrapper, before the interceptor or the body runs. An unpassed optional is dropped from the forwarded arguments, so the body applies its own default.
|
|
11
|
+
- `Candor::Signature` — the low-level compiler, public on its own: a parameter shape, a call-site name and a `source_location` in, a dispatch lambda out. Renders off a parameter's kind, never its name, so `end:`, `it`, `_1`, `**nil` and destructuring parameters all wrap at full fidelity. Both call-site names are validated against a strict method-name pattern, and a malformed `parameters` shape raises `ArgumentError` rather than a `SyntaxError` from inside `eval` — including a shape whose entries are each legal but whose combination Ruby's parser rejects, which is compiled before the target is mutated so a rejected fabrication cannot destroy the method it was replacing.
|
|
12
|
+
- Dispatch allocates nothing for shapes up to `Candor::Signature::KEYWORD_BRANCH_LIMIT` (2) optional keywords; beyond it, exactly one Hash per call. Past the limit a body's keyrest *is* that Hash, so a `**kw` in the signature costs only the capture and re-splat Ruby charges any wrapper. Exact from Ruby 3.4: on earlier Rubies the VM charges a `define_method`-created method for arguments crossing into it, and both the wrapper and the body are ones. A call carrying no keyword allocates nothing on every supported Ruby.
|
|
13
|
+
- Fabrication is thread-safe through one global `Monitor`; call-time dispatch takes no lock and never needs one. Re-fabricating a name overwrites the wrapper and the body in place, so it is warning-free under `ruby -w`, leaves no orphaned body, and a concurrent caller sees the old method or the new one, never a `NoMethodError`. A frozen target, an unbindable `Method`/`UnboundMethod` body, a name under the reserved `Candor::BODY_PREFIX`, an uncallable `via:` and a malformed `parameters:` all raise before the target is touched.
|
|
14
|
+
- `sig/` ships RBS for the public API: `Candor.define`, `Candor.body_name`, `BODY_PREFIX`, and `Candor::Signature` with `KINDS` and `KEYWORD_BRANCH_LIMIT`. Everything else is `private_constant`, which RBS cannot express and therefore does not declare.
|
|
15
|
+
|
|
16
|
+
[0.1.0]: https://github.com/svyatov/candor/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leonid Svyatov
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# Candor
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/candor)
|
|
4
|
+
[](https://github.com/svyatov/candor/actions/workflows/main.yml)
|
|
5
|
+
[](https://codecov.io/gh/svyatov/candor)
|
|
6
|
+
|
|
7
|
+
**Ruby's missing `functools.wraps`.** Turn a block or a callable into a real method that reports the
|
|
8
|
+
*body's* arity, the *body's* `parameters` and the *body's* `source_location` — and rejects a bad call
|
|
9
|
+
before anything of yours runs.
|
|
10
|
+
|
|
11
|
+
Zero runtime dependencies. Ruby >= 3.2.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
Candor.define(Greeter, :greet) { |name, greeting: "hi"| "#{greeting}, #{name}" }
|
|
15
|
+
|
|
16
|
+
Greeter.instance_method(:greet).arity # => -2
|
|
17
|
+
Greeter.instance_method(:greet).parameters # => [[:req, :__p0], [:key, :greeting]]
|
|
18
|
+
Greeter.instance_method(:greet).source_location # => ["app/greeter.rb", 3] ← the block, not the gem
|
|
19
|
+
|
|
20
|
+
Greeter.new.greet # => ArgumentError: wrong number of arguments (given 0, expected 1)
|
|
21
|
+
Greeter.new.greet("bob", grating: "yo") # => ArgumentError: unknown keyword: :grating
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## The problem
|
|
25
|
+
|
|
26
|
+
Ruby gives you no way to hand a generated wrapper the signature of the callable it wraps.
|
|
27
|
+
`define_method` accepts only a `Proc`, a `Method` or an `UnboundMethod`, so a wrapper's arity has to
|
|
28
|
+
come from an object that already has it — and the only source of such an object is a literal parameter
|
|
29
|
+
list, hand-typed or generated as text.
|
|
30
|
+
|
|
31
|
+
So libraries that wrap user-supplied callables retreat to `|*args, **kwargs, &block|`. The results are
|
|
32
|
+
right; the reflection lies. `arity` is `-1`, `parameters` reads `[[:rest], [:keyrest], [:block]]`, a
|
|
33
|
+
wrong-arity call blows up one frame too deep — inside the wrapper, where the library's own error
|
|
34
|
+
handling can swallow it — and every call allocates an Array and a Hash.
|
|
35
|
+
|
|
36
|
+
Candor generates the parameter list as source and `eval`s it, once, at definition time. There is no
|
|
37
|
+
degraded fallback: every shape Ruby can express wraps at full fidelity, including `end:`, `it`, `_1`,
|
|
38
|
+
`**nil`, and destructuring parameters.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
gem "candor"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
### Direct forward
|
|
49
|
+
|
|
50
|
+
With no interceptor, the wrapper forwards straight to the body. An unpassed optional is **dropped** from
|
|
51
|
+
the forwarded arguments, so the body applies its own default.
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
Candor.define(Formatter, :pad) { |string, width = 8| string.ljust(width) }
|
|
55
|
+
|
|
56
|
+
Formatter.new.pad("x") # => "x " the body's own default
|
|
57
|
+
Formatter.new.pad("x", 3) # => "x "
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Through an interceptor
|
|
61
|
+
|
|
62
|
+
`via:` names a method on the target, resolved per call. Every call routes to it and to it alone. It
|
|
63
|
+
receives the canonical name and the arguments exactly as passed, and reaches the body through
|
|
64
|
+
`Candor.body_name`, so it can also choose *not* to run it — memoize, instrument, short-circuit.
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class Memo
|
|
68
|
+
def initialize = @cache = {}
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def __call(name, ...)
|
|
73
|
+
@cache[name] ||= send(Candor.body_name(name), ...)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
Candor.define(Memo, :catalog, parameters: [], via: :__call) { Catalog.build }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A wrong-arity call raises at the wrapper, before `__call` is ever entered — so an interceptor that
|
|
81
|
+
rescues broadly never sees a caller's mistake as a body's failure. An alias left behind by an *earlier*
|
|
82
|
+
fabrication is the exception: it keeps the wrapper it was built with, so its gate is that fabrication's
|
|
83
|
+
signature and not the current body's. See [Re-fabrication](#the-contract).
|
|
84
|
+
|
|
85
|
+
### Aliases and shape overrides
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Candor.define(App, :configuration, aliases: %i[config c], via: :__call) { |scope| ... }
|
|
89
|
+
Candor.define(App, :catalog, parameters: []) { |page = 1| ... } # advertise zero parameters
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`aliases:` share one dispatch, and the interceptor always receives the *canonical* name.
|
|
93
|
+
`parameters:` overrides what the wrapper advertises; it is **not** validated against the body, so a
|
|
94
|
+
mismatch surfaces as the body's own `ArgumentError`.
|
|
95
|
+
|
|
96
|
+
### The compiler on its own
|
|
97
|
+
|
|
98
|
+
`Candor::Signature` is public for consumers that install methods themselves:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
dispatch = Candor::Signature.compile(parameters, name: :greet, via: :__call, source_location: loc)
|
|
102
|
+
klass.define_method(:greet, &dispatch)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## The contract
|
|
106
|
+
|
|
107
|
+
**Body kinds.** A block, a `Proc`, a `Method` or an `UnboundMethod` — the three things `define_method`
|
|
108
|
+
takes. A `#call` object is rejected. So is any body whose `source_location` is `nil`: a curried proc, a
|
|
109
|
+
`Symbol#to_proc`, a C-defined method. Ruby exposes no `curried?` predicate, and a nil `source_location`
|
|
110
|
+
is the one reliable discriminator — without it the honest-`source_location` guarantee cannot be kept, and
|
|
111
|
+
that guarantee is the product. Inside a block body, `self` is the receiver.
|
|
112
|
+
|
|
113
|
+
**Reserved prefix.** Compiled bodies are private methods named `#{Candor::BODY_PREFIX}#{name}`.
|
|
114
|
+
Fabricating a name that starts with the prefix raises `ArgumentError`.
|
|
115
|
+
|
|
116
|
+
**Allocation.** Dispatch allocates **nothing** for every shape up to two optional keywords. Ruby fills
|
|
117
|
+
optional positionals left to right, so `n` of them have `n + 1` states, enumerated as call sites rather
|
|
118
|
+
than accumulated into an Array. Optional keywords are independent, so `n` of them would cost `2**n` call
|
|
119
|
+
sites; past `Candor::Signature::KEYWORD_BRANCH_LIMIT` (2) they go through one Hash instead, and
|
|
120
|
+
dispatch allocates exactly one Hash per call. That shape — three or more optional keywords — is the one
|
|
121
|
+
case where candor is slower than the variadic wrapper it replaces. It is measured, and accepted.
|
|
122
|
+
|
|
123
|
+
Those counts are what the *wrapper* adds. A body that declares a **keyrest** (`**kw`) costs Ruby one Hash
|
|
124
|
+
to capture it and one to re-splat it into the body, on any wrapper you could write by hand; candor adds
|
|
125
|
+
none of its own, because past the branch limit the captured keyrest *is* the Hash the keywords accumulate
|
|
126
|
+
into.
|
|
127
|
+
|
|
128
|
+
Exactly as stated from **Ruby 3.4**. Below that, Ruby itself charges a `define_method`-created method for
|
|
129
|
+
arguments crossing into it, and both the wrapper and the body are ones: on 3.3 the hashed path costs one
|
|
130
|
+
Hash more, and on 3.2 a keyword-carrying call costs one Hash per hop. Nothing in the gem can reach that,
|
|
131
|
+
and no call that carries no keyword ever allocates, on any supported Ruby.
|
|
132
|
+
|
|
133
|
+
**Thread safety.** Fabrication takes one global `Monitor`; concurrent `Candor.define` calls
|
|
134
|
+
serialize. Call-time dispatch takes no lock at all, and never needs one: re-fabrication replaces a
|
|
135
|
+
method in place rather than removing and reinstalling it, so a caller racing a `Candor.define` gets
|
|
136
|
+
the old method or the new one, never a `NoMethodError`.
|
|
137
|
+
|
|
138
|
+
**Failing fast.** A frozen target raises `FrozenError`, an `UnboundMethod` whose owner is not an
|
|
139
|
+
ancestor of the target raises `TypeError`, and a malformed `parameters:` or an uncallable `via:` raises
|
|
140
|
+
`ArgumentError` — all *before* the target is touched. A rejected fabrication leaves the target exactly
|
|
141
|
+
as it was. A hand-written `parameters:` never passed Ruby's parser, so it is compiled before the first
|
|
142
|
+
mutation: a shape whose *combination* is illegal — two rests, a duplicate keyword — is an `ArgumentError`
|
|
143
|
+
too. Nothing ever surfaces as a `SyntaxError` from inside `eval`, which a `rescue` would not catch.
|
|
144
|
+
|
|
145
|
+
**A Ruby 3.4 wrinkle.** On 3.4 alone, a body written with the implicit `it` parameter receives its
|
|
146
|
+
arguments packed into an Array when it is reached through `(name, ...)` forwarding, `send`, or a splat —
|
|
147
|
+
the shape an interceptor is usually written in. Candor's own generated call sites name every
|
|
148
|
+
argument, so direct-forward mode is unaffected; fixed in Ruby 4.0, and `_1` and named parameters never
|
|
149
|
+
had it.
|
|
150
|
+
|
|
151
|
+
**Re-fabrication.** Redefining a name overwrites candor's own prior wrapper and body in place, so a
|
|
152
|
+
second `Candor.define` is warning-free under `ruby -w` and orphans nothing. It rewrites only the
|
|
153
|
+
names passed to the *current* call: an alias installed by an earlier fabrication stays installed and
|
|
154
|
+
rebinds to the replacement body while still advertising its original signature. Last write wins. A
|
|
155
|
+
hand-written method of the same name is replaced cleanly; an inherited one is shadowed, not removed.
|
|
156
|
+
|
|
157
|
+
## Benchmarks
|
|
158
|
+
|
|
159
|
+
`bundle exec rake bench`, on ruby 4.0.5 (arm64-darwin23). Nanoseconds per call, lower is better; the
|
|
160
|
+
optional-argument rows carry a few percent of noise.
|
|
161
|
+
|
|
162
|
+
| shape | candor | hand-written wrapper | bare method | variadic wrapper |
|
|
163
|
+
|---|---|---|---|---|
|
|
164
|
+
| no arguments | 52 ns · 0 alloc | 47 ns · 0 | 27 ns · 0 | 122 ns · 2 |
|
|
165
|
+
| required + optional, omitted | 107 ns · 0 alloc | 82 ns · 0 | 43 ns · 0 | 140 ns · 2 |
|
|
166
|
+
| required + optional, passed | 81 ns · 0 alloc | 63 ns · 0 | 36 ns · 0 | 149 ns · 2 |
|
|
167
|
+
| two optional keywords | 112 ns · 0 alloc | 79 ns · 0 | 46 ns · 0 | 170 ns · 2 |
|
|
168
|
+
| three optional keywords | 251 ns · 1 alloc | 99 ns · 0 | 46 ns · 0 | 185 ns · 2 |
|
|
169
|
+
|
|
170
|
+
- **hand-written wrapper** is the ceiling: a real `def` with the same signature forwarding to the same
|
|
171
|
+
private body, with the defaults hard-coded — what you would write by hand if you knew the shape *and*
|
|
172
|
+
the default expressions. Candor is within ~10–40% of it, and allocates the same nothing.
|
|
173
|
+
- **bare method** is a single `def` doing the work inline. It is roughly twice as fast as any wrapper,
|
|
174
|
+
because it is one method call rather than two. That is the price of wrapping at all, not of candor.
|
|
175
|
+
- **variadic wrapper** is the `|*args, **kwargs, &block|` retreat: slower than candor on every shape
|
|
176
|
+
below the keyword branch limit, faster on the hashed one, and dishonest about its signature everywhere.
|
|
177
|
+
|
|
178
|
+
Definition time is ~40 µs per fabricated method — one `eval` — against ~1.3 µs for a bare
|
|
179
|
+
`define_method`. It runs once, at boot.
|
|
180
|
+
|
|
181
|
+
## What it is not
|
|
182
|
+
|
|
183
|
+
Candor routes calls. It does not rescue, memoize, delegate or instrument; it is the method-fabrication
|
|
184
|
+
layer those features stand on. It does not recover an optional's default expression — that is
|
|
185
|
+
[permanently closed upstream](https://bugs.ruby-lang.org/issues/8629), and dropping the unpassed optional
|
|
186
|
+
so the body defaults is the semantics that replaces it. Fabricated methods are not Ractor-shareable, and
|
|
187
|
+
the gem needs `eval`, so it does not run on eval-restricted platforms.
|
|
188
|
+
|
|
189
|
+
## Contributing
|
|
190
|
+
|
|
191
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/svyatov/candor). See
|
|
192
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/candor.gemspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/candor/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "candor"
|
|
7
|
+
spec.version = Candor::VERSION
|
|
8
|
+
spec.authors = ["Leonid Svyatov"]
|
|
9
|
+
spec.email = ["leonid@svyatov.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Turn a block or a callable into a real method with an honest signature."
|
|
12
|
+
spec.description = "Ruby's missing functools.wraps. Candor fabricates real methods from blocks and " \
|
|
13
|
+
"callables: same arity, same parameters, source_location pointing at your code, and " \
|
|
14
|
+
"allocation-free dispatch. Zero runtime dependencies."
|
|
15
|
+
spec.homepage = "https://github.com/svyatov/candor"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
|
|
18
|
+
spec.required_ruby_version = ">= 3.2.0"
|
|
19
|
+
|
|
20
|
+
spec.require_paths = ["lib"]
|
|
21
|
+
spec.files = Dir["lib/**/*.rb"] + Dir["sig/**/*"] +
|
|
22
|
+
%w[.yardopts CHANGELOG.md LICENSE.txt README.md candor.gemspec]
|
|
23
|
+
|
|
24
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
25
|
+
spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/candor"
|
|
26
|
+
spec.metadata["source_code_uri"] = "https://github.com/svyatov/candor"
|
|
27
|
+
spec.metadata["changelog_uri"] = "https://github.com/svyatov/candor/blob/main/CHANGELOG.md"
|
|
28
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/svyatov/candor/issues"
|
|
29
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Candor
|
|
4
|
+
# Fabricates a real method from a block or a callable: compile the body strictly, read its true
|
|
5
|
+
# parameter shape, render a signature-identical wrapper, install it.
|
|
6
|
+
#
|
|
7
|
+
# The pipeline is an order, not a sequence of conveniences. Every check runs before the first
|
|
8
|
+
# mutation, so a rejected fabrication leaves the target exactly as it was — a frozen target, an
|
|
9
|
+
# unbindable +Method+, a name under the reserved prefix and a malformed shape all raise before the
|
|
10
|
+
# prior wrapper is replaced. The parameter shape is then read from the *compiled* method, never from
|
|
11
|
+
# the Proc, which reports every positional as +:opt+ and would silently destroy arity strictness.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
class Definer
|
|
15
|
+
# The only bodies +define_method+ accepts. A +#call+ object is not one of them, and converting it
|
|
16
|
+
# would forge a +source_location+ nobody wrote.
|
|
17
|
+
BODY_KINDS = [Proc, Method, UnboundMethod].freeze
|
|
18
|
+
|
|
19
|
+
# @param target [Module] the module the method is installed onto
|
|
20
|
+
# @param name [Symbol] the canonical name
|
|
21
|
+
# @param aliases [Array<Symbol>] further names sharing the one dispatch
|
|
22
|
+
# @param via [Symbol, nil] an interceptor method on +target+, resolved per call
|
|
23
|
+
# @param parameters [Array<Array>, nil] an explicit shape, overriding the body's
|
|
24
|
+
# @param body [Proc, Method, UnboundMethod]
|
|
25
|
+
def initialize(target, name, aliases:, via:, parameters:, body:)
|
|
26
|
+
@target = target
|
|
27
|
+
@canonical = name.to_sym
|
|
28
|
+
@names = [@canonical, *aliases.map(&:to_sym)].uniq
|
|
29
|
+
@via = via
|
|
30
|
+
@parameters = parameters
|
|
31
|
+
@body = body
|
|
32
|
+
@body_name = Candor.body_name(@canonical)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Symbol] the canonical name
|
|
36
|
+
def call
|
|
37
|
+
validate!
|
|
38
|
+
# An overridden shape never passed Ruby's parser, so it is compiled before the first mutation: a
|
|
39
|
+
# combination {Signature.parameters!} cannot see — two rests, a duplicate keyword — must not fail once
|
|
40
|
+
# the body is already installed. A shape read from the compiled body always renders.
|
|
41
|
+
dispatch = compile(@parameters) if @parameters
|
|
42
|
+
# Definition is a boot-time operation, so one global lock is the whole answer to concurrency;
|
|
43
|
+
# the dispatch it installs takes no lock at all.
|
|
44
|
+
MONITOR.synchronize do
|
|
45
|
+
install_body
|
|
46
|
+
install(dispatch || compile(@target.instance_method(@body_name).parameters))
|
|
47
|
+
end
|
|
48
|
+
@canonical
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# @return [void]
|
|
54
|
+
# @raise [TypeError, ArgumentError, FrozenError]
|
|
55
|
+
def validate!
|
|
56
|
+
raise TypeError, "target must be a Module, got #{@target.inspect}" unless @target.is_a?(Module)
|
|
57
|
+
|
|
58
|
+
validate_body!
|
|
59
|
+
validate_names!
|
|
60
|
+
# Both call-site names are interpolated into `eval`'d source. Without `via` the body's own name is
|
|
61
|
+
# the call site, so the canonical name has to survive being one.
|
|
62
|
+
Signature.method_name!(@via || @body_name)
|
|
63
|
+
Signature.parameters!(@parameters) if @parameters
|
|
64
|
+
raise FrozenError.new("can't fabricate a method on frozen #{@target}", receiver: @target) if @target.frozen?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# +source_location+ is the one reliable discriminator: Ruby exposes no +curried?+ predicate, and a
|
|
68
|
+
# curried proc, a symbol-to-proc and a C-defined method all report +nil+. R5 cannot be honoured for
|
|
69
|
+
# any of them, and honouring R5 is the product.
|
|
70
|
+
#
|
|
71
|
+
# @return [void]
|
|
72
|
+
# @raise [TypeError]
|
|
73
|
+
def validate_body!
|
|
74
|
+
raise TypeError, body_error unless BODY_KINDS.any? { |kind| @body.is_a?(kind) }
|
|
75
|
+
raise TypeError, body_error if @body.source_location.nil?
|
|
76
|
+
return unless @body.is_a?(Method) || @body.is_a?(UnboundMethod)
|
|
77
|
+
# `define_method` would raise this itself — after the prior wrapper was already removed.
|
|
78
|
+
raise TypeError, "#{@body.owner} is not an ancestor of #{@target}" unless @target <= @body.owner
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [void]
|
|
82
|
+
# @raise [ArgumentError]
|
|
83
|
+
def validate_names!
|
|
84
|
+
offender = @names.find { |name| name.to_s.start_with?(BODY_PREFIX) }
|
|
85
|
+
return unless offender
|
|
86
|
+
|
|
87
|
+
raise ArgumentError, "#{offender.inspect} starts with the reserved prefix #{BODY_PREFIX.inspect}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [String]
|
|
91
|
+
def body_error
|
|
92
|
+
"body must be a block, a Proc, a Method or an UnboundMethod with a source_location, " \
|
|
93
|
+
"got #{@body.inspect}; curried procs, `#call` objects and C-defined methods have none"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# +define_method+ replaces a method in place, so a re-fabrication is never observable as a missing
|
|
97
|
+
# one. Calling +remove_method+ first — the obvious way to reinstall a name — leaves it undefined for
|
|
98
|
+
# as long as the install takes, and a concurrent caller sees +NoMethodError+. Removal only ever bought
|
|
99
|
+
# silence from the +-w+ redefinition warning, so buy that here instead.
|
|
100
|
+
#
|
|
101
|
+
# +$VERBOSE+ is process-global, and that is the ceiling: {MONITOR} serializes fabricators, so two of
|
|
102
|
+
# them cannot interleave these assignments, but a thread warning about something else while one holds
|
|
103
|
+
# the lock loses that warning. An install is microseconds, and this runs at boot.
|
|
104
|
+
#
|
|
105
|
+
# @return [void]
|
|
106
|
+
def silently
|
|
107
|
+
verbose = $VERBOSE
|
|
108
|
+
$VERBOSE = nil
|
|
109
|
+
yield
|
|
110
|
+
ensure
|
|
111
|
+
$VERBOSE = verbose
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# An inherited method of the same name is shadowed by the install, never removed.
|
|
115
|
+
#
|
|
116
|
+
# @return [void]
|
|
117
|
+
def install_body
|
|
118
|
+
silently { @target.define_method(@body_name, @body) }
|
|
119
|
+
@target.send(:private, @body_name)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @param shape [Array<Array>]
|
|
123
|
+
# @return [Proc] the dispatch lambda
|
|
124
|
+
# @raise [ArgumentError] if the shape does not render to legal source
|
|
125
|
+
def compile(shape)
|
|
126
|
+
Signature.compile(shape, name: @via ? @canonical : @body_name, via: @via,
|
|
127
|
+
source_location: @body.source_location)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @param dispatch [Proc]
|
|
131
|
+
# @return [void]
|
|
132
|
+
def install(dispatch) = silently { @names.each { |name| @target.define_method(name, &dispatch) } }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Candor
|
|
4
|
+
# Compiles a method's +parameters+ into a dispatch lambda carrying the same parameter kinds, in the
|
|
5
|
+
# same order, and therefore the same +arity+.
|
|
6
|
+
#
|
|
7
|
+
# Rendering switches on a parameter's *kind*, never its name. +Method#parameters+ reports names that
|
|
8
|
+
# cannot be used as parameters (+:_1+), names that are reserved words (+:end+), and entries with no
|
|
9
|
+
# name at all — +[[:req]]+, for both +it+ and a destructuring parameter. A renderer that echoed names
|
|
10
|
+
# would die on most real shapes, and on +it+ it would fail *silently*, emitting arity 0. So every
|
|
11
|
+
# positional, rest, keyrest and block parameter gets a generated name; only keyword names survive,
|
|
12
|
+
# because a keyword name is the calling convention.
|
|
13
|
+
#
|
|
14
|
+
# Those surviving names are the only user input in the generated source, and Ruby lets a parameter
|
|
15
|
+
# shadow anything the source would otherwise reach. So the lambda closes over nothing it could lose:
|
|
16
|
+
# the canonical name is a Symbol literal, +binding()+ is called with parentheses a local cannot
|
|
17
|
+
# shadow, and every generated local is prefixed with a run of underscores no keyword name starts
|
|
18
|
+
# with. Only the sentinel survives as a free variable, and it is created by the rendered source
|
|
19
|
+
# itself, under that same collision-free prefix.
|
|
20
|
+
#
|
|
21
|
+
# The lambda is rendered on one line, so +eval+'s line forges its +source_location+ exactly.
|
|
22
|
+
#
|
|
23
|
+
# The call site it forwards to is one of two shapes. With +via+ it is +via(:name, args…)+, an
|
|
24
|
+
# interceptor that receives the canonical name; without, it is +name(args…)+, calling the body
|
|
25
|
+
# directly. Either way the target is a method name interpolated into +eval+'d source, so
|
|
26
|
+
# {method_name!} gates it first.
|
|
27
|
+
#
|
|
28
|
+
# Candor::Signature.compile([%i[req a]], name: :greet, via: :__call, source_location: loc)
|
|
29
|
+
# # => ->(__p0) { __call(:greet, __p0) }
|
|
30
|
+
class Signature
|
|
31
|
+
# Reserved words that are legal keyword parameter names but illegal as a bare reference:
|
|
32
|
+
# +__k[:end] = end+ is a +SyntaxError+.
|
|
33
|
+
RESERVED_WORDS = %w[
|
|
34
|
+
__ENCODING__ __FILE__ __LINE__ alias and begin break case class def do else elsif end ensure
|
|
35
|
+
false for if in module next nil not or redo rescue retry return self super then true undef
|
|
36
|
+
unless until when while yield
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# Kinds whose reported name is the calling convention and must be preserved.
|
|
40
|
+
NAMED_KINDS = %i[keyreq key].freeze
|
|
41
|
+
|
|
42
|
+
# Kinds that may have to be dropped from the forwarded arguments, so the body applies its own default.
|
|
43
|
+
OPTIONAL_KINDS = %i[opt key].freeze
|
|
44
|
+
|
|
45
|
+
# The generated local's name, after the collision-free prefix and before the parameter's index.
|
|
46
|
+
SUFFIXES = { req: "p", opt: "p", rest: "r", keyrest: "kr", block: "b" }.freeze
|
|
47
|
+
|
|
48
|
+
# How many optional keywords still dispatch through call sites rather than a Hash. Each one doubles
|
|
49
|
+
# the sites, so four is where a shape nobody writes stops paying for one everybody does.
|
|
50
|
+
KEYWORD_BRANCH_LIMIT = 2
|
|
51
|
+
|
|
52
|
+
# The lambda's parameter declaration, per kind: the name, then the sentinel. Filled with `sub`, not
|
|
53
|
+
# `%`: +:nokey+ takes neither, and `format` warns about the unused arguments under +-w+.
|
|
54
|
+
DECLARATIONS = {
|
|
55
|
+
req: "%s", opt: "%s = %s", rest: "*%s", keyreq: "%s:", key: "%s: %s",
|
|
56
|
+
keyrest: "**%s", nokey: "**nil", block: "&%s"
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
# Every kind +Method#parameters+ can emit, and the only ones {render} accepts.
|
|
60
|
+
KINDS = DECLARATIONS.keys.freeze
|
|
61
|
+
|
|
62
|
+
# A name the generated source may call with parentheses. Operators, spaces and everything else a
|
|
63
|
+
# +Symbol+ can hold are rejected before they reach +eval+.
|
|
64
|
+
METHOD_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*[?!]?\z/
|
|
65
|
+
|
|
66
|
+
# A name the generated source may declare as a keyword parameter. Reserved words qualify — the
|
|
67
|
+
# source reads them back through +binding()+ — but a numbered parameter does not.
|
|
68
|
+
KEYWORD_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
69
|
+
|
|
70
|
+
# Names Ruby reserves for the numbered block parameters; +->(_1: 1) {}+ is a +SyntaxError+.
|
|
71
|
+
NUMBERED_PARAMETERS = (1..9).map { |i| "_#{i}" }.freeze
|
|
72
|
+
|
|
73
|
+
# How the source is spelled, and what the two name gates happen to be spelled as. A consumer builds
|
|
74
|
+
# against {compile}, {method_name!} and {parameters!} — which raise — not against the patterns and
|
|
75
|
+
# tables they are written in terms of. Only {KINDS} and {KEYWORD_BRANCH_LIMIT} name something a
|
|
76
|
+
# caller has to know to use the compiler, so only those two stay public.
|
|
77
|
+
private_constant :RESERVED_WORDS, :NAMED_KINDS, :OPTIONAL_KINDS, :SUFFIXES, :DECLARATIONS,
|
|
78
|
+
:METHOD_NAME, :KEYWORD_NAME, :NUMBERED_PARAMETERS
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
# +eval+'s file and line forge the lambda's — and therefore the compiled method's —
|
|
82
|
+
# +source_location+ onto the body the caller supplied.
|
|
83
|
+
#
|
|
84
|
+
# @param parameters [Array<Array>] a *compiled* method's +parameters+; a Proc's would report every
|
|
85
|
+
# positional as +:opt+ and silently destroy arity strictness
|
|
86
|
+
# @param name [Symbol] the method to call, or — with +via+ — the canonical name handed to it
|
|
87
|
+
# @param source_location [Array(String, Integer)] the body's
|
|
88
|
+
# @param via [Symbol, nil] an interceptor method taking +(name, ...)+; omit to call +name+ directly
|
|
89
|
+
# @return [Proc] a lambda taking the body's parameter kinds and forwarding to the call site
|
|
90
|
+
# @raise [ArgumentError] if the call site's name or the parameter shape is malformed
|
|
91
|
+
def compile(parameters, name:, source_location:, via: nil)
|
|
92
|
+
eval(render(parameters, name: name, via: via), binding, *source_location) # rubocop:disable Security/Eval
|
|
93
|
+
rescue SyntaxError => e
|
|
94
|
+
# {parameters!} sees one entry at a time; only the parser sees the combination — two rests, a
|
|
95
|
+
# duplicate keyword, a block before a positional. A +SyntaxError+ is a +ScriptError+, which a
|
|
96
|
+
# consumer's +rescue+ never catches.
|
|
97
|
+
raise ArgumentError, "malformed parameters: #{parameters.inspect} (#{e.message.lines.first.strip})"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param parameters [Array<Array>]
|
|
101
|
+
# @param name [Symbol]
|
|
102
|
+
# @param via [Symbol, nil]
|
|
103
|
+
# @return [String] single-line source for a lambda forwarding to the call site
|
|
104
|
+
# @raise [ArgumentError] if the call site's name or the parameter shape is malformed
|
|
105
|
+
def render(parameters, name:, via: nil) = new(parameters, name, via).source
|
|
106
|
+
|
|
107
|
+
# The call site is interpolated into +eval+'d source, so its name is a trust boundary rather than
|
|
108
|
+
# a typo check.
|
|
109
|
+
#
|
|
110
|
+
# @param name [Symbol, String]
|
|
111
|
+
# @return [Symbol] the name
|
|
112
|
+
# @raise [ArgumentError] unless the name can be called with parentheses
|
|
113
|
+
def method_name!(name)
|
|
114
|
+
string = name.to_s
|
|
115
|
+
# +:class+ is callable on a receiver and useless here: the generated source calls the site bare,
|
|
116
|
+
# and a bare +class(…)+ is a +SyntaxError+. Say that, rather than "not a callable method name".
|
|
117
|
+
if RESERVED_WORDS.include?(string)
|
|
118
|
+
raise ArgumentError, "#{name.inspect} is a reserved word: the generated source would call it bare"
|
|
119
|
+
end
|
|
120
|
+
raise ArgumentError, "not a callable method name: #{name.inspect}" unless METHOD_NAME.match?(string)
|
|
121
|
+
|
|
122
|
+
string.to_sym
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A parameter shape reaching {render} from a consumer's override never passed Ruby's parser, so
|
|
126
|
+
# every kind and every keyword name is checked here rather than discovered as a +SyntaxError+.
|
|
127
|
+
#
|
|
128
|
+
# @param parameters [Array<Array>]
|
|
129
|
+
# @return [Array<Array>] the shape
|
|
130
|
+
# @raise [ArgumentError] unless every entry is a known kind, with a usable name where one is required
|
|
131
|
+
def parameters!(parameters)
|
|
132
|
+
raise ArgumentError, "malformed parameters: #{parameters.inspect}" unless parameters.is_a?(Array)
|
|
133
|
+
|
|
134
|
+
# +index+, not +find+: a +nil+ entry is malformed, and +find+ would hand back the same +nil+ it
|
|
135
|
+
# returns when every entry is fine. The caller passed the Array; naming the offender saves a bisect.
|
|
136
|
+
index = parameters.index { |entry| !valid_entry?(entry) }
|
|
137
|
+
raise ArgumentError, "malformed parameters: entry #{index} is #{parameters[index].inspect}" if index
|
|
138
|
+
|
|
139
|
+
parameters
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# @param entry [Object]
|
|
145
|
+
# @return [Boolean]
|
|
146
|
+
def valid_entry?(entry)
|
|
147
|
+
return false unless entry.is_a?(Array) && KINDS.include?(entry.first)
|
|
148
|
+
|
|
149
|
+
case entry.size
|
|
150
|
+
when 1 then !NAMED_KINDS.include?(entry.first)
|
|
151
|
+
when 2 then valid_name?(entry.first, entry[1])
|
|
152
|
+
else false
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A keyword name is the one piece of the shape the source carries verbatim; every other name is
|
|
157
|
+
# replaced by a generated one and so may be anything, or nothing.
|
|
158
|
+
#
|
|
159
|
+
# @param kind [Symbol]
|
|
160
|
+
# @param name [Object]
|
|
161
|
+
# @return [Boolean]
|
|
162
|
+
def valid_name?(kind, name)
|
|
163
|
+
return false unless name.is_a?(Symbol)
|
|
164
|
+
return true unless NAMED_KINDS.include?(kind)
|
|
165
|
+
|
|
166
|
+
string = name.to_s
|
|
167
|
+
KEYWORD_NAME.match?(string) && !NUMBERED_PARAMETERS.include?(string)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @param parameters [Array<Array>]
|
|
172
|
+
# @param name [Symbol]
|
|
173
|
+
# @param via [Symbol, nil]
|
|
174
|
+
def initialize(parameters, name, via = nil)
|
|
175
|
+
self.class.parameters!(parameters)
|
|
176
|
+
@target = self.class.method_name!(via || name)
|
|
177
|
+
# With an interceptor the canonical name leads the argument list, as a literal rather than a free
|
|
178
|
+
# variable a keyword could capture.
|
|
179
|
+
@literal = via ? name.to_sym.inspect : nil
|
|
180
|
+
@prefix = collision_free_prefix(parameters)
|
|
181
|
+
@unset = "#{@prefix}u"
|
|
182
|
+
@hash = "#{@prefix}k"
|
|
183
|
+
@entries = entries(parameters)
|
|
184
|
+
@optionals = @entries.select { |entry| OPTIONAL_KINDS.include?(entry.first) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The sentinel marking an unpassed optional is a local, not a constant: +Module#const_get+ pierces
|
|
188
|
+
# +private_constant+, so a caller could obtain the sentinel and pass it as an argument, silently
|
|
189
|
+
# defeating an optional's default.
|
|
190
|
+
#
|
|
191
|
+
# @return [String]
|
|
192
|
+
def source = "#{@unset} = ::Object.new.freeze; ->(#{declarations}) { #{dispatch} }"
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# @param parameters [Array<Array>]
|
|
197
|
+
# @return [Array<Array(Symbol, String)>] each kind paired with the local the lambda gives it
|
|
198
|
+
def entries(parameters)
|
|
199
|
+
parameters.each_with_index.map { |(kind, given), index| [kind, local(kind, given, index)] }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# No keyword name starts with the returned prefix, so no generated local can be shadowed by one.
|
|
203
|
+
#
|
|
204
|
+
# @param parameters [Array<Array>]
|
|
205
|
+
# @return [String]
|
|
206
|
+
def collision_free_prefix(parameters)
|
|
207
|
+
keywords = parameters.filter_map { |kind, given| given.to_s if NAMED_KINDS.include?(kind) }
|
|
208
|
+
prefix = "__"
|
|
209
|
+
prefix += "_" while keywords.any? { |keyword| keyword.start_with?(prefix) }
|
|
210
|
+
prefix
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @param kind [Symbol]
|
|
214
|
+
# @param given [Symbol, nil]
|
|
215
|
+
# @param index [Integer]
|
|
216
|
+
# @return [String]
|
|
217
|
+
def local(kind, given, index) = NAMED_KINDS.include?(kind) ? given.to_s : "#{@prefix}#{SUFFIXES[kind]}#{index}"
|
|
218
|
+
|
|
219
|
+
# @return [String] the lambda's parameter list
|
|
220
|
+
def declarations
|
|
221
|
+
@entries.map { |kind, name| DECLARATIONS[kind].sub("%s", name).sub("%s", @unset) }.join(", ")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# An optional's default expression is unrecoverable from +parameters+, so an unpassed optional is
|
|
225
|
+
# dropped from the forwarded arguments and the body applies its own default. Which ones were passed
|
|
226
|
+
# is a runtime fact, and {#branch} enumerates it as call sites rather than an Array: nothing is
|
|
227
|
+
# allocated, and every kind around the optionals forwards straight through — a rest as `*r`, a
|
|
228
|
+
# keyrest as `**kw`.
|
|
229
|
+
#
|
|
230
|
+
# Optional *keywords* are independently passed, so enumerating them costs +2**n+ call sites rather
|
|
231
|
+
# than +n + 1+. Past {KEYWORD_BRANCH_LIMIT} of them the source would balloon, so they go through a
|
|
232
|
+
# Hash instead and the branching sees only the positionals.
|
|
233
|
+
#
|
|
234
|
+
# @return [String] the lambda's body
|
|
235
|
+
def dispatch
|
|
236
|
+
keywords = @optionals.select { |entry| entry.first == :key }
|
|
237
|
+
hash = keywords.size > KEYWORD_BRANCH_LIMIT
|
|
238
|
+
statements = hash ? keyword_setup : []
|
|
239
|
+
statements << branch(hash ? @optionals - keywords : @optionals, [], hash)
|
|
240
|
+
statements.join("; ")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Ruby fills optional positionals left to right, so an unpassed one guarantees every optional
|
|
244
|
+
# positional after it went unpassed too. The states are a chain of +n + 1+ call sites, not the
|
|
245
|
+
# +2**n+ a subset would need.
|
|
246
|
+
#
|
|
247
|
+
# @param optionals [Array<Array(Symbol, String)>] those still to be decided
|
|
248
|
+
# @param dropped [Array<Array(Symbol, String)>] those already known unpassed
|
|
249
|
+
# @param hash [Boolean]
|
|
250
|
+
# @return [String] a call site, or a ternary choosing between two of them
|
|
251
|
+
def branch(optionals, dropped, hash)
|
|
252
|
+
return call(@entries - dropped, hash) if optionals.empty?
|
|
253
|
+
|
|
254
|
+
first, *rest = optionals
|
|
255
|
+
trailing = first.first == :opt ? rest.select { |entry| entry.first == :opt } : []
|
|
256
|
+
unpassed = branch(rest - trailing, dropped + [first] + trailing, hash)
|
|
257
|
+
"#{@unset}.equal?(#{value(first)}) ? #{unpassed} : #{branch(rest, dropped, hash)}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The target takes parentheses and, usually, an argument — neither of which a local of the same name
|
|
261
|
+
# can shadow.
|
|
262
|
+
#
|
|
263
|
+
# @param entries [Array<Array(Symbol, String)>]
|
|
264
|
+
# @param hash [Boolean]
|
|
265
|
+
# @return [String]
|
|
266
|
+
def call(entries, hash) = "#{@target}(#{[*@literal, *forward(entries, hash)].join(", ")})"
|
|
267
|
+
|
|
268
|
+
# @param entry [Array(Symbol, String)]
|
|
269
|
+
# @return [String] source reading that parameter's value
|
|
270
|
+
def value(entry) = entry.first == :key ? reference(entry.last) : entry.last
|
|
271
|
+
|
|
272
|
+
# A keyrest is the accumulator rather than a source to merge in: +**+ capture already allocated a Hash the
|
|
273
|
+
# lambda alone owns, and Ruby routes a declared keyword to its own parameter, never into the keyrest — so
|
|
274
|
+
# seeding from it and assigning after is what +{}+ plus +update+ was, one Hash cheaper.
|
|
275
|
+
#
|
|
276
|
+
# @return [Array<String>]
|
|
277
|
+
def keyword_setup
|
|
278
|
+
keyrest = @entries.find { |kind, _name| kind == :keyrest }
|
|
279
|
+
@entries.filter_map do |kind, name|
|
|
280
|
+
case kind
|
|
281
|
+
when :keyreq then "#{@hash}[:#{name}] = #{reference(name)}"
|
|
282
|
+
when :key then "#{@hash}[:#{name}] = #{reference(name)} unless #{@unset}.equal?(#{reference(name)})"
|
|
283
|
+
end
|
|
284
|
+
end.unshift("#{@hash} = #{keyrest ? keyrest.last : "{}"}")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# @param entries [Array<Array(Symbol, String)>]
|
|
288
|
+
# @param hash [Boolean]
|
|
289
|
+
# @return [Array<String>] the forwarded arguments
|
|
290
|
+
def forward(entries, hash)
|
|
291
|
+
args = positional_args(entries)
|
|
292
|
+
args.concat(hash ? ["**#{@hash}"] : keyword_args(entries))
|
|
293
|
+
args.concat(block_arg(entries))
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# An +:opt+ appears here only on a call site that passes it: {#dispatch} drops it from the entries
|
|
297
|
+
# of the call site that does not.
|
|
298
|
+
#
|
|
299
|
+
# @param entries [Array<Array(Symbol, String)>]
|
|
300
|
+
# @return [Array<String>] in declaration order, so a rest keeps its place among the required
|
|
301
|
+
def positional_args(entries)
|
|
302
|
+
entries.filter_map do |kind, name|
|
|
303
|
+
case kind
|
|
304
|
+
when :req, :opt then name
|
|
305
|
+
when :rest then "*#{name}"
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# @param entries [Array<Array(Symbol, String)>]
|
|
311
|
+
# @return [Array<String>]
|
|
312
|
+
def keyword_args(entries)
|
|
313
|
+
entries.filter_map do |kind, name|
|
|
314
|
+
case kind
|
|
315
|
+
when :keyreq, :key then "#{name}: #{reference(name)}"
|
|
316
|
+
when :keyrest then "**#{name}"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# @param entries [Array<Array(Symbol, String)>]
|
|
322
|
+
# @return [Array<String>] at most one element
|
|
323
|
+
def block_arg(entries) = entries.filter_map { |kind, name| "&#{name}" if kind == :block }
|
|
324
|
+
|
|
325
|
+
# +binding()+, never bare +binding+: a keyword named +binding+ is a local, and a local shadows the
|
|
326
|
+
# very method this reaches for.
|
|
327
|
+
#
|
|
328
|
+
# @param name [String] a keyword parameter's name
|
|
329
|
+
# @return [String] source reading that keyword's value
|
|
330
|
+
def reference(name) = RESERVED_WORDS.include?(name) ? "binding().local_variable_get(:#{name})" : name
|
|
331
|
+
end
|
|
332
|
+
end
|
data/lib/candor.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
require "candor/version"
|
|
6
|
+
require "candor/signature"
|
|
7
|
+
require "candor/definer"
|
|
8
|
+
|
|
9
|
+
# Turns a block or a callable into a real method with an honest signature: the body's arity, the body's
|
|
10
|
+
# +parameters+, the body's +source_location+, and allocation-free dispatch.
|
|
11
|
+
#
|
|
12
|
+
# Candor.define(MyClass, :greet) { |name, greeting: "hi"| "#{greeting}, #{name}" }
|
|
13
|
+
# MyClass.instance_method(:greet).parameters # => [[:req, :__p0], [:key, :greeting]]
|
|
14
|
+
#
|
|
15
|
+
# A wrong-arity call and an unknown keyword raise +ArgumentError+ at the fabricated method, before
|
|
16
|
+
# anything of yours runs.
|
|
17
|
+
module Candor
|
|
18
|
+
# Prefix of the private methods holding compiled bodies. Fabricated names may not start with it, and
|
|
19
|
+
# an interceptor reaches its body through {Candor.body_name}.
|
|
20
|
+
BODY_PREFIX = "__candor_body_"
|
|
21
|
+
|
|
22
|
+
# Fabrication is a boot-time operation, so one global lock serializes it; nothing is taken at call time.
|
|
23
|
+
MONITOR = Monitor.new
|
|
24
|
+
private_constant :MONITOR
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Defines a real method on +target+ whose signature is the body's.
|
|
28
|
+
#
|
|
29
|
+
# With +via+, every call routes to that method on the target as +via(canonical_name, ...)+, and it
|
|
30
|
+
# alone decides whether to run the body — which it reaches under {body_name}. Without +via+, the
|
|
31
|
+
# wrapper forwards straight to the body.
|
|
32
|
+
#
|
|
33
|
+
# An unpassed optional is dropped from the forwarded arguments, so the body applies its own default.
|
|
34
|
+
#
|
|
35
|
+
# Candor.define(App.singleton_class, :fetch, aliases: [:get], via: :__call) { |id, ttl: 60| ... }
|
|
36
|
+
#
|
|
37
|
+
# @param target [Module] the module — often a +singleton_class+ — to install onto
|
|
38
|
+
# @param name [Symbol] the canonical name, passed to the interceptor and used for the body method
|
|
39
|
+
# @param aliases [Array<Symbol>] further names sharing the one dispatch and the one canonical name
|
|
40
|
+
# @param via [Symbol, nil] an interceptor method on +target+, resolved per call
|
|
41
|
+
# @param parameters [Array<Array>, nil] an explicit shape advertised instead of the body's; not
|
|
42
|
+
# validated against the body, so a mismatch surfaces as the body's own +ArgumentError+
|
|
43
|
+
# @param body [Proc, Method, UnboundMethod, nil] the body, when it is not given as a block
|
|
44
|
+
# @yield the body, when it is not given as +body+; +self+ inside it is the receiver
|
|
45
|
+
# @return [Symbol] the canonical name
|
|
46
|
+
# @raise [TypeError] if +target+ is not a Module, or the body is neither a block, a Proc, a Method
|
|
47
|
+
# nor an UnboundMethod, or its +source_location+ is +nil+, or its owner is not an ancestor of
|
|
48
|
+
# +target+
|
|
49
|
+
# @raise [ArgumentError] if a name starts with {BODY_PREFIX}, or +via+ is not a callable method
|
|
50
|
+
# name, or +parameters+ is malformed
|
|
51
|
+
# @raise [FrozenError] if +target+ is frozen
|
|
52
|
+
def define(target, name, aliases: [], via: nil, parameters: nil, body: nil, &block)
|
|
53
|
+
Definer.new(target, name, aliases: aliases, via: via, parameters: parameters, body: body || block).call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The private method holding a fabricated method's body. An interceptor calls it with +send+.
|
|
57
|
+
#
|
|
58
|
+
# @param name [Symbol] a canonical name
|
|
59
|
+
# @return [Symbol]
|
|
60
|
+
def body_name(name) = :"#{BODY_PREFIX}#{name}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private_constant :Definer
|
|
64
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Candor
|
|
2
|
+
# Compiles a method's `parameters` into a dispatch lambda with the same parameter kinds, and
|
|
3
|
+
# therefore the same `arity`. Switches on the kind, never the name.
|
|
4
|
+
class Signature
|
|
5
|
+
# Every kind `Method#parameters` can emit, and the only ones `render` accepts.
|
|
6
|
+
KINDS: Array[Symbol]
|
|
7
|
+
|
|
8
|
+
# How many optional keywords still dispatch through call sites rather than a Hash.
|
|
9
|
+
KEYWORD_BRANCH_LIMIT: Integer
|
|
10
|
+
|
|
11
|
+
# One entry of `Method#parameters`: a kind, and a name the body may not have given.
|
|
12
|
+
type parameter = [ Symbol ] | [ Symbol, Symbol ]
|
|
13
|
+
|
|
14
|
+
def self.compile: (Array[parameter] parameters, name: Symbol, source_location: [ String, Integer ], ?via: Symbol?) -> Proc
|
|
15
|
+
def self.render: (Array[parameter] parameters, name: Symbol, ?via: Symbol?) -> String
|
|
16
|
+
def self.method_name!: (Symbol | String name) -> Symbol
|
|
17
|
+
def self.parameters!: (untyped parameters) -> Array[parameter]
|
|
18
|
+
|
|
19
|
+
def initialize: (Array[parameter] parameters, Symbol name, ?Symbol? via) -> void
|
|
20
|
+
def source: () -> String
|
|
21
|
+
end
|
|
22
|
+
end
|
data/sig/candor.rbs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Turns a block or a callable into a real method with an honest signature.
|
|
2
|
+
#
|
|
3
|
+
# Fabricated methods are compiled at runtime, so RBS cannot see them. Declare the ones you rely on in
|
|
4
|
+
# your own `sig/`.
|
|
5
|
+
#
|
|
6
|
+
# These files declare the public API and nothing else. `Candor::Definer` and the compiler's internal
|
|
7
|
+
# constants are `private_constant` in Ruby, and RBS has no syntax for that — declaring them here would
|
|
8
|
+
# advertise a surface that raises NameError on use.
|
|
9
|
+
module Candor
|
|
10
|
+
VERSION: String
|
|
11
|
+
|
|
12
|
+
# Prefix of the private methods holding compiled bodies.
|
|
13
|
+
BODY_PREFIX: String
|
|
14
|
+
|
|
15
|
+
# A body `define_method` accepts, with a `source_location` to forge onto the wrapper.
|
|
16
|
+
type body = Proc | Method | UnboundMethod
|
|
17
|
+
|
|
18
|
+
def self.define: (
|
|
19
|
+
Module target,
|
|
20
|
+
Symbol name,
|
|
21
|
+
?aliases: Array[Symbol],
|
|
22
|
+
?via: Symbol?,
|
|
23
|
+
?parameters: Array[Signature::parameter]?,
|
|
24
|
+
?body: body?
|
|
25
|
+
) ?{ (?) -> untyped } -> Symbol
|
|
26
|
+
|
|
27
|
+
def self.body_name: (Symbol name) -> Symbol
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: candor
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Leonid Svyatov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Ruby''s missing functools.wraps. Candor fabricates real methods from
|
|
13
|
+
blocks and callables: same arity, same parameters, source_location pointing at your
|
|
14
|
+
code, and allocation-free dispatch. Zero runtime dependencies.'
|
|
15
|
+
email:
|
|
16
|
+
- leonid@svyatov.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- ".yardopts"
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- README.md
|
|
25
|
+
- candor.gemspec
|
|
26
|
+
- lib/candor.rb
|
|
27
|
+
- lib/candor/definer.rb
|
|
28
|
+
- lib/candor/signature.rb
|
|
29
|
+
- lib/candor/version.rb
|
|
30
|
+
- sig/candor.rbs
|
|
31
|
+
- sig/candor/signature.rbs
|
|
32
|
+
homepage: https://github.com/svyatov/candor
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
rubygems_mfa_required: 'true'
|
|
37
|
+
documentation_uri: https://rubydoc.info/gems/candor
|
|
38
|
+
source_code_uri: https://github.com/svyatov/candor
|
|
39
|
+
changelog_uri: https://github.com/svyatov/candor/blob/main/CHANGELOG.md
|
|
40
|
+
bug_tracker_uri: https://github.com/svyatov/candor/issues
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 3.2.0
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 4.0.12
|
|
56
|
+
specification_version: 4
|
|
57
|
+
summary: Turn a block or a callable into a real method with an honest signature.
|
|
58
|
+
test_files: []
|