Class: Candor::Signature

Inherits:
Object
  • Object
show all
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#parameters can emit, and the only ones render accepts.

DECLARATIONS.keys.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(parameters, name, via = nil) ⇒ Signature

Returns a new instance of Signature.

Parameters:

  • parameters (Array<Array>)
  • name (Symbol)
  • via (Symbol, nil) (defaults to: nil)


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.

Parameters:

  • parameters (Array<Array>)

    a compiled method's parameters; a Proc's would report every positional as :opt and silently destroy arity strictness

  • name (Symbol)

    the method to call, or — with via — the canonical name handed to it

  • source_location (Array(String, Integer))

    the body's

  • via (Symbol, nil) (defaults to: nil)

    an interceptor method taking (name, ...); omit to call name directly

Returns:

  • (Proc)

    a lambda taking the body's parameter kinds and forwarding to the call site

Raises:

  • (ArgumentError)

    if the call site's name, the parameter shape or the location is malformed



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.message.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.

Parameters:

  • name (Symbol, String)

Returns:

  • (Symbol)

    the name

Raises:

  • (ArgumentError)

    unless the name can be called with parentheses



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.

Parameters:

  • parameters (Array<Array>)

Returns:

  • (Array<Array>)

    the shape

Raises:

  • (ArgumentError)

    unless every entry is a known kind, with a usable name where one is required



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.

Parameters:

  • parameters (Array<Array>)
  • name (Symbol)
  • via (Symbol, nil) (defaults to: nil)

Returns:

  • (String)

    single-line source for a lambda forwarding to the call site

Raises:

  • (ArgumentError)

    if the call site's name or the parameter shape is malformed



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.

Parameters:

  • source_location (Array(String, Integer))

Returns:

  • (Array(String, Integer))

    the location

Raises:

  • (ArgumentError)

    unless it is a [String, Integer] pair whose line eval will take



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

#sourceString

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.

Returns:

  • (String)


214
# File 'lib/candor/signature.rb', line 214

def source = "#{@unset} = ::Object.new.freeze; ->(#{declarations}) { #{dispatch} }"