candor 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +45 -28
- data/lib/candor/definer.rb +42 -13
- data/lib/candor/signature.rb +28 -6
- data/lib/candor/version.rb +1 -1
- data/lib/candor.rb +9 -5
- data/sig/candor/signature.rbs +1 -0
- data/sig/candor.rbs +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 413521a77b9a26be85cbfe5ba7078da7a1c9192cb879da37f8d3be0d7c973ecc
|
|
4
|
+
data.tar.gz: 7b8a583a71fe52f567b7a9c931d9b46dfa9aae0d4d3f8daf2b412e08201cb7c8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f1bf8cc569d1c3924b42ae8d299fb9779c8efef396da6eaa9c95ffe625b97c7aa94ba27077f61decb275b3fd6e58aed98d2e2e39f2b1a512e2211e0a0830b20
|
|
7
|
+
data.tar.gz: 476fd93d7089c5b2ffc2d276f611dd833dcf468f35014662586d5fb536533cbe0e0ba3558c7632fc64d53bd29bf5b9642492335168ae451488710dd4dc1c3bba
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
+
## [0.2.0] - 2026-07-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `Candor.define(..., source_location:)` — the location every fabricated name reports, overriding the body's. A caller whose body is a proc it generated on the user's behalf can now point the wrapper at what the user actually wrote, rather than at the generator. It is also the only way to fabricate from a body carrying no location of its own — a curried proc, a symbol-to-proc, a C-defined method — which is the caller assuming responsibility for the honesty the gem otherwise derives. A `#call` object is not one of them and no `source_location:` rescues it: that refusal is about the kind. A `source_location:` that is not a `[String, Integer]` pair, or whose line is one `eval` will not take, raises `ArgumentError` before the target is touched.
|
|
11
|
+
- `Candor::Signature.source_location!` — the location gate, public beside `method_name!` and `parameters!`. `Signature.compile` calls it, so a consumer installing dispatch itself can no longer forge a location `eval` refuses, nor one it silently misreads. Its line must fit `eval`'s: a C `int`.
|
|
12
|
+
|
|
7
13
|
## [0.1.0] - 2026-07-10
|
|
8
14
|
|
|
9
15
|
### Added
|
|
@@ -13,4 +19,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
13
19
|
- 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
20
|
- `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
21
|
|
|
22
|
+
[0.2.0]: https://github.com/svyatov/candor/releases/tag/v0.2.0
|
|
16
23
|
[0.1.0]: https://github.com/svyatov/candor/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://codecov.io/gh/svyatov/candor)
|
|
6
6
|
|
|
7
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
|
|
8
|
+
*body's* arity, the *body's* `parameters` and the *body's* `source_location`, and rejects a bad call
|
|
9
9
|
before anything of yours runs.
|
|
10
10
|
|
|
11
11
|
Zero runtime dependencies. Ruby >= 3.2.
|
|
@@ -25,13 +25,13 @@ Greeter.new.greet("bob", grating: "yo") # => ArgumentError: unknown key
|
|
|
25
25
|
|
|
26
26
|
Ruby gives you no way to hand a generated wrapper the signature of the callable it wraps.
|
|
27
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
|
|
28
|
+
come from an object that already has it. The only source of such an object is a literal parameter
|
|
29
29
|
list, hand-typed or generated as text.
|
|
30
30
|
|
|
31
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
|
|
34
|
-
handling can swallow it
|
|
32
|
+
right; the reflection lies. `arity` is `-1`, `parameters` reads `[[:rest], [:keyrest], [:block]]`, and 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. Every call allocates an Array and a Hash.
|
|
35
35
|
|
|
36
36
|
Candor generates the parameter list as source and `eval`s it, once, at definition time. There is no
|
|
37
37
|
degraded fallback: every shape Ruby can express wraps at full fidelity, including `end:`, `it`, `_1`,
|
|
@@ -61,7 +61,7 @@ Formatter.new.pad("x", 3) # => "x "
|
|
|
61
61
|
|
|
62
62
|
`via:` names a method on the target, resolved per call. Every call routes to it and to it alone. It
|
|
63
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
|
|
64
|
+
`Candor.body_name`, so it can also choose *not* to run it: memoize, instrument, short-circuit.
|
|
65
65
|
|
|
66
66
|
```ruby
|
|
67
67
|
class Memo
|
|
@@ -77,7 +77,7 @@ end
|
|
|
77
77
|
Candor.define(Memo, :catalog, parameters: [], via: :__call) { Catalog.build }
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
A wrong-arity call raises at the wrapper, before `__call` is ever entered
|
|
80
|
+
A wrong-arity call raises at the wrapper, before `__call` is ever entered, so an interceptor that
|
|
81
81
|
rescues broadly never sees a caller's mistake as a body's failure. An alias left behind by an *earlier*
|
|
82
82
|
fabrication is the exception: it keeps the wrapper it was built with, so its gate is that fabrication's
|
|
83
83
|
signature and not the current body's. See [Re-fabrication](#the-contract).
|
|
@@ -102,13 +102,26 @@ dispatch = Candor::Signature.compile(parameters, name: :greet, via: :__call, sou
|
|
|
102
102
|
klass.define_method(:greet, &dispatch)
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
`compile` gates all three of its inputs. `method_name!`, `parameters!` and `source_location!` are public
|
|
106
|
+
for a consumer that wants to fail earlier than the compile does.
|
|
107
|
+
|
|
105
108
|
## The contract
|
|
106
109
|
|
|
107
|
-
**Body kinds.** A block, a `Proc`, a `Method` or an `UnboundMethod
|
|
108
|
-
takes. A `#call` object is rejected
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
**Body kinds.** A block, a `Proc`, a `Method` or an `UnboundMethod`, the three things `define_method`
|
|
111
|
+
takes. A `#call` object is rejected, and no `source_location:` rescues it: the refusal is about the kind.
|
|
112
|
+
So is any body whose `source_location` is `nil`: a curried proc, a `Symbol#to_proc`, a C-defined method.
|
|
113
|
+
Ruby exposes no `curried?` predicate, and a nil `source_location` is the one reliable discriminator.
|
|
114
|
+
Without it the honest-`source_location` guarantee cannot be kept. An explicit `source_location:` is the
|
|
115
|
+
one way to fabricate from those three, and it is the caller taking responsibility for the honesty the gem
|
|
116
|
+
otherwise derives. Inside a block body, `self` is the receiver.
|
|
117
|
+
|
|
118
|
+
**Location override.** `source_location:` is what every fabricated name reports, in place of the body's.
|
|
119
|
+
A caller whose body is a proc it generated on the user's behalf points the wrapper at what the user
|
|
120
|
+
actually wrote, rather than at the generator:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
Candor.define(App, :fetch, source_location: user_block.source_location, body: proc { ... })
|
|
124
|
+
```
|
|
112
125
|
|
|
113
126
|
**Reserved prefix.** Compiled bodies are private methods named `#{Candor::BODY_PREFIX}#{name}`.
|
|
114
127
|
Fabricating a name that starts with the prefix raises `ArgumentError`.
|
|
@@ -117,8 +130,8 @@ Fabricating a name that starts with the prefix raises `ArgumentError`.
|
|
|
117
130
|
optional positionals left to right, so `n` of them have `n + 1` states, enumerated as call sites rather
|
|
118
131
|
than accumulated into an Array. Optional keywords are independent, so `n` of them would cost `2**n` call
|
|
119
132
|
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
|
|
121
|
-
case where candor is slower than the variadic wrapper it replaces. It is measured
|
|
133
|
+
dispatch allocates exactly one Hash per call. That shape, three or more optional keywords, is the one
|
|
134
|
+
case where candor is slower than the variadic wrapper it replaces. It is measured and accepted.
|
|
122
135
|
|
|
123
136
|
Those counts are what the *wrapper* adds. A body that declares a **keyrest** (`**kw`) costs Ruby one Hash
|
|
124
137
|
to capture it and one to re-splat it into the body, on any wrapper you could write by hand; candor adds
|
|
@@ -136,16 +149,18 @@ method in place rather than removing and reinstalling it, so a caller racing a `
|
|
|
136
149
|
the old method or the new one, never a `NoMethodError`.
|
|
137
150
|
|
|
138
151
|
**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
|
|
140
|
-
`ArgumentError
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
152
|
+
ancestor of the target raises `TypeError`, and a malformed `parameters:`, a malformed `source_location:`
|
|
153
|
+
or an uncallable `via:` raises `ArgumentError`, all *before* the target is touched. A `source_location:`
|
|
154
|
+
is malformed unless it is a `[String, Integer]` pair whose line `eval` will take. A rejected fabrication
|
|
155
|
+
leaves the target exactly as it was. A hand-written `parameters:` never passed Ruby's parser, so it is
|
|
156
|
+
compiled before the first mutation: a shape whose *combination* is illegal (two rests, a duplicate
|
|
157
|
+
keyword) is an `ArgumentError` too. Nothing ever surfaces as a `SyntaxError` from inside `eval`, which a
|
|
158
|
+
`rescue` would not catch.
|
|
144
159
|
|
|
145
160
|
**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
|
|
161
|
+
arguments packed into an Array when it is reached through `(name, ...)` forwarding, `send`, or a splat,
|
|
162
|
+
which is the shape an interceptor is usually written in. Candor's own generated call sites name every
|
|
163
|
+
argument, so direct-forward mode is unaffected. Ruby 4.0 fixes it, and `_1` and named parameters never
|
|
149
164
|
had it.
|
|
150
165
|
|
|
151
166
|
**Re-fabrication.** Redefining a name overwrites candor's own prior wrapper and body in place, so a
|
|
@@ -168,23 +183,25 @@ optional-argument rows carry a few percent of noise.
|
|
|
168
183
|
| three optional keywords | 251 ns · 1 alloc | 99 ns · 0 | 46 ns · 0 | 185 ns · 2 |
|
|
169
184
|
|
|
170
185
|
- **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
|
|
172
|
-
the default expressions. Candor is within ~10–40% of it, and allocates the same nothing.
|
|
186
|
+
private body, with the defaults hard-coded. It is what you would write by hand if you knew the shape
|
|
187
|
+
*and* the default expressions. Candor is within ~10–40% of it, and allocates the same nothing.
|
|
173
188
|
- **bare method** is a single `def` doing the work inline. It is roughly twice as fast as any wrapper,
|
|
174
189
|
because it is one method call rather than two. That is the price of wrapping at all, not of candor.
|
|
175
190
|
- **variadic wrapper** is the `|*args, **kwargs, &block|` retreat: slower than candor on every shape
|
|
176
191
|
below the keyword branch limit, faster on the hashed one, and dishonest about its signature everywhere.
|
|
177
192
|
|
|
178
|
-
Definition time is ~40 µs per fabricated method
|
|
193
|
+
Definition time is ~40 µs per fabricated method (one `eval`) against ~1.3 µs for a bare
|
|
179
194
|
`define_method`. It runs once, at boot.
|
|
180
195
|
|
|
181
196
|
## What it is not
|
|
182
197
|
|
|
183
198
|
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
|
|
199
|
+
layer those features stand on. It does not recover an optional's default expression; that is
|
|
185
200
|
[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.
|
|
187
|
-
|
|
201
|
+
so the body defaults is the semantics that replaces it.
|
|
202
|
+
|
|
203
|
+
Definition must happen on the main Ractor. Calling a fabricated method from a non-main Ractor raises, as
|
|
204
|
+
it does for any `define_method`-installed `Proc`, unless both the dispatch and the body are shareable.
|
|
188
205
|
|
|
189
206
|
## Contributing
|
|
190
207
|
|
data/lib/candor/definer.rb
CHANGED
|
@@ -12,8 +12,8 @@ module Candor
|
|
|
12
12
|
#
|
|
13
13
|
# @api private
|
|
14
14
|
class Definer
|
|
15
|
-
# The only bodies +define_method+ accepts. A +#call+ object is not one of them, and
|
|
16
|
-
#
|
|
15
|
+
# The only bodies +define_method+ accepts. A +#call+ object is not one of them, and no
|
|
16
|
+
# +source_location:+ rescues it: the refusal is about the kind, not the location.
|
|
17
17
|
BODY_KINDS = [Proc, Method, UnboundMethod].freeze
|
|
18
18
|
|
|
19
19
|
# @param target [Module] the module the method is installed onto
|
|
@@ -21,13 +21,15 @@ module Candor
|
|
|
21
21
|
# @param aliases [Array<Symbol>] further names sharing the one dispatch
|
|
22
22
|
# @param via [Symbol, nil] an interceptor method on +target+, resolved per call
|
|
23
23
|
# @param parameters [Array<Array>, nil] an explicit shape, overriding the body's
|
|
24
|
+
# @param source_location [Array(String, Integer), nil] a location, overriding the body's
|
|
24
25
|
# @param body [Proc, Method, UnboundMethod]
|
|
25
|
-
def initialize(target, name, aliases:, via:, parameters:, body:)
|
|
26
|
+
def initialize(target, name, aliases:, via:, parameters:, source_location:, body:)
|
|
26
27
|
@target = target
|
|
27
28
|
@canonical = name.to_sym
|
|
28
29
|
@names = [@canonical, *aliases.map(&:to_sym)].uniq
|
|
29
30
|
@via = via
|
|
30
31
|
@parameters = parameters
|
|
32
|
+
@source_location = source_location
|
|
31
33
|
@body = body
|
|
32
34
|
@body_name = Candor.body_name(@canonical)
|
|
33
35
|
end
|
|
@@ -56,6 +58,7 @@ module Candor
|
|
|
56
58
|
raise TypeError, "target must be a Module, got #{@target.inspect}" unless @target.is_a?(Module)
|
|
57
59
|
|
|
58
60
|
validate_body!
|
|
61
|
+
validate_location!
|
|
59
62
|
validate_names!
|
|
60
63
|
# Both call-site names are interpolated into `eval`'d source. Without `via` the body's own name is
|
|
61
64
|
# the call site, so the canonical name has to survive being one.
|
|
@@ -64,20 +67,37 @@ module Candor
|
|
|
64
67
|
raise FrozenError.new("can't fabricate a method on frozen #{@target}", receiver: @target) if @target.frozen?
|
|
65
68
|
end
|
|
66
69
|
|
|
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
70
|
# @return [void]
|
|
72
71
|
# @raise [TypeError]
|
|
73
72
|
def validate_body!
|
|
74
|
-
raise TypeError,
|
|
75
|
-
raise TypeError, body_error if @body.source_location.nil?
|
|
73
|
+
raise TypeError, kind_error unless BODY_KINDS.any? { |kind| @body.is_a?(kind) }
|
|
76
74
|
return unless @body.is_a?(Method) || @body.is_a?(UnboundMethod)
|
|
77
75
|
# `define_method` would raise this itself — after the prior wrapper was already removed.
|
|
78
76
|
raise TypeError, "#{@body.owner} is not an ancestor of #{@target}" unless @target <= @body.owner
|
|
79
77
|
end
|
|
80
78
|
|
|
79
|
+
# A body's +source_location+ is the one reliable discriminator: Ruby exposes no +curried?+ predicate,
|
|
80
|
+
# and a curried proc, a symbol-to-proc and a C-defined method all report +nil+. Honouring the body's
|
|
81
|
+
# location is the product, so a body carrying none is refused — unless the caller names one, which is
|
|
82
|
+
# the caller assuming responsibility for the honesty. A caller that *rewrites* a location has to be
|
|
83
|
+
# able to: a wrapper whose body is a proc literal the caller generated should point at what the user
|
|
84
|
+
# wrote, not at the generator.
|
|
85
|
+
#
|
|
86
|
+
# {Signature.compile} gates the location it is handed, but a shape read from the compiled body only
|
|
87
|
+
# reaches that gate once the body is installed. So an overridden location is gated here too, exactly
|
|
88
|
+
# as {Signature.method_name!} is: the compiler owns the rule, and the pipeline pays it early enough
|
|
89
|
+
# that a rejected fabrication still leaves the target untouched.
|
|
90
|
+
#
|
|
91
|
+
# @return [void]
|
|
92
|
+
# @raise [TypeError, ArgumentError]
|
|
93
|
+
def validate_location!
|
|
94
|
+
Signature.source_location!(@source_location) if @source_location
|
|
95
|
+
raise TypeError, no_location_error if location.nil?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Array(String, Integer), nil] the location the fabricated method will report
|
|
99
|
+
def location = @source_location || @body.source_location
|
|
100
|
+
|
|
81
101
|
# @return [void]
|
|
82
102
|
# @raise [ArgumentError]
|
|
83
103
|
def validate_names!
|
|
@@ -88,9 +108,19 @@ module Candor
|
|
|
88
108
|
end
|
|
89
109
|
|
|
90
110
|
# @return [String]
|
|
91
|
-
def
|
|
111
|
+
def kind_error
|
|
92
112
|
"body must be a block, a Proc, a Method or an UnboundMethod with a source_location, " \
|
|
93
|
-
"got #{@body.inspect};
|
|
113
|
+
"got #{@body.inspect}; a `#call` object is none of them, and no source_location: rescues it"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Separate from {kind_error} because the remedy differs. Only a body +define_method+ accepts reaches
|
|
117
|
+
# this, so naming a location really is the fix — where a +#call+ object is refused for its kind, and
|
|
118
|
+
# naming one buys it nothing.
|
|
119
|
+
#
|
|
120
|
+
# @return [String]
|
|
121
|
+
def no_location_error
|
|
122
|
+
"#{@body.inspect} carries no source_location: curried procs, symbol-to-procs and C-defined " \
|
|
123
|
+
"methods have none, so fabricating from one takes an explicit source_location:"
|
|
94
124
|
end
|
|
95
125
|
|
|
96
126
|
# +define_method+ replaces a method in place, so a re-fabrication is never observable as a missing
|
|
@@ -123,8 +153,7 @@ module Candor
|
|
|
123
153
|
# @return [Proc] the dispatch lambda
|
|
124
154
|
# @raise [ArgumentError] if the shape does not render to legal source
|
|
125
155
|
def compile(shape)
|
|
126
|
-
Signature.compile(shape, name: @via ? @canonical : @body_name, via: @via,
|
|
127
|
-
source_location: @body.source_location)
|
|
156
|
+
Signature.compile(shape, name: @via ? @canonical : @body_name, via: @via, source_location: location)
|
|
128
157
|
end
|
|
129
158
|
|
|
130
159
|
# @param dispatch [Proc]
|
data/lib/candor/signature.rb
CHANGED
|
@@ -70,12 +70,16 @@ module Candor
|
|
|
70
70
|
# Names Ruby reserves for the numbered block parameters; +->(_1: 1) {}+ is a +SyntaxError+.
|
|
71
71
|
NUMBERED_PARAMETERS = (1..9).map { |i| "_#{i}" }.freeze
|
|
72
72
|
|
|
73
|
+
# The line numbers +eval+ takes: its +lineno+ is a C +int+, and one outside this range is a
|
|
74
|
+
# +RangeError+ from inside {compile} rather than an +ArgumentError+ from its gate.
|
|
75
|
+
LINE_RANGE = (-(2**31))...(2**31)
|
|
76
|
+
|
|
73
77
|
# 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 {
|
|
75
|
-
# tables they are written in terms of. Only {KINDS} and {KEYWORD_BRANCH_LIMIT} name
|
|
76
|
-
# caller has to know to use the compiler, so only those two stay public.
|
|
78
|
+
# against {compile}, {method_name!}, {parameters!} and {source_location!} — which raise — not against
|
|
79
|
+
# the patterns and tables they are written in terms of. Only {KINDS} and {KEYWORD_BRANCH_LIMIT} name
|
|
80
|
+
# something a caller has to know to use the compiler, so only those two stay public.
|
|
77
81
|
private_constant :RESERVED_WORDS, :NAMED_KINDS, :OPTIONAL_KINDS, :SUFFIXES, :DECLARATIONS,
|
|
78
|
-
:METHOD_NAME, :KEYWORD_NAME, :NUMBERED_PARAMETERS
|
|
82
|
+
:METHOD_NAME, :KEYWORD_NAME, :NUMBERED_PARAMETERS, :LINE_RANGE
|
|
79
83
|
|
|
80
84
|
class << self
|
|
81
85
|
# +eval+'s file and line forge the lambda's — and therefore the compiled method's —
|
|
@@ -87,9 +91,10 @@ module Candor
|
|
|
87
91
|
# @param source_location [Array(String, Integer)] the body's
|
|
88
92
|
# @param via [Symbol, nil] an interceptor method taking +(name, ...)+; omit to call +name+ directly
|
|
89
93
|
# @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
|
|
94
|
+
# @raise [ArgumentError] if the call site's name, the parameter shape or the location is malformed
|
|
91
95
|
def compile(parameters, name:, source_location:, via: nil)
|
|
92
|
-
|
|
96
|
+
source = render(parameters, name: name, via: via)
|
|
97
|
+
eval(source, binding, *source_location!(source_location)) # rubocop:disable Security/Eval
|
|
93
98
|
rescue SyntaxError => e
|
|
94
99
|
# {parameters!} sees one entry at a time; only the parser sees the combination — two rests, a
|
|
95
100
|
# duplicate keyword, a block before a positional. A +SyntaxError+ is a +ScriptError+, which a
|
|
@@ -139,6 +144,23 @@ module Candor
|
|
|
139
144
|
parameters
|
|
140
145
|
end
|
|
141
146
|
|
|
147
|
+
# Unlike the two gates above, the location is never interpolated into the generated source: +eval+
|
|
148
|
+
# takes it as a file and a line, not as code. So this is a shape check rather than a trust boundary
|
|
149
|
+
# — with one edge that bites. +eval+'s line is a C +int+, and one outside {LINE_RANGE} raises
|
|
150
|
+
# +RangeError+ from inside {compile}. A consumer reading a shape off an already-installed body only
|
|
151
|
+
# reaches that compile after installing it, so the range is checked here, where it is still cheap.
|
|
152
|
+
#
|
|
153
|
+
# @param source_location [Array(String, Integer)]
|
|
154
|
+
# @return [Array(String, Integer)] the location
|
|
155
|
+
# @raise [ArgumentError] unless it is a [String, Integer] pair whose line +eval+ will take
|
|
156
|
+
def source_location!(source_location)
|
|
157
|
+
file, line = source_location if source_location.is_a?(Array) && source_location.size == 2
|
|
158
|
+
return source_location if file.is_a?(String) && line.is_a?(Integer) && LINE_RANGE.cover?(line)
|
|
159
|
+
|
|
160
|
+
raise ArgumentError, "malformed source_location: #{source_location.inspect}; " \
|
|
161
|
+
"expected a [String, Integer] pair, the line within #{LINE_RANGE}"
|
|
162
|
+
end
|
|
163
|
+
|
|
142
164
|
private
|
|
143
165
|
|
|
144
166
|
# @param entry [Object]
|
data/lib/candor/version.rb
CHANGED
data/lib/candor.rb
CHANGED
|
@@ -40,17 +40,21 @@ module Candor
|
|
|
40
40
|
# @param via [Symbol, nil] an interceptor method on +target+, resolved per call
|
|
41
41
|
# @param parameters [Array<Array>, nil] an explicit shape advertised instead of the body's; not
|
|
42
42
|
# validated against the body, so a mismatch surfaces as the body's own +ArgumentError+
|
|
43
|
+
# @param source_location [Array(String, Integer), nil] a location reported instead of the body's, for
|
|
44
|
+
# a caller whose body is a proc it generated on the user's behalf; also the only way to fabricate
|
|
45
|
+
# from a body carrying no location of its own
|
|
43
46
|
# @param body [Proc, Method, UnboundMethod, nil] the body, when it is not given as a block
|
|
44
47
|
# @yield the body, when it is not given as +body+; +self+ inside it is the receiver
|
|
45
48
|
# @return [Symbol] the canonical name
|
|
46
49
|
# @raise [TypeError] if +target+ is not a Module, or the body is neither a block, a Proc, a Method
|
|
47
|
-
# nor an UnboundMethod, or
|
|
48
|
-
# +target+
|
|
50
|
+
# nor an UnboundMethod, or neither it nor +source_location+ carries a location, or the body's owner
|
|
51
|
+
# is not an ancestor of +target+
|
|
49
52
|
# @raise [ArgumentError] if a name starts with {BODY_PREFIX}, or +via+ is not a callable method
|
|
50
|
-
# name, or +parameters+ is malformed
|
|
53
|
+
# name, or +parameters+ or +source_location+ is malformed
|
|
51
54
|
# @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,
|
|
55
|
+
def define(target, name, aliases: [], via: nil, parameters: nil, source_location: nil, body: nil, &block)
|
|
56
|
+
Definer.new(target, name, aliases: aliases, via: via, parameters: parameters,
|
|
57
|
+
source_location: source_location, body: body || block).call
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
# The private method holding a fabricated method's body. An interceptor calls it with +send+.
|
data/sig/candor/signature.rbs
CHANGED
|
@@ -15,6 +15,7 @@ module Candor
|
|
|
15
15
|
def self.render: (Array[parameter] parameters, name: Symbol, ?via: Symbol?) -> String
|
|
16
16
|
def self.method_name!: (Symbol | String name) -> Symbol
|
|
17
17
|
def self.parameters!: (untyped parameters) -> Array[parameter]
|
|
18
|
+
def self.source_location!: (untyped source_location) -> [ String, Integer ]
|
|
18
19
|
|
|
19
20
|
def initialize: (Array[parameter] parameters, Symbol name, ?Symbol? via) -> void
|
|
20
21
|
def source: () -> String
|
data/sig/candor.rbs
CHANGED