Module: Hegel::DrawName

Defined in:
lib/hegel/draw_name.rb,
sig/hegel.rbs

Overview

Recovers a drawn value's variable name from the caller's own source (see docs/adr/0005), so a failure report can print n = 501 instead of draw = 501 when the caller never passed a label:. This module only answers "what name does path:lineno assign the value a draw call there produced, when that call is the whole assigned value"; deciding whether to call it, what counts as a draw call, and what to fall back to when it answers nil, is Hegel::TestCase#name_for's job, not this one's.

Constant Summary collapse

ASSIGNMENT_NODE_TYPES =

Node types #for treats as "this line names a drawn value". An explicit list, not every Prism::Node subclass whose name ends in WriteNode, because a wrong guess here (e.g. matching a constant assignment as if it named a draw) would misname a report entry -- the one outcome this feature must never risk. Local and instance variables both expose the assigned name the same way (a Symbol, including the leading "@" for an ivar), so one code path reads both.

Returns:

  • (Array[untyped])
[Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode].freeze

Class Method Summary collapse

Class Method Details

.assignment_nodes(node, matches = []) ⇒ Array[untyped]

Every ASSIGNMENT_NODE_TYPES node under node, found by walking the whole tree: a drawn value's assignment can be nested arbitrarily deep (inside a block, a method body, a conditional), and this module has no way to know which nesting level to expect it at.

Parameters:

  • node (Object)
  • matches (Array[untyped]) (defaults to: [])

Returns:

  • (Array[untyped])


98
99
100
101
102
# File 'lib/hegel/draw_name.rb', line 98

def assignment_nodes(node, matches = [])
  matches << node if ASSIGNMENT_NODE_TYPES.include?(node.class)
  node.compact_child_nodes.each { |child| assignment_nodes(child, matches) }
  matches
end

.cacheHash[String, untyped]

Returns:

  • (Hash[String, untyped])


78
79
80
# File 'lib/hegel/draw_name.rb', line 78

def cache
  @cache ||= {}
end

.covers?(location, lineno) ⇒ Boolean

Range containment, not a start-line match: Ruby's own caller_locations reports the line a call's own last token is written on, and a multi-line assignment's start line can differ from that -- e.g. n = tc\n .draw_integer(...) reports the second line, but the assignment node's own start_line is the first.

Parameters:

  • location (Object)
  • lineno (Integer)

Returns:

  • (Boolean)


109
110
111
# File 'lib/hegel/draw_name.rb', line 109

def covers?(location, lineno)
  (location.start_line..location.end_line).cover?(lineno)
end

.encloses?(outer, inner) ⇒ Boolean

Whether outer strictly contains inner, by byte offset rather than by line: two assignments on one line share both line numbers, and only the offsets tell them apart from a genuine nesting.

Returns:

  • (Boolean)


116
117
118
119
120
121
# File 'lib/hegel/draw_name.rb', line 116

def encloses?(outer, inner)
  return false if outer.equal?(inner)

  outer.location.start_offset <= inner.location.start_offset &&
    inner.location.end_offset <= outer.location.end_offset
end

.for(path, lineno, call_names) ⇒ String?

The name path:+lineno+ assigns a drawn value to, or nil when that cannot be answered confidently: path cannot be read, Prism cannot parse it, the assignments covering lineno do not single one out, or the one they single out does not assign a draw call directly. call_names is the set of method names that count as a draw call (Hegel::TestCase::DRAW_METHOD_NAMES); this module has no opinion of its own about which methods draw.

A wrong name would misdirect a reader of the failure report more than a missing one would, so an ambiguous line returns nil rather than guessing between candidates. That is also why a singled-out assignment still answers nil when its value is built from a draw rather than being the draw itself, as in xs = [tc.draw(integers)] or n = tc.draw(integers).abs: the recovered name would describe the Array or the Integer#abs result, not the drawn value the report actually prints next to it.

Several assignments can cover one line by nesting rather than by ambiguity. error = assert_raises do ... n = tc.draw_integer(...) ... end puts the draw inside both, and a reader has no doubt which one names it. So the innermost wins: an enclosing assignment is discarded whenever another candidate sits inside it. Two assignments written side by side on one line contain neither the other, nothing singles one out, and the answer is nil.

Parameters:

  • path (String)
  • lineno (Integer)
  • call_names (Array[Symbol])

Returns:

  • (String, nil)


49
50
51
52
53
54
55
56
57
58
59
# File 'lib/hegel/draw_name.rb', line 49

def for(path, lineno, call_names)
  program = parse(path)
  return nil unless program

  matches = assignment_nodes(program).select { |node| covers?(node.location, lineno) }
  innermost = matches.reject { |node| matches.any? { |other| encloses?(node, other) } }
  return nil unless innermost.one?

  node = innermost.first
  (node.value.is_a?(Prism::CallNode) && call_names.include?(node.value.name)) ? node.name.to_s : nil
end

.parse(path) ⇒ Object

+path+'s parsed Prism::ProgramNode, or nil if it could not be read or parsed. Cached either way, success or nil, because a single failure report names every draw the final replay recorded against the same one file, and nothing about that file changes between those lookups.

Parameters:

  • path (String)

Returns:

  • (Object)


74
75
76
# File 'lib/hegel/draw_name.rb', line 74

def parse(path)
  cache.fetch(path) { cache[path] = read_and_parse(path) }
end

.read_and_parse(path) ⇒ Object

Reads and parses path. Returns nil, not the ParseResult, for either failure mode #for's caller cares about: path raising SystemCallError (a nonexistent path -- eval, "-e", irb, or a file removed since the caller was compiled) and Prism reporting a syntax error (#success? false) for a path that did exist.

Parameters:

  • path (String)

Returns:

  • (Object)


87
88
89
90
91
92
# File 'lib/hegel/draw_name.rb', line 87

def read_and_parse(path)
  result = Prism.parse(File.read(path))
  result.success? ? result.value : nil
rescue SystemCallError
  nil
end

.reset_cachevoid

This method returns an undefined value.

Drops every cached parse. #for's only state; a fresh process would never need this, but a test process that calls #for many times over the life of the suite does, both to isolate one test's fixture file from another's and to prove #parse only reads a given path once (see #parse).



66
67
68
# File 'lib/hegel/draw_name.rb', line 66

def reset_cache
  @cache = {}
end