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
MAGIC_KEYS =

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.

%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

#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_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



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

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.



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/audition/static/source_file.rb', line 162

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?