Module: Audition::Rewriters::Memoization
- Defined in:
- lib/audition/rewriters.rb
Overview
Rewrites singleton-scope memoization, both idioms:
@x ||= expr
return @x if defined?(@x); @x = expr
Preferred strategy is freeze-on-memoize, the pattern Rails
core applies to its own code: the memoization stays exactly
as written and only the memoized value becomes shareable
(.freeze appended; Ractor.make_shareable for containers).
Non-main Ractors may then read the ivar once it has been
computed; the first write must still happen on the main
Ractor, which is a boot-warming concern the static check
reports as an info note. Chosen when the value expression
carries no block (a proxy for one-time side effects) and no
writes exist outside the memo sites.
Otherwise falls back to Ractor-local storage:
@x ||= expr -> Ractor.store_if_absent(:"Klass/@x") { expr }
@x = expr -> Ractor.current[:"Klass/@x"] = expr
@x -> Ractor.current[:"Klass/@x"]
Only when every reference in singleton scope is visible in this file, none sit directly in the class body, and no compound writes exist. Caveat (why this is unsafe): freezing changes value mutability, and each Ractor computes its own copy under store_if_absent.
Defined Under Namespace
Classes: SingletonIvars
Class Method Summary collapse
- .accumulator?(value) ⇒ Boolean
- .blockless?(node) ⇒ Boolean
-
.deletion(source, node) ⇒ Object
Deletes the guard statement together with its line and any blank lines that follow, so the method body does not open with a hole.
-
.freezable?(ops, memos) ⇒ Boolean
Freeze-on-memoize applies when every write is a memo site (a stray write means cache invalidation; frozen values cannot support that) and no value needs a block to build.
-
.freeze_edits(file, memos) ⇒ Object
One edit per memo site: make the memoized value shareable while leaving the memoization intact.
-
.freeze_value(value, kind) ⇒ Object
Plain
.freezeonly where the value is provably a string; everything unproven gets Ractor.make_shareable, which is a no-op for already-shareable values. - .frozen_call?(value) ⇒ Boolean
- .guarded_with_strays?(ops, memos) ⇒ Boolean
-
.memo_sites(ops) ⇒ Object
A memo site is an ||= write, or a plain write paired with a defined? return guard inside the same method.
- .orphan_guards?(ops, memos) ⇒ Boolean
-
.parens?(value) ⇒ Boolean
.freezebinds tighter than operators and ternaries, so compound expressions get wrapped; message sends and plain literals do not need it. - .plan(file, findings) ⇒ Object
-
.ractor_edits(file, ops, memos, key) ⇒ Object
Two Ractor-local flavors.
-
.store_wrap(source, node, key) ⇒ Object
A single-line value keeps the brace form; a multi-line value becomes a do..end block with the body shifted one level right, so the rewrite stays idiomatic.
Class Method Details
.accumulator?(value) ⇒ Boolean
193 194 195 196 |
# File 'lib/audition/rewriters.rb', line 193 def self.accumulator?(value) (value.is_a?(Prism::ArrayNode) || value.is_a?(Prism::HashNode)) && value.elements.empty? end |
.blockless?(node) ⇒ Boolean
229 230 231 232 233 234 235 236 237 238 239 240 |
# File 'lib/audition/rewriters.rb', line 229 def self.blockless?(node) queue = [node] until queue.empty? current = queue.shift if current.is_a?(Prism::BlockNode) || current.is_a?(Prism::LambdaNode) return false end queue.concat(current.child_nodes.compact) end true end |
.deletion(source, node) ⇒ Object
Deletes the guard statement together with its line and any blank lines that follow, so the method body does not open with a hole. Falls back to the bare node span when other code shares the line.
392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 |
# File 'lib/audition/rewriters.rb', line 392 def self.deletion(source, node) raw = source.dup.force_encoding(Encoding::BINARY) from = node.location.start_offset upto = node.location.end_offset start = from.zero? ? 0 : (raw.rindex("\n", from - 1) || -1) + 1 if raw[start...from].match?(/\A[ \t]*\z/n) newline = raw.index("\n", upto) stop = newline ? newline + 1 : raw.length loop do newline = raw.index("\n", stop) break unless newline break unless raw[stop...newline].match?(/\A[ \t]*\z/n) stop = newline + 1 end Autofix.new(start_offset: start, end_offset: stop, replacement: "", safety: :unsafe) else Autofix.new(start_offset: from, end_offset: upto, replacement: "", safety: :unsafe) end end |
.freezable?(ops, memos) ⇒ Boolean
Freeze-on-memoize applies when every write is a memo site (a stray write means cache invalidation; frozen values cannot support that) and no value needs a block to build.
221 222 223 224 225 226 227 |
# File 'lib/audition/rewriters.rb', line 221 def self.freezable?(ops, memos) memo_ops = memos.map { |memo| memo[:op] } writes = ops.select { |op| op[:kind] == :write } return false unless (writes - memo_ops).empty? memos.all? { |memo| blockless?(memo[:op][:node].value) } end |
.freeze_edits(file, memos) ⇒ Object
One edit per memo site: make the memoized value shareable while leaving the memoization intact. Guards, sibling reads, and method shapes stay untouched.
245 246 247 248 249 250 251 252 253 |
# File 'lib/audition/rewriters.rb', line 245 def self.freeze_edits(file, memos) classifier = Static::LiteralClassifier.new( frozen_string_literal: file.frozen_string_literal? ) memos.filter_map do |memo| value = memo[:op][:node].value freeze_value(value, classifier.classify(value)) end end |
.freeze_value(value, kind) ⇒ Object
Plain .freeze only where the value is provably a string;
everything unproven gets Ractor.make_shareable, which is a
no-op for already-shareable values. This matters for
memoized classes (multi_json memoizes adapter classes):
.freeze on a Class freezes the class object and later
ivar writes on it raise FrozenError.
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 |
# File 'lib/audition/rewriters.rb', line 261 def self.freeze_value(value, kind) return nil if kind == :shareable # Freezing or wrapping a sync primitive raises; leave the # finding in place for a human. return nil if kind == :sync_primitive return nil if frozen_call?(value) slice = value.location.slice replacement = if kind == :mutable_string parens?(value) ? "(#{slice}).freeze" : "#{slice}.freeze" else "Ractor.make_shareable(#{slice})" end Autofix.new( start_offset: value.location.start_offset, end_offset: value.location.end_offset, replacement: replacement, safety: :unsafe ) end |
.frozen_call?(value) ⇒ Boolean
283 284 285 286 287 |
# File 'lib/audition/rewriters.rb', line 283 def self.frozen_call?(value) value.is_a?(Prism::CallNode) && value.name == :freeze && value.receiver && value.arguments.nil? end |
.guarded_with_strays?(ops, memos) ⇒ Boolean
198 199 200 201 202 203 204 205 |
# File 'lib/audition/rewriters.rb', line 198 def self.guarded_with_strays?(ops, memos) return false if memos.none? { |memo| memo[:guard] } memo_ops = memos.map { |memo| memo[:op] } ops.any? do |op| op[:kind] == :write && !memo_ops.include?(op) end end |
.memo_sites(ops) ⇒ Object
A memo site is an ||= write, or a plain write paired with a defined? return guard inside the same method. A guarded method with more than one write is nobody's memoization; such groups are dropped by the orphan check below.
180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/audition/rewriters.rb', line 180 def self.memo_sites(ops) guards = ops.select { |op| op[:kind] == :guard } ops.filter_map do |op| case op[:kind] when :or_write {op: op, guard: nil} when :write guard = guards.find { |g| g[:def_id] == op[:def_id] } {op: op, guard: guard} if guard end end end |
.orphan_guards?(ops, memos) ⇒ Boolean
207 208 209 210 211 212 213 214 215 216 |
# File 'lib/audition/rewriters.rb', line 207 def self.orphan_guards?(ops, memos) used = memos.filter_map { |m| m[:guard] } guards = ops.select { |op| op[:kind] == :guard } return true if guards.size != used.size writes = ops.select { |op| op[:kind] == :write } guards.any? do |guard| writes.count { |w| w[:def_id] == guard[:def_id] } != 1 end end |
.parens?(value) ⇒ Boolean
.freeze binds tighter than operators and ternaries, so
compound expressions get wrapped; message sends and plain
literals do not need it.
292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'lib/audition/rewriters.rb', line 292 def self.parens?(value) case value when Prism::CallNode !value.name.to_s.match?(/\A[a-z_]/i) when Prism::StringNode, Prism::InterpolatedStringNode, Prism::ArrayNode, Prism::HashNode, Prism::ConstantReadNode, Prism::ConstantPathNode false else true end end |
.plan(file, findings) ⇒ Object
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/rewriters.rb', line 141 def self.plan(file, findings) return [] if findings.none? { |f| f.check == "class-level-state" } collector = SingletonIvars.new collector.visit(file.root) edits = [] collector.groups.each do |(namespace, name), ops| next if namespace.empty? kinds = ops.map { |op| op[:kind] } next if kinds.include?(:other) next if ops.any? { |op| op[:body] } memos = memo_sites(ops) next if memos.empty? next if orphan_guards?(ops, memos) next if guarded_with_strays?(ops, memos) # `@x ||= {}` is a lazily-built accumulator, mutated # through the accessor after memoization (jwt's # algorithm registry). Freezing it breaks registration # and Ractor-local copies would leave other Ractors an # empty registry; the copy-on-write refactor is human # work, so no edit is offered. next if memos.any? { |m| accumulator?(m[:op][:node].value) } if freezable?(ops, memos) edits.concat(freeze_edits(file, memos)) else key = %(:"#{namespace}/#{name}") edits.concat(ractor_edits(file, ops, memos, key)) end end edits end |
.ractor_edits(file, ops, memos, key) ⇒ Object
Two Ractor-local flavors. With stray writes present (cache
invalidation, @x = nil), memo sites become
Ractor.current[key] ||= expr: it recomputes after a nil
reset exactly like the original ||=, where
store_if_absent would treat the stored nil as present and
never recompute (this broke i18n's reserved_keys_pattern).
Without strays, store_if_absent keeps its atomic lazy
init. Guard-idiom groups with strays are skipped entirely
in plan: the defined? guard caches nil deliberately and
neither flavor reproduces that alongside invalidation.
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 |
# File 'lib/audition/rewriters.rb', line 315 def self.ractor_edits(file, ops, memos, key) guarded = memos.filter_map { |m| m[:op] if m[:guard] } memo_ops = memos.map { |memo| memo[:op] } strays = ops.any? do |op| op[:kind] == :write && !memo_ops.include?(op) end edits = memos.filter_map do |memo| deletion(file.source, memo[:guard][:node]) if memo[:guard] end ops.each do |op| node = op[:node] edits << if op[:kind] == :or_write || guarded.include?(op) replacement = if strays value = node.value.location.slice "Ractor.current[#{key}] ||= #{value}" else store_wrap(file.source, node, key) end Autofix.new( start_offset: node.location.start_offset, end_offset: node.location.end_offset, replacement: replacement, safety: :unsafe ) elsif op[:kind] == :write Autofix.new( start_offset: node.name_loc.start_offset, end_offset: node.name_loc.end_offset, replacement: "Ractor.current[#{key}]", safety: :unsafe ) else Autofix.new( start_offset: node.location.start_offset, end_offset: node.location.end_offset, replacement: "Ractor.current[#{key}]", safety: :unsafe ) end end edits end |
.store_wrap(source, node, key) ⇒ Object
A single-line value keeps the brace form; a multi-line value becomes a do..end block with the body shifted one level right, so the rewrite stays idiomatic. When the write shares its line with other code, layout cannot be inferred and the brace form is kept as-is.
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 |
# File 'lib/audition/rewriters.rb', line 365 def self.store_wrap(source, node, key) value = node.value.location.slice call = "Ractor.store_if_absent(#{key})" return "#{call} { #{value} }" unless value.include?("\n") raw = source.dup.force_encoding(Encoding::BINARY) from = node.location.start_offset start = from.zero? ? 0 : (raw.rindex("\n", from - 1) || -1) + 1 indent = raw[start...from].force_encoding(source.encoding) return "#{call} { #{value} }" unless indent.match?(/\A[ \t]*\z/) body = value.lines.map.with_index do |line, index| if index.zero? "#{indent} #{line}" elsif line.match?(/\A\s*\z/) line else " #{line}" end end.join "#{call} do\n#{body}\n#{indent}end" end |