Class: Candor::Signature
- Inherits:
-
Object
- Object
- Candor::Signature
- Defined in:
- lib/candor/signature.rb
Overview
Compiles a method's parameters into a dispatch lambda carrying the same parameter kinds, in the
same order, and therefore the same arity.
Rendering switches on a parameter's kind, never its name. Method#parameters reports names that
cannot be used as parameters (+:_1+), names that are reserved words (+:end+), and entries with no
name at all — [[:req]], for both it and a destructuring parameter. A renderer that echoed names
would die on most real shapes, and on it it would fail silently, emitting arity 0. So every
positional, rest, keyrest and block parameter gets a generated name; only keyword names survive,
because a keyword name is the calling convention.
Those surviving names are the only user input in the generated source, and Ruby lets a parameter
shadow anything the source would otherwise reach. So the lambda closes over nothing it could lose:
the canonical name is a Symbol literal, binding() is called with parentheses a local cannot
shadow, and every generated local is prefixed with a run of underscores no keyword name starts
with. Only the sentinel survives as a free variable, and it is created by the rendered source
itself, under that same collision-free prefix.
The lambda is rendered on one line, so +eval+'s line forges its source_location exactly.
The call site it forwards to is one of two shapes. With via it is via(:name, args…), an
interceptor that receives the canonical name; without, it is name(args…), calling the body
directly. Either way the target is a method name interpolated into +eval+'d source, so
Signature.method_name! gates it first.
Candor::Signature.compile([%i[req a]], name: :greet, via: :__call, source_location: loc)
# => ->(__p0) { __call(:greet, __p0) }
Constant Summary collapse
- KEYWORD_BRANCH_LIMIT =
How many optional keywords still dispatch through call sites rather than a Hash. Each one doubles the sites, so four is where a shape nobody writes stops paying for one everybody does.
2- KINDS =
Every kind
Method#parameterscan emit, and the only ones render accepts. DECLARATIONS.keys.freeze
Class Method Summary collapse
-
.compile(parameters, name:, source_location:, via: nil) ⇒ Proc
+eval+'s file and line forge the lambda's — and therefore the compiled method's —
source_locationonto the body the caller supplied. -
.method_name!(name) ⇒ Symbol
The call site is interpolated into +eval+'d source, so its name is a trust boundary rather than a typo check.
-
.parameters!(parameters) ⇒ Array<Array>
A parameter shape reaching Signature.render from a consumer's override never passed Ruby's parser, so every kind and every keyword name is checked here rather than discovered as a
SyntaxError. -
.render(parameters, name:, via: nil) ⇒ String
Single-line source for a lambda forwarding to the call site.
-
.source_location!(source_location) ⇒ Array(String, Integer)
Unlike the two gates above, the location is never interpolated into the generated source:
evaltakes it as a file and a line, not as code.
Instance Method Summary collapse
-
#initialize(parameters, name, via = nil) ⇒ Signature
constructor
A new instance of Signature.
-
#source ⇒ String
The sentinel marking an unpassed optional is a local, not a constant:
Module#const_getpiercesprivate_constant, so a caller could obtain the sentinel and pass it as an argument, silently defeating an optional's default.
Constructor Details
#initialize(parameters, name, via = nil) ⇒ Signature
Returns a new instance of Signature.
196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/candor/signature.rb', line 196 def initialize(parameters, name, via = nil) self.class.parameters!(parameters) @target = self.class.method_name!(via || name) # With an interceptor the canonical name leads the argument list, as a literal rather than a free # variable a keyword could capture. @literal = via ? name.to_sym.inspect : nil @prefix = collision_free_prefix(parameters) @unset = "#{@prefix}u" @hash = "#{@prefix}k" @entries = entries(parameters) @optionals = @entries.select { |entry| OPTIONAL_KINDS.include?(entry.first) } end |
Class Method Details
.compile(parameters, name:, source_location:, via: nil) ⇒ Proc
+eval+'s file and line forge the lambda's — and therefore the compiled method's —
source_location onto the body the caller supplied.
95 96 97 98 99 100 101 102 103 |
# File 'lib/candor/signature.rb', line 95 def compile(parameters, name:, source_location:, via: nil) source = render(parameters, name: name, via: via) eval(source, binding, *source_location!(source_location)) # rubocop:disable Security/Eval rescue SyntaxError => e # {parameters!} sees one entry at a time; only the parser sees the combination — two rests, a # duplicate keyword, a block before a positional. A +SyntaxError+ is a +ScriptError+, which a # consumer's +rescue+ never catches. raise ArgumentError, "malformed parameters: #{parameters.inspect} (#{e..lines.first.strip})" end |
.method_name!(name) ⇒ Symbol
The call site is interpolated into +eval+'d source, so its name is a trust boundary rather than a typo check.
118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/candor/signature.rb', line 118 def method_name!(name) string = name.to_s # +:class+ is callable on a receiver and useless here: the generated source calls the site bare, # and a bare +class(…)+ is a +SyntaxError+. Say that, rather than "not a callable method name". if RESERVED_WORDS.include?(string) raise ArgumentError, "#{name.inspect} is a reserved word: the generated source would call it bare" end raise ArgumentError, "not a callable method name: #{name.inspect}" unless METHOD_NAME.match?(string) string.to_sym end |
.parameters!(parameters) ⇒ Array<Array>
A parameter shape reaching render from a consumer's override never passed Ruby's parser, so
every kind and every keyword name is checked here rather than discovered as a SyntaxError.
136 137 138 139 140 141 142 143 144 145 |
# File 'lib/candor/signature.rb', line 136 def parameters!(parameters) raise ArgumentError, "malformed parameters: #{parameters.inspect}" unless parameters.is_a?(Array) # +index+, not +find+: a +nil+ entry is malformed, and +find+ would hand back the same +nil+ it # returns when every entry is fine. The caller passed the Array; naming the offender saves a bisect. index = parameters.index { |entry| !valid_entry?(entry) } raise ArgumentError, "malformed parameters: entry #{index} is #{parameters[index].inspect}" if index parameters end |
.render(parameters, name:, via: nil) ⇒ String
Returns single-line source for a lambda forwarding to the call site.
110 |
# File 'lib/candor/signature.rb', line 110 def render(parameters, name:, via: nil) = new(parameters, name, via).source |
.source_location!(source_location) ⇒ Array(String, Integer)
Unlike the two gates above, the location is never interpolated into the generated source: eval
takes it as a file and a line, not as code. So this is a shape check rather than a trust boundary
— with one edge that bites. +eval+'s line is a C int, and one outside LINE_RANGE raises
RangeError from inside compile. A consumer reading a shape off an already-installed body only
reaches that compile after installing it, so the range is checked here, where it is still cheap.
156 157 158 159 160 161 162 |
# File 'lib/candor/signature.rb', line 156 def source_location!(source_location) file, line = source_location if source_location.is_a?(Array) && source_location.size == 2 return source_location if file.is_a?(String) && line.is_a?(Integer) && LINE_RANGE.cover?(line) raise ArgumentError, "malformed source_location: #{source_location.inspect}; " \ "expected a [String, Integer] pair, the line within #{LINE_RANGE}" end |
Instance Method Details
#source ⇒ String
The sentinel marking an unpassed optional is a local, not a constant: Module#const_get pierces
private_constant, so a caller could obtain the sentinel and pass it as an argument, silently
defeating an optional's default.
214 |
# File 'lib/candor/signature.rb', line 214 def source = "#{@unset} = ::Object.new.freeze; ->(#{declarations}) { #{dispatch} }" |