class Incline::CliHelpers::Yaml::YamlContents

Helper class to process the YAML file contents easily.

Public Class Methods

new(content) click to toggle source

Creates a new YAML contents.

# File lib/incline/cli/helpers/yaml.rb, line 18
def initialize(content)
  @content = content.to_s.gsub("\r\n", "\n").strip + "\n"
end

Public Instance Methods

=~(regexp) click to toggle source

Allows comparing the contents against a regular expression.

# File lib/incline/cli/helpers/yaml.rb, line 518
def =~(regexp)
  @content =~ regexp
end
add_key(key, value, make_safe_value = true) click to toggle source

Adds a key to the YAML contents if it is missing. Does nothing to the key if it exists.

add_key [ "default", "name" ], "george"

The 'key' should be an array defining the path.

Value can be nil, a string, a symbol, a number, or a boolean.

The 'make_safe_value' option can be used to provide an explicit text value. This can be useful if you want to add a specific value, like an ERB command.

add_key [ "default", "name" ], "<%= ENV[\"DEFAULT_USER\"] %>", false

You can also use a hash for the value to specify advanced options. Currently only three advanced options are recognized.

The first option, :value, simply sets the value. If this is the only hash key provided, then the value supplied is treated as if it was the original value. In other words, only setting :value is the same as not using a hash and just passing in the value, so the value must be nil, a string, a symbol, a number, or a boolean.

The second option, :safe, works the opposite of the 'make_safe_value' parameter. If :safe is a non-false value, then it is like 'make_safe_value' is set to false. If :safe is a false value, then it is like 'make_safe_value' is set to true. The :safe value can be set to true and the :value option can set the value, or the :safe value can be set to the value directly since all strings are non-false.

The third option, :before_section, tells add_key to insert the section before the named section (if the new section doesn't exist). This can be useful if the named section is going to be referencing the key you are adding. Otherwise, when a section needs to be added, it gets added to the end of the file.

Returns the contents object.

# File lib/incline/cli/helpers/yaml.rb, line 64
def add_key(key, value, make_safe_value = true)

  # ensure the parent structure exists!
  if key.count > 1
    add_key(key[0...-1], nil)
  else
    # If the base key already exists, no need to look further.
    return self if @content =~ /^#{key.first}:/

    unless @content[0] == '#'
      @content = "# File modified by Incline v#{Incline::VERSION}.\n" + @content
    end
  end

  val_name = key.last

  # construct a regular expression to find the parent group and value.
  rex_str = '^('
  rex_prefix = /\A./
  key.each_with_index do |attr,level|
    lev = (level < 1 ? '' : ('\\s\\s' * (level)))
    if lev != ''
      rex_str += '(?:' + lev + '[^\\n]*\\n)*'
    end
    if level == key.count - 1
      if level == 0
        # At level 0 we cheat and use a very simple regular expression to confirm the section exists.
        rex_str = "^#{attr}:"
        # If it doesn't exists, the prefix regex will usually want to put the new section at the end of the
        # file.  However if the :before_section option is set, and the other section exists, then we
        # want to ensure that we are putting the new section before it.
        #
        # Down below we take care to reverse the replacement string when key.count == 1.
        rex_prefix =
          if value.is_a?(::Hash) && value[:before_section] && @content =~ /^#{value[:before_section]}:/
            /(^#{value[:before_section]}:)/
          else
            /(\z)/   # match the end of the contents.
          end
      else
        rex_str += ')'
        rex_prefix = Regexp.new(rex_str)
        rex_str += '(' + lev + attr + ':.*\\n)'
      end
    else
      rex_str += lev + attr + ':.*\\n'
    end
  end

  rex = Regexp.new(rex_str)

  if @content =~ rex
    # all good.
  elsif @content =~ rex_prefix
    if make_safe_value
      value = safe_value(value)
      value = add_value_offset(key, value)
    elsif value.is_a?(::Hash)
      value = value[:value]
    end
    value = '' if value =~ /\A\s*\z/
    # Should be true thanks to first step in this method.
    # Capture 1 would be the parent group.
    # When key.count == 1 then we want to put our new value before capture 1.
    # Otherwise we put our new value after capture 1.
    rep = if key.count == 1
            "\n#{val_name}:#{value}\n\\1"
          else
            "\\1#{'  ' * (key.count - 1)}#{val_name}:#{value}\n"
          end

    @content.gsub! rex_prefix, rep
  else
    raise ::Incline::CliHelpers::Yaml::YamlError, "Failed to create parent group for '#{key.join('/')}'."
  end

  self
end
add_key_with_comment(key, value, comment) click to toggle source

Adds a key to the YAML contents if it is missing. Does nothing to the key if it exists.

add_key_with_comment [ "default", "name" ], "george", "this is the name of the default user"

The 'key' should be an array defining the path. If the 'comment' is blank (nil or ''), then it will not modify the comment. Use a whitespace string (' ') to indicate that you want a blank comment added.

Value can be nil, a string, a symbol, a number, or a boolean. Value can also be a hash according to add_key.

Returns the contents object.

# File lib/incline/cli/helpers/yaml.rb, line 158
def add_key_with_comment(key, value, comment)
  if comment.to_s == ''
    add_key key, value
  else
    add_key key, value_with_comment(key, value, comment), false
  end
end
append_comment(text) click to toggle source

Appends a comment to the end of the contents.

# File lib/incline/cli/helpers/yaml.rb, line 531
def append_comment(text)
  text = '# ' + text.gsub("\r\n", "\n").gsub("\n", "\n# ") + "\n"
  unless @content[-1] == "\n"
    @content += "\n"
  end
  @content += text
end
insert_comment(text) click to toggle source

Inserts a comment to the beginning of the contents.

# File lib/incline/cli/helpers/yaml.rb, line 524
def insert_comment(text)
  text = '# ' + text.gsub("\r\n", "\n").gsub("\n", "\n# ") + "\n"
  @content = @content.insert(0, text)
end
level_comment_offset(level) click to toggle source

Gets the comment offset for the specified level.

The offset is based on the beginning of the level in question. There will always be at least one whitespace before the value and at least one whitespace between the value and a comment. For instance an offset of 15 for level 2 would be like this:

one:
  two: value     # comment
  some_long_name: value # comment
# 012345678901234^
# File lib/incline/cli/helpers/yaml.rb, line 495
def level_comment_offset(level)
  comment_offsets[level] || 0
end
level_value_offset(level) click to toggle source

Gets the value offset for the specified level.

The offset is based on the beginning of the level in question. There will always be at least one whitespace before the value. For instance an offset of 10 for level 2 would be like this:

one:
  two:      value
  some_long_name: value
# 0123456789^
# File lib/incline/cli/helpers/yaml.rb, line 462
def level_value_offset(level)
  value_offsets[level] || 0
end
realign!() click to toggle source

Realigns the file.

All values and comments will line up at each level when complete.

# File lib/incline/cli/helpers/yaml.rb, line 393
def realign!
  lines = extract_to_array(@content)

  # reset the offsets.
  value_offsets.clear
  comment_offsets.clear

  # get value offsets.
  lines.each do |line|
    level = line[:level]
    if level > 0 && line[:key]
      key_len = line[:key].length + 2   # include the colon and a space
      if key_len > level_value_offset(level)
        set_level_value_offset level, key_len
      end
    end
  end

  # get comment offsets.
  lines.each do |line|
    level = line[:level]
    if level > 0 && line[:value]
      voff = level_value_offset(level)
      val_len = line[:value] ? line[:value].length : 0
      coff = voff + val_len + 1         # add a space after the value.
      if coff > level_comment_offset(level)
        set_level_comment_offset level, coff
      end
    end
  end

  # convert the lines back into strings with proper spacing.
  lines = lines.map do |line|
    level = line[:level]
    if level > 0
      if line[:key]
        # a key: value line.
        key = line[:key] + ':'
        key = key.ljust(level_value_offset(level), ' ') unless line[:value].to_s == '' && line[:comment].to_s == ''
        val = line[:value].to_s
        val = val.ljust(level_comment_offset(level) - level_value_offset(level), ' ') unless line[:comment].to_s == ''
        comment = line[:comment] ? "# #{line[:comment]}" : ''
        ('  ' * (level - 1)) + key + val + comment
      else
        # just a comment line.
        ('  ' * (level - 1)) +
            (' ' * level_comment_offset(level)) +
            "# #{line[:comment]}"
      end
    else
      line[:value]  # return the original value
    end
  end

  @content = lines.join("\n") + "\n"
end
remove_key(key) click to toggle source

Removes the specified key from the contents.

Returns an array containing the contents of the key. The first element will be for the key itself. If the key had child keys, then they will also be included in the array.

The returned array will contain hashes for each removed key.

data = remove_key %w(pet dog)

[
  {
    :key => [ "pet", "dog" ],
    :value => "",
    :safe => true,
    :comment => "This list has the family dogs."
  },
  {
    :key => [ "pet", "dog", "sadie" ],
    :value => "",
    :safe => true,
    :comment => ""
  },
  {
    :key => [ "pet", "dog", "sadie", "breed" ],
    :value => "boxer",
    :safe => true,
    :comment => ""
  },
  {
    :key => [ "pet", "dog", "sadie", "dob" ],
    :value => "\"2016-06-01\"",
    :safe => true,
    :comment => "Estimated date of birth since she was a rescue."
  }
]

The returned hashes can be fired right back into add_key.

data.each do |item|
  add_key_with_comment item[:key], item, item[:comment]
end

This method can be used to move a section within the file.

# remove the 'familes' section from the file.
section = remove_key [ "families" ]
item = section.delete(section.first)

# add the 'familes' section back in before the 'pets' section.
add_key_with_comment item[:key], { before_section: "pets" }.merge(item), item[:comment]

# add the data back into the 'familes' section.
section.each do |item|
  add_key_with_comment item[:key], item, item[:comment]
end
# File lib/incline/cli/helpers/yaml.rb, line 305
def remove_key(key)
  rex_str = '(^'
  key.each_with_index do |attr,level|
    lev = (level < 1 ? '' : ('\\s\\s' * level))
    if lev != ''
      rex_str += '(?:' + lev + '.*\\n)*'
    end
    if level == key.count - 1
      if level == 0
        rex_str = '(^)(' + attr + ':[^\\n]*\\n(?:\\s\\s[^\\n]*\\n)*)'
      else
        rex_str += ')(' + lev + attr + ':[^\\n]*\\n(?:' + lev + '\\s\\s[^\\n]*\\n)*)'
      end
    else
      rex_str += lev + attr + ':[^\\n]*\\n'
    end
  end

  # match result 1 is the parent key structure leading up to the key to be extracted.
  # match result 2 is the key with all child elements to be extracted.

  rex = Regexp.new(rex_str)

  if @content =~ rex

    # cache the key contents
    key_content = $2

    # remove the key from the main contents.
    @content.gsub!(rex, "\\1")

    # and separate into lines.
    lines = extract_to_array(key_content)

    ret = []

    base_key = key.length == 1 ? [] : key[0...-1]

    last_line = nil

    lines.each do |line|
      level = line[:level]
      if level > 0 && line[:key]
        # go from base 1 to base 0
        level -= 1

        # make sure the base key is the right length for the current key.
        while level > base_key.length
          base_key.push '?' # hopefully this never occurs.
        end
        while level < base_key.length
          base_key.pop
        end

        # add our key to the base key.
        # if the next key is below it, this ensures the parent structure is correct.
        # if the next key is higher or at the same level the above loops should make it correct.
        base_key << line[:key]

        last_line = {
            key: base_key.dup,
            value: line[:value].to_s,
            comment: line[:comment],
            safe: true
        }

        ret << last_line
      elsif level > 0 && line[:comment]
        if last_line && last_line[:key].length == level
          if last_line[:comment]
            last_line[:comment] += "\n" + line[:comment]
          else
            last_line[:comment] = "\n" + line[:comment]
          end
        end
      end
    end

    ret
  else
    []
  end
end
set_key(key, value, make_safe_value = true) click to toggle source

Sets a key in the YAML contents. Adds the key if it is missing, replaces it if it already exists.

set_key [ "default", "name" ], "george"

The 'key' should be an array defining the path.

Value can be nil, a string, a symbol, a number, or a boolean. Value can also be a hash according to add_key.

The 'make_safe_value' option can be used to provide an explicit text value. This can be useful if you want to add a specific value, like an ERB command.

set_key [ "default", "name" ], "<%= ENV[\"DEFAULT_USER\"] %>", false

Returns the contents object.

# File lib/incline/cli/helpers/yaml.rb, line 183
def set_key(key, value, make_safe_value = true)

  # construct a regular expression to find the value and not confuse it with any other value in the file.
  rex_str = '^('
  key.each_with_index do |attr,level|
    lev = (level < 1 ? '' : ('\\s\\s' * (level)))
    if lev != ''
      rex_str += '(?:' + lev + '.*\\n)*'
    end
    if level == key.count - 1
      rex_str += lev + attr + ':)\\s*([^#\\n]*)?(#[^\\n]*)?\\n'
    else
      rex_str += lev + attr + ':.*\\n'
    end
  end

  rex = Regexp.new(rex_str)

  if @content =~ rex
    if make_safe_value
      value = safe_value(value)
      value = add_value_offset(key, value)
    elsif value.is_a?(::Hash)
      value = value[:value]
    end
    value = '' if value =~ /\A\s*\z/
    # Capture 1 is everything before the value.
    # Capture 2 is going to be just the value.
    # Capture 3 is the comment (if any).  This allows us to propagate comments if we change a value.
    if $2 != value
      rep = "\\1#{value}\\3\n"
      @content.gsub! rex, rep
    end

    self
  else
    add_key(key, value, make_safe_value)
  end
end
set_key_with_comment(key, value, comment) click to toggle source

Sets a key in the YAML contents. Adds the key if it is missing, replaces it if it already exists.

set_key_with_comment [ "default", "name" ], "george", "this is the name of the default user"

The 'key' should be an array defining the path. If the 'comment' is blank (nil or ''), then it will not modify the comment. Use a whitespace string (' ') to indicate that you want a blank comment added.

Value can be nil, a string, a symbol, a number, or a boolean. Value can also be a hash according to add_key.

Returns the contents object.

# File lib/incline/cli/helpers/yaml.rb, line 237
def set_key_with_comment(key, value, comment)
  if comment.to_s == ''
    set_key key, value
  else
    set_key key, value_with_comment(key, value, comment), false
  end
end
set_level_comment_offset(level, offset) click to toggle source

Sets the comment offset for the specified level.

The offset is based on the beginning of the level in question. There will always be at least one whitespace before the value and at least one whitespace between the value and a comment. For instance an offset of 15 for level 2 would be like this:

one:
  two: value     # comment
  some_long_name: value # comment
# 012345678901234^
# File lib/incline/cli/helpers/yaml.rb, line 512
def set_level_comment_offset(level, offset)
  comment_offsets[level] = offset
end
set_level_value_offset(level, offset) click to toggle source

Sets the value offset for the specified level.

The offset is based on the beginning of the level in question. There will always be at least one whitespace before the value. For instance an offset of 10 for level 2 would be like this:

one:
  two:      value
  some_long_name: value
# 0123456789^
# File lib/incline/cli/helpers/yaml.rb, line 478
def set_level_value_offset(level, offset)
  value_offsets[level] = offset
end
to_s() click to toggle source

Returns the YAML contents.

# File lib/incline/cli/helpers/yaml.rb, line 24
def to_s
  @content
end

Private Instance Methods

add_comment(key, safe_value, comment) click to toggle source
# File lib/incline/cli/helpers/yaml.rb, line 605
def add_comment(key, safe_value, comment)
  key_len = key.last.to_s.length + 1 # add one for the colon.
  coff = level_comment_offset(key.count) - key_len - safe_value.length
  coff = 1 if coff < 1
  coff_total = ((key.count - 1) * 2) + safe_value.length + coff

  safe_value + (' ' * coff) + '# ' + comment.to_s.gsub("\r\n", "\n").gsub("\n", "\n#{' ' * coff_total}# ")
end
add_value_offset(key, safe_value) click to toggle source
# File lib/incline/cli/helpers/yaml.rb, line 597
def add_value_offset(key, safe_value)
  key_len = key.last.to_s.length + 1 # add one for the colon.
  voff = level_value_offset(key.count) - key_len
  voff = 1 if voff < 1

  (' ' * voff) + safe_value
end
comment_offsets() click to toggle source
# File lib/incline/cli/helpers/yaml.rb, line 618
def comment_offsets
  @comment_offsets ||= [ ]
end
extract_to_array(data) click to toggle source
# File lib/incline/cli/helpers/yaml.rb, line 541
def extract_to_array(data)
  # match 1 = lead white
  # ([ \t]*)
  # match 2 = key name
  # (\S+):
  # match 3 = value with leading whitespace
  # ((?:[ \t]*(?:"(?:[^"]*(?:(?:\\{1}|\\{3}|\\{5}|\\{7}|\\{9})")?)*"|'(?:[^']*(?:(?:\\{1}|\\{3}|\\{5}|\\{7}|\\{9})')?)*'|[^\s#"']+))*)
  # ignore white between value and comment (if any).
  # [ \t]*
  # match 4 = comment (if any).
  # (?:#([^\n]*))?
  line_regex = /\A([ \t]*)(\S+):((?:[ \t]*(?:"(?:[^"]*(?:(?:\\{1}|\\{3}|\\{5}|\\{7}|\\{9})")?)*"|'(?:[^']*(?:(?:\\{1}|\\{3}|\\{5}|\\{7}|\\{9})')?)*'|[^\s#"']+))*)[ \t]*(?:#([^\n]*))?\z/
  last_level = 0
  data.split("\n").map do |raw_line|
    # assuming the file is valid any lines not matching the regex should be comments or blank.
    match = line_regex.match(raw_line)
    if match    # a key: value line
      last_level = (match[1].length / 2).to_i + 1 # one level per 2 spaces.
      {
          level: last_level,
          key: match[2].strip,
          value: match[3] ? match[3].strip : nil,
          comment: match[4] ? match[4].lstrip : nil
      }
    elsif raw_line =~ /\A(\s*)#(.*)\z/    # a comment
      whitespace = $1
      raw_line = $2
      level =
          if whitespace.length >= (last_level * 2)
            last_level
          else
            (whitespace.length / 2).to_i + 1
          end
      raw_line = raw_line[1..-1] if raw_line[0] == ' '
      {
          level: level,
          comment: raw_line
      }
    else
      {
          level: 0,
          value: raw_line
      }
    end
  end
end
safe_value(value) click to toggle source

always returns a string, even for safe values.

# File lib/incline/cli/helpers/yaml.rb, line 623
def safe_value(value)
  # Allows the user to specify the value as a hash option without marking it as safe.
  if value.is_a?(::Hash) && value[:value] && !value[:safe]
    value = value[:value]
  end

  if value.is_a?(::Hash) && value[:safe]
    # If the user specifies a safe value, return it as-is.
    (value[:value] || value[:safe]).to_s
  elsif value.nil?
    # If the value is nil, return an empty string.
    ''
  else
    # Otherwise process the value to make it YAML compliant.
    unless value.is_a?(::String) || value.is_a?(::Symbol) || value.is_a?(::Numeric) || value.is_a?(::TrueClass) || value.is_a?(::FalseClass)
      raise ArgumentError, "'value' must be a value type (string, symbol, number, boolean)"
    end

    if value.is_a?(::String)
      if value =~ /\A\s*\z/m  ||                # Empty or filled with whitespace
          value =~ /\A\s/m    ||                # Starts with whitespace
          value =~ /\s\z/m    ||                # Ends with whitespace
          value =~ /\A[+-]?\d+(\.\d*)?\z/ ||    # Contains a probable number
          value =~ /\A0(b[01]*|x[0-9a-f]*)\z/i  # Another probable number in binary or hex format.
        value.inspect
      elsif value =~ /\A([0-9]*[a-z]|[a-z])([a-z0-9_ .,=-]*[a-z0-9_])*\z/i
        value
      else
        value.inspect
      end
    else
      value.inspect
    end
  end

end
value_offsets() click to toggle source
# File lib/incline/cli/helpers/yaml.rb, line 614
def value_offsets
  @value_offsets ||= [ ]
end
value_with_comment(key, value, comment) click to toggle source
# File lib/incline/cli/helpers/yaml.rb, line 588
def value_with_comment(key, value, comment)
  vh = value.is_a?(::Hash) ? value : { safe: false, value: value, before_section: nil }
  unless vh[:safe]
    vh[:value] = add_comment(key, add_value_offset(key, safe_value(vh[:value])), comment)
    vh[:safe] = true
  end
  vh
end