Class: Audition::Static::SourceFile

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

Overview

A parsed Ruby file plus the magic comments that change Ractor semantics.

Constant Summary collapse

CONST_MUTATORS =

Method names that read as in-place data mutation when sent to a constant. Shared by the mutable-constants check and the fixers: a constant this file mutates is a deliberate accumulator (sinatra's PARAMS_CONFIG) and must never be frozen by magic comment or wrap.

%i[
  []= << push unshift concat merge! replace
].freeze
INDEX_WRITES =

Index writes are their own node types, not calls: COUNTS[k] += 1 is IndexOperatorWriteNode and CACHE[k] ||= v is IndexOrWriteNode; missing them let a .freeze autofix produce FrozenError at runtime.

[
  Prism::IndexOperatorWriteNode,
  Prism::IndexOrWriteNode,
  Prism::IndexAndWriteNode,
  Prism::IndexTargetNode
].freeze
CONST_CUSTOMIZERS =

Calls that give a constant's object singleton behavior; freezing the object first makes them raise FrozenError (NULL = Object.new; def NULL.to_s = "null").

%i[
  extend define_singleton_method instance_eval instance_exec
  singleton_class instance_variable_set
].freeze
MAGIC_KEYS =
%w[
  encoding coding frozen_string_literal
  shareable_constant_value warn_indent
].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source:, path:) ⇒ SourceFile

Returns a new instance of SourceFile.



16
17
18
19
20
# File 'lib/audition/static/source_file.rb', line 16

def initialize(source:, path:)
  @source = source
  @path = path
  @parse_result = Prism.parse(source)
end

Instance Attribute Details

#parse_resultObject (readonly)

Returns the value of attribute parse_result.



10
11
12
# File 'lib/audition/static/source_file.rb', line 10

def parse_result
  @parse_result
end

#pathObject (readonly)

Returns the value of attribute path.



10
11
12
# File 'lib/audition/static/source_file.rb', line 10

def path
  @path
end

#sourceObject (readonly)

Returns the value of attribute source.



10
11
12
# File 'lib/audition/static/source_file.rb', line 10

def source
  @source
end

Class Method Details

.read(path) ⇒ Object



12
13
14
# File 'lib/audition/static/source_file.rb', line 12

def self.read(path)
  new(source: File.read(path), path: path)
end

Instance Method Details

#boot_insertionObject

Where a hoisted require line can be inserted: right after the last existing top-level require, or after the leading comment block (shebang and magic comments), or at the top.



82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/audition/static/source_file.rb', line 82

def boot_insertion
  last_require = root.statements.body.rfind do |s|
    s.is_a?(Prism::CallNode) && s.receiver.nil? &&
      %i[require require_relative].include?(s.name)
  end
  if last_require
    newline = raw.index("\n", last_require.location.end_offset)
    offset = newline ? newline + 1 : raw.bytesize
    {offset: offset, after_require: true}
  else
    {offset: leading_comments_end, after_require: false}
  end
end

#constant_receiver(node) ⇒ Object



218
219
220
221
222
223
# File 'lib/audition/static/source_file.rb', line 218

def constant_receiver(node)
  case node
  when Prism::ConstantReadNode then node.name.to_s
  when Prism::ConstantPathNode then node.location.slice
  end
end

#customized_constantsArray<String>

Returns names of constants that receive a singleton method definition or a customizing call.

Returns:

  • (Array<String>)

    names of constants that receive a singleton method definition or a customizing call



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/audition/static/source_file.rb', line 197

def customized_constants
  @customized_constants ||= begin
    names = []
    queue = [root]
    until queue.empty?
      node = queue.shift
      queue.concat(node.child_nodes.compact)
      receiver =
        if node.is_a?(Prism::DefNode)
          node.receiver
        elsif node.is_a?(Prism::CallNode) &&
            CONST_CUSTOMIZERS.include?(node.name)
          node.receiver
        end
      name = constant_receiver(receiver)
      names << name if name
    end
    names.uniq
  end
end

#first_statement_offsetObject



41
42
43
44
# File 'lib/audition/static/source_file.rb', line 41

def first_statement_offset
  first = root.statements.body.first
  first ? first.location.start_offset : source.bytesize
end

#frozen_constantsArray<String>

Keys Ruby actually honors. Prism reports every comment shaped like # key: value, which sweeps up documentation (# I18n.t: 'date.formats.short'); inserting after those would land a magic comment mid-file. Constants frozen by a bare NAME.freeze statement at the same lexical level as a file, class, or module body: the build-then-freeze shape. A freeze inside a method does not count; nothing guarantees it runs before a Ractor reads the constant.

Returns:

  • (Array<String>)


161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/audition/static/source_file.rb', line 161

def frozen_constants
  @frozen_constants ||= begin
    names = []
    bodies = [root.statements]
    until bodies.empty?
      statements = bodies.shift
      next unless statements.is_a?(Prism::StatementsNode)

      statements.body.each do |node|
        case node
        when Prism::ModuleNode, Prism::ClassNode,
             Prism::SingletonClassNode
          bodies << node.body
        when Prism::CallNode
          next unless node.name == :freeze &&
            node.arguments.nil? && node.block.nil?

          name = constant_receiver(node.receiver)
          names << name if name
        end
      end
    end
    names.uniq
  end
end

#frozen_string_literal?Boolean

String literals in this file are frozen (and therefore shareable when they contain no interpolation).

Returns:

  • (Boolean)


48
49
50
# File 'lib/audition/static/source_file.rb', line 48

def frozen_string_literal?
  magic_comment("frozen_string_literal") == "true"
end

#leading_comments_endObject



249
250
251
252
253
254
255
256
257
# File 'lib/audition/static/source_file.rb', line 249

def leading_comments_end
  offset = 0
  source.each_line do |line|
    break unless line.start_with?("#")

    offset += line.bytesize
  end
  offset
end

#line_at(number) ⇒ Object



61
62
63
64
65
66
# File 'lib/audition/static/source_file.rb', line 61

def line_at(number)
  @lines ||= source.lines.map do |line|
    line.dup.force_encoding(Encoding::UTF_8).scrub
  end
  @lines[number - 1]&.strip
end

#magic_comment(key) ⇒ Object

Ruby matches magic-comment keys and values case-insensitively and ignores magic comments that appear after the first statement (verified on 4.0); Prism reports them all, so both rules are applied here.



32
33
34
35
36
37
38
39
# File 'lib/audition/static/source_file.rb', line 32

def magic_comment(key)
  limit = first_statement_offset
  comment = parse_result.magic_comments.find do |mc|
    mc.key_loc.slice.downcase == key &&
      mc.key_loc.start_offset < limit
  end
  comment&.value_loc&.slice&.downcase
end

#magic_insertion_offsetObject

Where a new magic comment can go: after the shebang and any existing magic comments, before code.



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/audition/static/source_file.rb', line 232

def magic_insertion_offset
  offset = 0
  if raw.start_with?("#!")
    newline = raw.index("\n")
    offset = newline ? newline + 1 : raw.bytesize
  end
  limit = first_statement_offset
  after_magic = parse_result.magic_comments.filter_map do |mc|
    next unless MAGIC_KEYS.include?(mc.key_loc.slice.downcase)
    next unless mc.key_loc.start_offset < limit

    newline = raw.index("\n", mc.value_loc.end_offset)
    newline ? newline + 1 : raw.bytesize
  end.max
  [offset, after_magic || 0].max
end

#mutated_constantsArray<String>

Returns names of constants that receive a mutator call somewhere in this file.

Returns:

  • (Array<String>)

    names of constants that receive a mutator call somewhere in this file



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/audition/static/source_file.rb', line 125

def mutated_constants
  @mutated_constants ||= begin
    names = []
    queue = [root]
    until queue.empty?
      node = queue.shift
      queue.concat(node.child_nodes.compact)
      receiver =
        if node.is_a?(Prism::CallNode) &&
            CONST_MUTATORS.include?(node.name)
          node.receiver
        elsif INDEX_WRITES.any? { |type| node.is_a?(type) }
          node.receiver
        end

      case receiver
      when Prism::ConstantReadNode
        names << receiver.name.to_s
      when Prism::ConstantPathNode
        names << receiver.location.slice
      end
    end
    names.uniq
  end
end

#rawObject

Prism reports byte offsets; index math against the source must run on a binary copy or multibyte content shifts every computed position.



99
100
101
# File 'lib/audition/static/source_file.rb', line 99

def raw
  @raw ||= source.dup.force_encoding(Encoding::BINARY)
end

#rootObject



26
# File 'lib/audition/static/source_file.rb', line 26

def root = parse_result.value

#shareable_constants?Boolean

# shareable_constant_value: literal|experimental_everything| experimental_copy makes constant values deeply frozen and shareable at parse time, so constant checks are moot. (v1 treats the comment as file-wide.)

Returns:

  • (Boolean)


56
57
58
59
# File 'lib/audition/static/source_file.rb', line 56

def shareable_constants?
  value = magic_comment("shareable_constant_value")
  !value.nil? && value != "none"
end

#syntax_errorsObject



24
# File 'lib/audition/static/source_file.rb', line 24

def syntax_errors = parse_result.errors

#top_level_requiresObject

Literal feature strings required by top-level statements.



69
70
71
72
73
74
75
76
77
# File 'lib/audition/static/source_file.rb', line 69

def top_level_requires
  @top_level_requires ||= root.statements.body.filter_map do |s|
    next unless s.is_a?(Prism::CallNode) && s.receiver.nil?
    next unless %i[require require_relative].include?(s.name)

    arg = s.arguments&.arguments&.first
    arg.unescaped if arg.is_a?(Prism::StringNode)
  end
end

#valid_syntax?Boolean

Returns:

  • (Boolean)


22
# File 'lib/audition/static/source_file.rb', line 22

def valid_syntax? = parse_result.success?