Class: Audition::Static::LiteralClassifier

Inherits:
Object
  • Object
show all
Defined in:
lib/audition/static/literal_classifier.rb

Overview

Classifies a Prism expression node by Ractor shareability:

:shareable         proven deeply shareable
:mutable_string    unfrozen String literal
:mutable_container Array/Hash/Set literal or constructor
:mutable_call      unfrozen String, Regexp, or sentinel
                 Object returned by a call (`.tr`,
                 `format`, `Regexp.new`, `Object.new`)
:fresh_container   unfrozen Array or Hash a core method
                 allocates on any receiver (`.map`,
                 `.keys`, `.merge`, `X + [..]`, `.dup`)
:unshareable_object a Method or UnboundMethod, never
                 shareable even frozen
:shallow_freeze    frozen container with mutable elements
:sync_primitive    Mutex/Queue/... constructor
:proc              lambda or proc
:default_proc      Hash.new with a block; the block
                 survives .freeze and stays unshareable
:instance_new      unfrozen instance of an arbitrary class
:opaque_call       method call with an unprovable return
:shallow_opaque    frozen at the top, but what it holds
                 is an unprovable call result
:unknown           cannot tell statically

Constant Summary collapse

SYNC_PRIMITIVES =
%w[
  Mutex Monitor Queue SizedQueue ConditionVariable
  Thread::Mutex Thread::Queue Thread::SizedQueue
  Thread::ConditionVariable
].freeze
SHAREABLE_FACTORIES =
%w[Struct Class Module].freeze
SENTINEL_FACTORIES =

Sentinels: NOT_GIVEN = Object.new raises when read from a worker until frozen, and a frozen bare Object is shareable (verified on Ruby 4.0.6). BasicObject has no #freeze.

%w[Object BasicObject].freeze
UNFREEZABLE_COLLECTIONS =

Concurrent::Map defines no #freeze, so make_shareable raises NoMethodError on it; a constant holding one can never be shared (verified on 4.0.6 with concurrent-ruby).

%w[Concurrent::Map].freeze
STRING_ONLY_METHODS =

Calls returning a fresh, unfrozen String or Regexp; # frozen_string_literal: true covers literals only. Both shapes (.tr and Regexp.new) turn up in constants in the wild. These names belong to String alone in core, so any receiver qualifies.

%i[
  tr tr_s gsub sub squeeze strip lstrip rstrip chomp chop
  center ljust rjust encode scrub unicode_normalize b
].freeze
STRINGIFIERS =

Fresh Strings off a literal receiver: to_s on anything but a String (String#to_s returns self), inspect on any literal, and a Regexp literal's source.

%i[to_s inspect].freeze
REGEXP_LITERAL_METHODS =
%i[source to_s inspect].freeze
STRING_LITERAL_METHODS =

Unambiguous only on a String literal receiver: Symbols and numbers define these too and return shareable values.

%i[
  + * % upcase downcase capitalize swapcase reverse dup
  succ next +@
].freeze
ARRAY_CHAIN_METHODS =

Array methods that hand back another Array, so a .join at the end of the chain still joins the literal it started from ([MAJOR, MINOR, PRE].compact.join(".")).

%i[
  compact map collect flatten uniq sort sort_by reverse
  reject select filter flat_map take drop rotate shuffle
  grep grep_v first last
].freeze
FORMATTERS =
%i[format sprintf].freeze
REGEXP_FACTORIES =
%i[new union compile].freeze
FILE_PATH_METHODS =

File methods returning a fresh path String.

%i[
  expand_path join dirname basename absolute_path realpath
].freeze
SHAREABLE_RETURNS =

Calls returning a shareable primitive on any receiver.

%i[
  to_i to_int to_f to_r to_c to_sym size length count
  bytesize ord hash
].freeze
FRESH_ARRAY_METHODS =

Return-type contracts of core methods, executed against the running Ruby by literal_classifier_spec so no entry can drift from what Ruby does. Each list names methods whose result, on every core receiver that defines them, is a newly allocated object of the stated kind.

%i[
  map collect flat_map collect_concat filter_map grep grep_v
  partition sort sort_by uniq flatten reverse rotate shuffle
  zip product take drop take_while drop_while entries keys
  values values_at
].freeze
FRESH_HASH_METHODS =
%i[
  merge invert transform_values transform_keys tally group_by
  except
].freeze
FRESH_CONTAINER_METHODS =

Array on an Array or Range, Hash on a Hash; fresh either way.

%i[select filter reject compact].freeze
FRESH_STRING_ARRAYS =

Arrays of freshly allocated Strings: a .freeze on the result is shallow.

%i[chars lines split scan].freeze
SHAREABLE_ELEMENT_ARRAYS =

Arrays of Integers: a .freeze on the result is enough.

%i[bytes codepoints].freeze
METHOD_OBJECTS =

Method objects are unshareable and freezing does not help (verified on Ruby 4.0.6).

%i[
  method instance_method public_method public_instance_method
].freeze
COMPARISONS =

Booleans, nil, or an Integer on any receiver.

%i[== != < > <= >= <=> === !].freeze
ARITHMETIC =

Numeric on a Numeric receiver, whatever the operand.

%i[+ - * / % ** << >> & | ^].freeze
OPERAND_TYPED =

Numeric on any receiver given a numeric literal operand: no core container accepts a number there. Array and String repeat with *, String formats with %, Array appends with <<, so those three stay unproven.

(ARITHMETIC - %i[* % <<]).freeze
SET_OPERATORS =

Operators whose result takes its type from a literal operand: X + [..] is an Array, X + "s" a String.

%i[+ - | &].freeze
NUMERIC_LITERALS =
[
  Prism::IntegerNode, Prism::FloatNode, Prism::RationalNode,
  Prism::ImaginaryNode
].freeze
SORBET_CASTS =

Sorbet's inline casts, which return their value argument.

%i[let cast must].freeze

Instance Method Summary collapse

Constructor Details

#initialize(frozen_string_literal:) ⇒ LiteralClassifier

Returns a new instance of LiteralClassifier.

Parameters:

  • frozen_string_literal (Boolean) —

    whether the file has the frozen_string_literal magic comment



135
136
137
# File 'lib/audition/static/literal_classifier.rb', line 135

def initialize(frozen_string_literal:)
  @frozen_string_literal = frozen_string_literal
end

Instance Method Details

#array_root(node) ⇒ Object

The Array literal a call chain starts from, when every link keeps it an Array; nil for any other receiver.



240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/audition/static/literal_classifier.rb', line 240

def array_root(node)
  loop do
    case node
    when Prism::ArrayNode
      return node
    when Prism::CallNode
      return nil unless ARRAY_CHAIN_METHODS.include?(node.name)

      node = node.receiver
    else
      return nil
    end
  end
end

#begin_value(node) ⇒ Object

A begin block's value is its last statement. A rescue, else, or ensure clause can supply a different one, so only the plain form resolves.



191
192
193
194
195
196
197
# File 'lib/audition/static/literal_classifier.rb', line 191

def begin_value(node)
  return node unless node.is_a?(Prism::BeginNode) &&
    node.rescue_clause.nil? && node.else_clause.nil? &&
    node.ensure_clause.nil?

  node.statements&.body&.last || node
end

#cast_value(node) ⇒ Object

The value inside a Sorbet inline cast, or nil. The cast returns its argument, so fixes and type names belong on the value, not the cast.



213
214
215
216
217
218
219
# File 'lib/audition/static/literal_classifier.rb', line 213

def cast_value(node)
  return nil unless node.is_a?(Prism::CallNode)
  return nil unless const_name(node.receiver) == "T" &&
    SORBET_CASTS.include?(node.name)

  node.arguments&.arguments&.first
end

#classify(node) ⇒ Symbol

Returns classification, see class docs.

Parameters:

  • node (Prism::Node) —

    an expression node

Returns:

  • (Symbol) —

    classification, see class docs



141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/audition/static/literal_classifier.rb', line 141

def classify(node)
  node = begin_value(node)
  case node
  when Prism::IntegerNode, Prism::FloatNode,
       Prism::RationalNode, Prism::ImaginaryNode,
       Prism::SymbolNode, Prism::InterpolatedSymbolNode,
       Prism::TrueNode, Prism::FalseNode, Prism::NilNode,
       Prism::RegularExpressionNode,
       Prism::InterpolatedRegularExpressionNode
    :shareable
  when Prism::StringNode
    @frozen_string_literal ? :shareable : :mutable_string
  when Prism::InterpolatedStringNode
    classify_interpolated_string(node)
  when Prism::ArrayNode, Prism::HashNode,
       Prism::KeywordHashNode
    container_kind(node)
  when Prism::RangeNode
    ends = [node.left, node.right].compact
    if ends.all? { |n| classify(n) == :shareable }
      :shareable
    else
      :unknown
    end
  when Prism::LambdaNode
    :proc
  when Prism::CallNode
    classify_call(node)
  when Prism::IfNode
    ternary_kind(node)
  else
    :unknown
  end
end

#const_name(node) ⇒ Object

::Mutex and Mutex are the same constant for matching purposes; the leading colons are stripped.



201
202
203
204
205
206
207
208
# File 'lib/audition/static/literal_classifier.rb', line 201

def const_name(node)
  case node
  when Prism::ConstantReadNode
    node.name.to_s
  when Prism::ConstantPathNode
    node.location.slice.delete_prefix("::")
  end
end

#fresh_elements(node) ⇒ Object

Whether a fresh container's elements are provably mutable (split strings), provably shareable (bytes), or unknown.



294
295
296
297
298
299
# File 'lib/audition/static/literal_classifier.rb', line 294

def fresh_elements(node)
  return :mutable if FRESH_STRING_ARRAYS.include?(node.name)
  return :shareable if SHAREABLE_ELEMENT_ARRAYS.include?(node.name)

  :unknown
end

#fresh_string_owner(node) ⇒ Object

The core class whose method a fresh-string call names, for the finding's display: Array#join, Symbol#to_s, String#tr.



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/audition/static/literal_classifier.rb', line 257

def fresh_string_owner(node)
  receiver = node.receiver
  if node.name == :join && array_root(receiver)
    "Array"
  elsif receiver.is_a?(Prism::SymbolNode)
    "Symbol"
  elsif receiver.is_a?(Prism::RegularExpressionNode)
    "Regexp"
  elsif NUMERIC_LITERALS.any? { |type| receiver.is_a?(type) }
    "Integer"
  elsif receiver.is_a?(Prism::ArrayNode)
    "Array"
  elsif receiver.is_a?(Prism::HashNode)
    "Hash"
  else
    "String"
  end
end

#fresh_type(node) ⇒ Object

What a fresh-container call allocates, for display.



277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/audition/static/literal_classifier.rb', line 277

def fresh_type(node)
  name = node.name
  return "Hash" if FRESH_HASH_METHODS.include?(name)
  return "copy" if name == :dup
  if name == :each_with_object
    return literal_container_type(first_argument(node))
  end
  if SET_OPERATORS.include?(name)
    return literal_container_type(first_argument(node))
  end
  return "container" if FRESH_CONTAINER_METHODS.include?(name)

  "Array"
end

#frozen_kind(value) ⇒ Object

What value.freeze would classify as, for the check to choose a plain .freeze over a deep wrap: :shareable when every element is provably shareable, :shallow_freeze when one is provably mutable, :unknown otherwise.



225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/audition/static/literal_classifier.rb', line 225

def frozen_kind(value)
  case value
  when Prism::ArrayNode, Prism::HashNode,
       Prism::KeywordHashNode
    deep_classify(value.elements)
  when Prism::CallNode
    elements = set_elements(value)
    elements ? deep_classify(elements) : :unknown
  else
    :unknown
  end
end

#unwrap(node) ⇒ Object

The value an expression hands back once begin blocks and inline casts are peeled off.



178
179
180
181
182
183
184
185
186
# File 'lib/audition/static/literal_classifier.rb', line 178

def unwrap(node)
  loop do
    inner = begin_value(node)
    inner = cast_value(inner) || inner
    break node if inner.equal?(node)

    node = inner
  end
end