candor 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c57856ee0508cf6514f57073f0b68462e9cd8fc6f14c22bebe338dd3161558a3
4
- data.tar.gz: f121e2ff9a2ca75a812f2e24991e10332ce5a81765f842c22572672928d3f32f
3
+ metadata.gz: 4f762b3abe3c9761a038e98d5070ac771bada1d677bd84695e34c7ec623091fc
4
+ data.tar.gz: b2ae106ed934e85f4c55b2a28d070bbb57a1218f0f27e40c6620c23acd6a922d
5
5
  SHA512:
6
- metadata.gz: cdd277bdb4c503f974ead0e2be03da69361ceee33b586eefd187a761d03654b0084bc42a069d016578d174aa91cbf767f18a5885bdb4a7602a628b5c83f5d4af
7
- data.tar.gz: e5275d7788db55cb88d258f391c45d10a4807b44616c332ace2f3a28a51a975357632a481d3974406147cd85a39a557145b377253033e23456a178eb5c2c8431
6
+ metadata.gz: 60b025033f7e8b208b74eb110b252814c9cabd5272b0c1681fd1d0421e2f48fe3511334c8378a86c4d489ade82ced0a5648b7e48d7da66552cc7bf277682e3f1
7
+ data.tar.gz: a2984ef22907efc8134743c2e2da12ea0b8bb1c381f6df792a1385a778957c9288a19f4fcfd509095cfd75eff572384d1eff2fbe06ba93d90780196ae7164d17
data/CHANGELOG.md CHANGED
@@ -4,13 +4,30 @@ 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
+ ## [Unreleased]
8
+
9
+ ## [0.2.1] - 2026-07-31
10
+
11
+ No change to the library.
12
+
13
+ The gem now ships from a tagged CI run through RubyGems trusted publishing. This release and every later one carries a sigstore attestation. The GitHub release attaches the built gem beside a checksum manifest with build provenance.
14
+
15
+ ## [0.2.0] - 2026-07-10
16
+
17
+ ### Added
18
+ - `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.
19
+ - `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`.
20
+
7
21
  ## [0.1.0] - 2026-07-10
8
22
 
9
23
  ### 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.
24
+ - `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.
25
+ - `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`. That includes 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
26
  - 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
27
  - 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
28
  - `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
29
 
30
+ [Unreleased]: https://github.com/svyatov/candor/compare/v0.2.1...HEAD
31
+ [0.2.1]: https://github.com/svyatov/candor/releases/tag/v0.2.1
32
+ [0.2.0]: https://github.com/svyatov/candor/releases/tag/v0.2.0
16
33
  [0.1.0]: https://github.com/svyatov/candor/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,21 +1,33 @@
1
- # Candor
1
+ # Candor [![Gem Version](https://img.shields.io/gem/v/candor)](https://rubygems.org/gems/candor) [![Codecov](https://img.shields.io/codecov/c/github/svyatov/candor)](https://app.codecov.io/gh/svyatov/candor) [![CI](https://github.com/svyatov/candor/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/svyatov/candor/actions?query=workflow%3ACI)
2
2
 
3
- [![Gem Version](https://img.shields.io/gem/v/candor)](https://rubygems.org/gems/candor)
4
- [![CI](https://github.com/svyatov/candor/actions/workflows/main.yml/badge.svg)](https://github.com/svyatov/candor/actions/workflows/main.yml)
5
- [![codecov](https://codecov.io/gh/svyatov/candor/branch/main/graph/badge.svg)](https://codecov.io/gh/svyatov/candor)
3
+ Turn a block or a callable into a real Ruby method that reports the body's own arity, parameters and
4
+ source location.
6
5
 
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.
6
+ - **Zero runtime dependencies.** Ruby 3.2 or newer and nothing else. Tested on 3.2, 3.3, 3.4, 4.0 and
7
+ `ruby-head`.
8
+ - **Every shape Ruby can express.** All eight parameter kinds wrap at full fidelity, including `end:`,
9
+ `it`, `_1`, `**nil` and destructuring. There is no degraded fallback.
10
+ - **Faster than the `|*args, **kwargs|` wrapper it replaces.** 52 ns and zero allocations per call
11
+ against 122 ns and two, on the no-argument shape. Full table below.
12
+ - **RBS signatures shipped.** `sig/` declares the public API, and the test suite pins it against the
13
+ code in both directions.
10
14
 
11
- Zero runtime dependencies. Ruby >= 3.2.
15
+ ## Install
16
+
17
+ ```sh
18
+ bundle add candor
19
+ ```
20
+
21
+ Or drop `gem "candor"` into your Gemfile, or run `gem install candor` without Bundler.
22
+
23
+ Then, anywhere in your code:
12
24
 
13
25
  ```ruby
14
26
  Candor.define(Greeter, :greet) { |name, greeting: "hi"| "#{greeting}, #{name}" }
15
27
 
16
28
  Greeter.instance_method(:greet).arity # => -2
17
29
  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
30
+ Greeter.instance_method(:greet).source_location # => ["app/greeter.rb", 3] the block, not the gem
19
31
 
20
32
  Greeter.new.greet # => ArgumentError: wrong number of arguments (given 0, expected 1)
21
33
  Greeter.new.greet("bob", grating: "yo") # => ArgumentError: unknown keyword: :grating
@@ -25,24 +37,18 @@ Greeter.new.greet("bob", grating: "yo") # => ArgumentError: unknown key
25
37
 
26
38
  Ruby gives you no way to hand a generated wrapper the signature of the callable it wraps.
27
39
  `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
40
+ come from an object that already has it. The only source of such an object is a literal parameter
29
41
  list, hand-typed or generated as text.
30
42
 
31
43
  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.
44
+ right; the reflection lies. `arity` is `-1`, `parameters` reads `[[:rest], [:keyrest], [:block]]`, and a
45
+ wrong-arity call blows up one frame too deep: inside the wrapper, where the library's own error
46
+ handling can swallow it. Every call allocates an Array and a Hash.
35
47
 
36
48
  Candor generates the parameter list as source and `eval`s it, once, at definition time. There is no
37
49
  degraded fallback: every shape Ruby can express wraps at full fidelity, including `end:`, `it`, `_1`,
38
50
  `**nil`, and destructuring parameters.
39
51
 
40
- ## Install
41
-
42
- ```ruby
43
- gem "candor"
44
- ```
45
-
46
52
  ## Use
47
53
 
48
54
  ### Direct forward
@@ -61,7 +67,7 @@ Formatter.new.pad("x", 3) # => "x "
61
67
 
62
68
  `via:` names a method on the target, resolved per call. Every call routes to it and to it alone. It
63
69
  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.
70
+ `Candor.body_name`, so it can also choose *not* to run it: memoize, instrument, short-circuit.
65
71
 
66
72
  ```ruby
67
73
  class Memo
@@ -77,7 +83,7 @@ end
77
83
  Candor.define(Memo, :catalog, parameters: [], via: :__call) { Catalog.build }
78
84
  ```
79
85
 
80
- A wrong-arity call raises at the wrapper, before `__call` is ever entered so an interceptor that
86
+ A wrong-arity call raises at the wrapper, before `__call` is ever entered, so an interceptor that
81
87
  rescues broadly never sees a caller's mistake as a body's failure. An alias left behind by an *earlier*
82
88
  fabrication is the exception: it keeps the wrapper it was built with, so its gate is that fabrication's
83
89
  signature and not the current body's. See [Re-fabrication](#the-contract).
@@ -102,13 +108,28 @@ dispatch = Candor::Signature.compile(parameters, name: :greet, via: :__call, sou
102
108
  klass.define_method(:greet, &dispatch)
103
109
  ```
104
110
 
111
+ `compile` gates all three of its inputs. `method_name!`, `parameters!` and `source_location!` are public
112
+ for a consumer that wants to fail earlier than the compile does. `Candor::Signature::KINDS` is the frozen
113
+ list of the eight parameter kinds `Method#parameters` can emit, and the only ones `parameters!` and
114
+ `compile` accept.
115
+
105
116
  ## The contract
106
117
 
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.
118
+ **Body kinds.** A block, a `Proc`, a `Method` or an `UnboundMethod`, the three things `define_method`
119
+ takes. A `#call` object raises `TypeError`, and no `source_location:` rescues it: the refusal is about the
120
+ kind. So does any body whose `source_location` is `nil`: a curried proc, a `Symbol#to_proc`, a C-defined
121
+ method. Ruby exposes no `curried?` predicate, and a nil `source_location` is the one reliable discriminator.
122
+ Without it the honest-`source_location` guarantee cannot be kept. An explicit `source_location:` is the
123
+ one way to fabricate from those three, and it is the caller taking responsibility for the honesty the gem
124
+ otherwise derives. Inside a block body, `self` is the receiver.
125
+
126
+ **Location override.** `source_location:` is what every fabricated name reports, in place of the body's.
127
+ A caller whose body is a proc it generated on the user's behalf points the wrapper at what the user
128
+ actually wrote, rather than at the generator:
129
+
130
+ ```ruby
131
+ Candor.define(App, :fetch, source_location: user_block.source_location, body: proc { ... })
132
+ ```
112
133
 
113
134
  **Reserved prefix.** Compiled bodies are private methods named `#{Candor::BODY_PREFIX}#{name}`.
114
135
  Fabricating a name that starts with the prefix raises `ArgumentError`.
@@ -117,8 +138,8 @@ Fabricating a name that starts with the prefix raises `ArgumentError`.
117
138
  optional positionals left to right, so `n` of them have `n + 1` states, enumerated as call sites rather
118
139
  than accumulated into an Array. Optional keywords are independent, so `n` of them would cost `2**n` call
119
140
  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.
141
+ dispatch allocates exactly one Hash per call. That shape, three or more optional keywords, is the one
142
+ case where candor is slower than the variadic wrapper it replaces. It is measured and accepted.
122
143
 
123
144
  Those counts are what the *wrapper* adds. A body that declares a **keyrest** (`**kw`) costs Ruby one Hash
124
145
  to capture it and one to re-splat it into the body, on any wrapper you could write by hand; candor adds
@@ -135,17 +156,37 @@ serialize. Call-time dispatch takes no lock at all, and never needs one: re-fabr
135
156
  method in place rather than removing and reinstalling it, so a caller racing a `Candor.define` gets
136
157
  the old method or the new one, never a `NoMethodError`.
137
158
 
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.
159
+ **Failing fast.** Every check runs *before* the target is touched, so a rejected fabrication leaves it
160
+ exactly as it was. A frozen target raises `FrozenError`. A non-Module target, a body that is none of the
161
+ three kinds or carries no location, and a `Method` or `UnboundMethod` whose owner is not an ancestor of
162
+ the target all raise `TypeError`. A name under the reserved prefix, a malformed `parameters:`, a malformed
163
+ `source_location:` or an uncallable `via:` raises `ArgumentError`. A `source_location:` is malformed
164
+ unless it is a `[String, Integer]` pair whose line `eval` will take. A hand-written `parameters:` never
165
+ passed Ruby's parser, so it is compiled before the first mutation: a shape whose *combination* is illegal
166
+ (two rests, a duplicate keyword) is an `ArgumentError` too. Nothing ever surfaces as a `SyntaxError` from
167
+ inside `eval`, which a `rescue` would not catch.
168
+
169
+ **The `eval`.** The one `eval` runs at definition time, never per call, and never on anything a caller
170
+ passes at runtime. Its source is rendered off the parameter *kinds*, not their names: every positional,
171
+ rest, keyrest and block name becomes a generated one, so the only user text that survives into the source
172
+ is a keyword name. That name is checked before it is rendered, and it cannot shadow the lambda's own
173
+ locals, which all carry a run-of-underscores prefix no keyword name starts with. The two call-site names,
174
+ the body's or `via:`, pass `method_name!` first, so a reserved word or anything `eval` would not call bare
175
+ raises `ArgumentError` before a character is compiled. You can read exactly what it will run: `Signature.render`
176
+ returns the source `compile` would `eval`, without evaluating it.
177
+
178
+ ```ruby
179
+ Candor::Signature.render([%i[req a]], name: :greet, via: :__call)
180
+ # => "__u = ::Object.new.freeze; ->(__p0) { __call(:greet, __p0) }"
181
+
182
+ Candor::Signature.render([[:req, :name], [:key, :greeting]], name: Candor.body_name(:greet))
183
+ # => "__u = ::Object.new.freeze; ->(__p0, greeting: __u) { __u.equal?(greeting) ? __candor_body_greet(__p0) : __candor_body_greet(__p0, greeting: greeting) }"
184
+ ```
144
185
 
145
186
  **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
187
+ arguments packed into an Array when it is reached through `(name, ...)` forwarding, `send`, or a splat,
188
+ which is the shape an interceptor is usually written in. Candor's own generated call sites name every
189
+ argument, so direct-forward mode is unaffected. Ruby 4.0 fixes it, and `_1` and named parameters never
149
190
  had it.
150
191
 
151
192
  **Re-fabrication.** Redefining a name overwrites candor's own prior wrapper and body in place, so a
@@ -168,28 +209,41 @@ optional-argument rows carry a few percent of noise.
168
209
  | three optional keywords | 251 ns · 1 alloc | 99 ns · 0 | 46 ns · 0 | 185 ns · 2 |
169
210
 
170
211
  - **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 ~1040% of it, and allocates the same nothing.
212
+ private body, with the defaults hard-coded. It is what you would write by hand if you knew the shape
213
+ *and* the default expressions. Candor is within ~10-40% of it, and allocates the same nothing.
173
214
  - **bare method** is a single `def` doing the work inline. It is roughly twice as fast as any wrapper,
174
215
  because it is one method call rather than two. That is the price of wrapping at all, not of candor.
175
216
  - **variadic wrapper** is the `|*args, **kwargs, &block|` retreat: slower than candor on every shape
176
217
  below the keyword branch limit, faster on the hashed one, and dishonest about its signature everywhere.
177
218
 
178
- Definition time is ~40 µs per fabricated method one `eval` against ~1.3 µs for a bare
219
+ Definition time is ~40 µs per fabricated method (one `eval`) against ~1.3 µs for a bare
179
220
  `define_method`. It runs once, at boot.
180
221
 
181
222
  ## What it is not
182
223
 
183
224
  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
225
+ layer those features stand on. It does not recover an optional's default expression; that is
185
226
  [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.
227
+ so the body defaults is the semantics that replaces it.
228
+
229
+ Definition must happen on the main Ractor. Calling a fabricated method from a non-main Ractor raises, as
230
+ it does for any `define_method`-installed `Proc`, unless both the dispatch and the body are shareable.
231
+
232
+ ## Status and support
233
+
234
+ Candor is maintained. It is pre-1.0, so the API can still change between minor versions, and only the
235
+ latest release gets fixes.
236
+
237
+ Ask a question or report a bug in [GitHub issues](https://github.com/svyatov/candor/issues). Report a
238
+ security problem privately through the
239
+ [advisory form](https://github.com/svyatov/candor/security/advisories/new) instead, as
240
+ [SECURITY.md](SECURITY.md) describes.
188
241
 
189
242
  ## Contributing
190
243
 
191
- Bug reports and pull requests are welcome on [GitHub](https://github.com/svyatov/candor). See
192
- [CONTRIBUTING.md](CONTRIBUTING.md).
244
+ Pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) has the setup and test commands, and
245
+ states what an acceptable change has to satisfy. [CHANGELOG.md](CHANGELOG.md) records what changed in
246
+ each release, and the API docs are at [rubydoc.info/gems/candor](https://rubydoc.info/gems/candor).
193
247
 
194
248
  ## License
195
249
 
data/candor.gemspec CHANGED
@@ -8,8 +8,9 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Leonid Svyatov"]
9
9
  spec.email = ["leonid@svyatov.com"]
10
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 " \
11
+ spec.summary = "Turn a block or a callable into a real Ruby method that reports the body's own " \
12
+ "arity, parameters and source location."
13
+ spec.description = "Candor fabricates real methods from blocks and " \
13
14
  "callables: same arity, same parameters, source_location pointing at your code, and " \
14
15
  "allocation-free dispatch. Zero runtime dependencies."
15
16
  spec.homepage = "https://github.com/svyatov/candor"
@@ -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 converting it
16
- # would forge a +source_location+ nobody wrote.
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, body_error unless BODY_KINDS.any? { |kind| @body.is_a?(kind) }
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 body_error
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}; curried procs, `#call` objects and C-defined methods have none"
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]
@@ -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 {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.
78
+ # against {compile}, {render}, {method_name!}, {parameters!} and {source_location!} — which raise — not
79
+ # against the patterns and tables they are written in terms of. Only {KINDS} and {KEYWORD_BRANCH_LIMIT}
80
+ # name 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 or the parameter shape is malformed
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
- eval(render(parameters, name: name, via: via), binding, *source_location) # rubocop:disable Security/Eval
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]
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Candor
4
4
  # The gem version.
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.1"
6
6
  end
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 its +source_location+ is +nil+, or its owner is not an ancestor of
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, body: body || block).call
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+.
@@ -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
@@ -21,6 +21,7 @@ module Candor
21
21
  ?aliases: Array[Symbol],
22
22
  ?via: Symbol?,
23
23
  ?parameters: Array[Signature::parameter]?,
24
+ ?source_location: [ String, Integer ]?,
24
25
  ?body: body?
25
26
  ) ?{ (?) -> untyped } -> Symbol
26
27
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: candor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leonid Svyatov
@@ -9,9 +9,9 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
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.'
12
+ description: 'Candor fabricates real methods from blocks and callables: same arity,
13
+ same parameters, source_location pointing at your code, and allocation-free dispatch.
14
+ Zero runtime dependencies.'
15
15
  email:
16
16
  - leonid@svyatov.com
17
17
  executables: []
@@ -52,7 +52,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0'
54
54
  requirements: []
55
- rubygems_version: 4.0.12
55
+ rubygems_version: 4.0.16
56
56
  specification_version: 4
57
- summary: Turn a block or a callable into a real method with an honest signature.
57
+ summary: Turn a block or a callable into a real Ruby method that reports the body's
58
+ own arity, parameters and source location.
58
59
  test_files: []