class Bashcov::Xtrace

This class manages xtrace output.

@see Runner

Constants

DEPTH_CHAR
String

Character that will be used to indicate the nesting level of

+xtrace+d instructions
FIELDS
Array<String>

A collection of Bash internal variables to expand in the

{PS4}
PREFIX
String

Prefix used in PS4 to identify relevant output

PS4_START_REGEXP

Regexp to match the beginning of the {.ps4}. {DEPTH_CHAR} will be repeated in proportion to the level of Bash call nesting.

Attributes

delimiter[W]
ps4[W]

Public Class Methods

delimiter() click to toggle source
String

A randomly-generated UUID used for delimiting the fields of

the PS4.

# File lib/bashcov/xtrace.rb, line 29
def delimiter
  @delimiter ||= SecureRandom.uuid
end
make_ps4(*fields) click to toggle source

@return [String] a {delimiter}-separated String suitable for use as

+PS4+
# File lib/bashcov/xtrace.rb, line 43
def make_ps4(*fields)
  fields.reduce(DEPTH_CHAR + PREFIX) do |memo, field|
    memo + delimiter + field
  end + delimiter
end
new(field_stream) click to toggle source

Creates a pipe for xtrace output. @see stackoverflow.com/questions/6977562/pipe-vs-temporary-file

# File lib/bashcov/xtrace.rb, line 56
def initialize(field_stream)
  @field_stream = field_stream

  @read, @write = IO.pipe

  # Tracks coverage for each file under test
  @files ||= {}

  # Stacks for updating working directory changes
  @pwd_stack ||= []
  @oldpwd_stack ||= []
end
ps4() click to toggle source

@return [String] PS4 variable used for xtrace output. Expands to

internal Bash variables +BASH_SOURCE+, +PWD+, +OLDPWD+, and +LINENO+,
delimited by {delimiter}.

@see www.gnu.org/software/bash/manual/bashref.html#index-PS4

# File lib/bashcov/xtrace.rb, line 37
def ps4
  @ps4 ||= make_ps4(*FIELDS)
end

Public Instance Methods

close() click to toggle source

Closes the pipe for writing. @return [void]

# File lib/bashcov/xtrace.rb, line 76
def close
  @write.close
end
file_descriptor() click to toggle source

@return [Fixnum] File descriptor of the write end of the pipe

# File lib/bashcov/xtrace.rb, line 70
def file_descriptor
  @write.fileno
end
read() click to toggle source

Read fields extracted from Bash’s debugging output @return [Hash<Pathname, Array<Integer, nil>>] A hash mapping Bash scripts

to Simplecov-style coverage stats
# File lib/bashcov/xtrace.rb, line 83
def read
  @field_stream.read = @read

  field_count = FIELDS.length
  fields = @field_stream.each(
    self.class.delimiter, field_count, PS4_START_REGEXP
  )

  # +take(field_count)+ would be more natural here, but doesn't seem to
  # play nicely with +Enumerator+s backed by +IO+ objects.
  loop do
    break if (hit = (1..field_count).map { fields.next }).empty?

    parse_hit!(*hit)
  end

  @read.close unless @read.closed?

  @files
end

Private Instance Methods

find_script(bash_source) click to toggle source

Scans entries in the PWD stack, checking whether +entry/$BASH_SOURCE+ refers to an existing file. Scans the stack in reverse on the assumption that more-recent entries are more plausible candidates for base directories from which BASH_SOURCE can be reached. @param [Pathname] bash_source expanded BASH_SOURCE @return [Pathname] the resolved path to bash_source, if it exists;

otherwise, +bash_source+ cleaned of redundant slashes and dots
# File lib/bashcov/xtrace.rb, line 154
def find_script(bash_source)
  script = @pwd_stack.reverse.map { |wd| wd + bash_source }.find(&:file?)

  return bash_source.cleanpath if script.nil?

  begin
    script.realpath
  rescue Errno::ENOENT # catch race condition if the file has been deleted
    bash_source.cleanpath
  end
end
parse_hit!(lineno, *paths) click to toggle source

Parses the expanded {ps4} fields and updates the coverage-tracking {@files} hash @overload parse_hit!(lineno, bash_source, pwd, oldpwd)

@param [String]  lineno       expanded +LINENO+
@param [Pathname] bash_source expanded +BASH_SOURCE+
@param [Pathname] pwd         expanded +PWD+
@param [Pathname] oldpwd      expanded +OLDPWD+

@return [void] @raise [XtraceError] when lineno is not composed solely of digits,

indicating that something has gone wrong with parsing the +PS4+ fields
# File lib/bashcov/xtrace.rb, line 116
def parse_hit!(lineno, *paths)
  # If +LINENO+ isn't a series of digits, something has gone wrong. Add
  # +@files+ to the exception in order to propagate the existing coverage
  # data back to the {Bashcov::Runner} instance.
  if /\A\d+\z/.match?(lineno)
    lineno = lineno.to_i
  elsif lineno == "${LINENO-}"
    # the variable doesn't expand on line misses so we can safely ignore it
    return
  else
    raise XtraceError.new(
      "expected integer for LINENO, got #{lineno.inspect}", @files
    )
  end

  # The next three fields will be $BASH_SOURCE, $PWD, $OLDPWD, and $LINENO
  bash_source, pwd, oldpwd = paths.map { |p| Pathname.new(p) }

  update_wd_stacks!(pwd, oldpwd)

  script = find_script(bash_source)

  # For one-liners, +LINENO+ == 0. Do this to avoid an +IndexError+;
  # one-liners will be culled from the coverage results later on.
  index = (lineno > 1 ? lineno - 1 : 0)

  @files[script] ||= []
  @files[script][index] ||= 0
  @files[script][index] += 1
end
update_wd_stacks!(pwd, oldpwd) click to toggle source

Updates the stacks that track the history of values for PWD and OLDPWD @param [Pathname] pwd expanded PWD @param [Pathname] oldpwd expanded OLDPWD @return [void]

# File lib/bashcov/xtrace.rb, line 171
def update_wd_stacks!(pwd, oldpwd)
  @pwd_stack[0] ||= pwd
  @oldpwd_stack[0] ||= oldpwd unless oldpwd.to_s.empty?

  # We haven't changed working directories; short-circuit.
  return if pwd == @pwd_stack[-1]

  # If the current +pwd+ is identical to the top of the +@oldpwd_stack+ and
  # the current +oldpwd+ is identical to the second-to-top entry, then a
  # previous cd/pushd has been undone.
  if pwd == @oldpwd_stack[-1] && oldpwd == @oldpwd_stack[-2]
    @pwd_stack.pop
    @oldpwd_stack.pop
  else # New cd/pushd
    @pwd_stack << pwd
    @oldpwd_stack << oldpwd
  end
end