module Psychgus

Psychgus uses the core standard library {github.com/ruby/psych Psych} for working with YAML and extends it so that developers can easily style the YAML according to their needs. Thank you to the people that worked and continue to work hard on that project.

The name comes from the well-styled character Gus from the TV show Psych.

Create a Styler

First, we will create a {Styler}.

All you need to do is add +include Psychgus::Styler+ to a class.

Here is a complex {Styler} for the examples below:

require 'psychgus'

class BurgerStyler
  # Mix in methods needed for styling
  include Psychgus::Styler

  def initialize(sniffer=nil)
    if sniffer.nil?()
      @class_level = 0
      @class_position = 0
    else
      # For the Class Example
      @class_level = sniffer.level
      @class_position = sniffer.position
    end
  end

  # Style all nodes (Psych::Nodes::Node)
  def style(sniffer,node)
    # Remove "!ruby/object:..." for classes
    node.tag = nil if node.node_of?(:mapping,:scalar,:sequence)

    # This is another way to do the above
    #node.tag = nil if node.respond_to?(:tag=)
  end

  # Style aliases (Psych::Nodes::Alias)
  def style_alias(sniffer,node)
  end

  # Style maps (Psych::Nodes::Mapping)
  # - Hashes (key/value pairs)
  # - Example: "Burgers: Classic {}"
  def style_mapping(sniffer,node)
    parent = sniffer.parent

    if !parent.nil?()
      # BBQ
      node.style = Psychgus::MAPPING_FLOW if parent.node_of?(:scalar) &&
                                             parent.value.casecmp('BBQ') == 0
    end
  end

  # Style scalars (Psych::Nodes::Scalar)
  # - Any text (non-alias)
  def style_scalar(sniffer,node)
    parent = sniffer.parent

    # Single quote scalars that are not keys to a map
    # - "child_key?" is the same as "child_type == :key"
    node.style = Psychgus::SCALAR_SINGLE_QUOTED unless parent.child_key?()

    # Remove colon (change symbols into strings)
    node.value = node.value.sub(':','')

    # Change lettuce to spinach
    node.value = 'Spinach' if node.value.casecmp('Lettuce') == 0

    # Capitalize each word
    node.value = node.value.split(' ').map do |v|
      if v.casecmp('BBQ') == 0
        v.upcase()
      else
        v.capitalize()
      end
    end.join(' ')
  end

  # Style sequences (Psych::Nodes::Sequence)
  # - Arrays
  # - Example: "[Lettuce, Onions, Pickles, Tomatoes]"
  def style_sequence(sniffer,node)
    relative_level = (sniffer.level - @class_level) + 1

    node.style = Psychgus::SEQUENCE_FLOW if sniffer.level >= 4

    # Make "[Ketchup, Mustard]" a block for the Class Example
    node.style = Psychgus::SEQUENCE_BLOCK if relative_level == 7
  end
end

@example Hash example

require 'psychgus'

burgers = {
  :Burgers => {
    :Classic => {
      :Sauce  => %w(Ketchup Mustard),
      :Cheese => 'American',
      :Bun    => 'Sesame Seed'
    },
    :BBQ => {
      :Sauce  => 'Honey BBQ',
      :Cheese => 'Cheddar',
      :Bun    => 'Kaiser'
    },
    :Fancy => {
      :Sauce  => 'Spicy Wasabi',
      :Cheese => 'Smoked Gouda',
      :Bun    => 'Hawaiian'
    }
  },
  :Toppings => [
    'Mushrooms',
    %w(Lettuce Onions Pickles Tomatoes),
    [%w(Ketchup Mustard), %w(Salt Pepper)]
  ]
}
burgers[:Favorite] = burgers[:Burgers][:BBQ] # Alias

puts burgers.to_yaml(indent: 3,stylers: BurgerStyler.new,deref_aliases: true)

# Output:
# ---
# Burgers:
#    Classic:
#       Sauce: ['Ketchup', 'Mustard']
#       Cheese: 'American'
#       Bun: 'Sesame Seed'
#    BBQ: {Sauce: 'Honey BBQ', Cheese: 'Cheddar', Bun: 'Kaiser'}
#    Fancy:
#       Sauce: 'Spicy Wasabi'
#       Cheese: 'Smoked Gouda'
#       Bun: 'Hawaiian'
# Toppings:
# - 'Mushrooms'
# - ['Spinach', 'Onions', 'Pickles', 'Tomatoes']
# - [['Ketchup', 'Mustard'], ['Salt', 'Pepper']]
# Favorite:
#    Sauce: 'Honey BBQ'
#    Cheese: 'Cheddar'
#    Bun: 'Kaiser'

@example Class example

require 'psychgus'

class Burger
  attr_accessor :bun
  attr_accessor :cheese
  attr_accessor :sauce

  def initialize(sauce,cheese,bun)
    @bun = bun
    @cheese = cheese
    @sauce = sauce
  end

  # You can still use Psych's encode_with(), no problem
  #def encode_with(coder)
  #  coder['Bun'] = @bun
  #  coder['Cheese'] = @cheese
  #  coder['Sauce'] = @sauce
  #end
end

class Burgers
  include Psychgus::Blueberry

  attr_accessor :burgers
  attr_accessor :toppings
  attr_accessor :favorite

  def initialize()
    @burgers = {
      'Classic' => Burger.new(['Ketchup','Mustard'],'American','Sesame Seed'),
      'BBQ'     => Burger.new('Honey BBQ','Cheddar','Kaiser'),
      'Fancy'   => Burger.new('Spicy Wasabi','Smoked Gouda','Hawaiian')
    }

    @toppings = [
      'Mushrooms',
      %w(Lettuce Onions Pickles Tomatoes),
      [%w(Ketchup Mustard),%w(Salt Pepper)]
    ]

    @favorite = @burgers['BBQ'] # Alias
  end

  def psychgus_stylers(sniffer)
    return BurgerStyler.new(sniffer)
  end

  # You can still use Psych's encode_with(), no problem
  #def encode_with(coder)
  #  coder['Burgers'] = @burgers
  #  coder['Toppings'] = @toppings
  #  coder['Favorite'] = @favorite
  #end
end

burgers = Burgers.new
puts burgers.to_yaml(indent: 3,deref_aliases: true)

# Output:
# ---
# Burgers:
#    Classic:
#       Bun: 'Sesame Seed'
#       Cheese: 'American'
#       Sauce:
#       - 'Ketchup'
#       - 'Mustard'
#    BBQ: {Bun: 'Kaiser', Cheese: 'Cheddar', Sauce: 'Honey BBQ'}
#    Fancy:
#       Bun: 'Hawaiian'
#       Cheese: 'Smoked Gouda'
#       Sauce: 'Spicy Wasabi'
# Toppings:
# - 'Mushrooms'
# - ['Spinach', 'Onions', 'Pickles', 'Tomatoes']
# - [['Ketchup', 'Mustard'], ['Salt', 'Pepper']]
# Favorite:
#    Bun: 'Kaiser'
#    Cheese: 'Cheddar'
#    Sauce: 'Honey BBQ'

@example Emitting / Parsing examples

styler = BurgerStyler.new()
options = {:indentation=>3,:stylers=>styler,:deref_aliases=>true}
yaml = burgers.to_yaml(options)

# High-level emitting
Psychgus.dump(burgers,options)
Psychgus.dump_file('burgers.yaml',burgers,options)
burgers.to_yaml(options)

# High-level parsing
# - Because to_ruby() will be called, just use Psych:
#   - load(), load_file(), load_stream(), safe_load()

# Mid-level emitting
stream = Psychgus.parse_stream(yaml,stylers: styler,deref_aliases: true)

stream.to_yaml()

# Mid-level parsing
Psychgus.parse(yaml,stylers: styler,deref_aliases: true)
Psychgus.parse_file('burgers.yaml',stylers: styler,deref_aliases: true)
Psychgus.parse_stream(yaml,stylers: styler,deref_aliases: true)

# Low-level emitting
tree_builder = Psychgus::StyledTreeBuilder.new(styler,deref_aliases: true)
visitor = Psych::Visitors::YAMLTree.create(options,tree_builder)

visitor << burgers
visitor.tree.to_yaml

# Low-level parsing
parser = Psychgus.parser(stylers: styler,deref_aliases: true)

parser.parse(yaml)
parser.handler
parser.handler.root

@author Jonathan Bradley Whited @since 1.0.0

Constants

MAPPING_ANY
MAPPING_BLOCK
MAPPING_FLOW
MAP_ANY
MAP_BLOCK
MAP_FLOW
NODE_CLASS_ALIASES
OPTIONS_ALIASES
SCALAR_ANY
SCALAR_DOUBLE_QUOTED
SCALAR_FOLDED
SCALAR_LITERAL
SCALAR_PLAIN
SCALAR_SINGLE_QUOTED
SEQUENCE_ANY
SEQUENCE_BLOCK
SEQUENCE_FLOW
SEQ_ANY
SEQ_BLOCK
SEQ_FLOW
STREAM_ANY
STREAM_UTF16BE
STREAM_UTF16LE
STREAM_UTF8
VERSION

Version of this gem in “#.#.#” format

Public Class Methods

dump(object,io=nil,**options) click to toggle source

Convert object to YAML and dump to io.

object, io, and options are used like in Psych.dump so can be a drop-in replacement for Psych.

@param object [Object] the Object to convert to YAML and dump @param io [nil,IO,Hash] the IO to dump the YAML to or the options Hash; if nil, will use StringIO @param options [Hash] the options (or keyword args) to use; see {.dump_stream}

@return [String,Object] the result of converting object to YAML using the params

@see .dump_stream @see Psych.dump_stream

# File lib/psychgus.rb, line 386
def self.dump(object,io=nil,**options)
  return dump_stream(object,io: io,**options)
end
dump_file(filename,*objects,mode: 'w',perm: nil,opt: nil,**options) click to toggle source

Convert objects to YAML and dump to a file.

@example

Psychgus.dump_file('my_dir/my_file.yaml',my_object1,my_object2,mode: 'w:UTF-16',
                   stylers: MyStyler.new())
Psychgus.dump_file('my_file.yaml',my_object,stylers: [MyStyler1.new(),MyStyler2.new()])

@param filename [String] the name of the file (and path) to dump to @param objects [Object,Array<Object>] the Object(s) to convert to YAML and dump @param mode [String,Integer] the IO open mode to use; examples:

[+'w:UTF-8'+]  create a new file or truncate an existing file
               and use UTF-8 encoding;
[+'a:UTF-16'+] create a new file or append to an existing file
               and use UTF-16 encoding

@param perm [Integer] the permission bits to use (platform dependent) @param opt [Hash] Hash of keyword args to pass to +File.open()+ @param options [Hash] the options (or keyword args) to use; see {.dump_stream}

@see .dump_stream @see File.open @see IO.new @see ruby-doc.org/core/IO.html#method-c-new

# File lib/psychgus.rb, line 412
def self.dump_file(filename,*objects,mode: 'w',perm: nil,opt: nil,**options)
  opt = Hash(opt)

  File.open(filename,mode,perm,**opt) do |file|
    file.write(dump_stream(*objects,**options))
  end
end
dump_stream(*objects,io: nil,stylers: nil,deref_aliases: false,**options) click to toggle source

Convert objects to YAML and dump to io.

io and options are used like in Psych.dump so can be a drop-in replacement for Psych.

@param objects [Object,Array<Object>] the Object(s) to convert to YAML and dump @param io [nil,IO,Hash] the IO to dump the YAML to or the options Hash; if nil, will use StringIO @param stylers [nil,Styler,Array<Styler>] the Styler(s) to use when converting to YAML @param deref_aliases [true,false] whether to dereference aliases; output the actual value

instead of the alias

@param options [Hash] the options (or keyword args) to use when converting to YAML:

[+:indent+]      Alias for +:indentation+. +:indentation+ will override this.
[+:indentation+] Default: +2+.
                 Number of space characters used to indent.
                 Acceptable value should be in +0..9+ range, else ignored.
[+:line_width+]  Default: +0+ (meaning "wrap at 81").
                 Max character to wrap line at.
[+:canon+]       Alias for +:canonical+. +:canonical+ will override this.
[+:canonical+]   Default: +false+.
                 Write "canonical" YAML form (very verbose, yet strictly formal).
[+:header+]      Default: +false+.
                 Write +%YAML [version]+ at the beginning of document.

@return [String,Object] the result of converting object to YAML using the params

@see Psych.dump_stream @see OPTIONS_ALIASES

# File lib/psychgus.rb, line 446
def self.dump_stream(*objects,io: nil,stylers: nil,deref_aliases: false,**options)
  # If you call this method with only a Hash that uses symbols as keys,
  # then options will be set to the Hash, instead of objects.
  #
  # For example, the below will be stored in options, not objects:
  # - dump_stream({:coffee => {:roast => [],:style => []}})
  #
  # This if-statement is guaranteed because dump_stream([]) and dump_stream(nil)
  # will produce [[]] and [nil], which are not empty.
  #
  # dump_stream() w/o any args is the only problem, but resolved w/ [nil].
  if objects.empty?
    objects = options.empty? ? [nil] : [options]
    options = {}
  end

  if io.is_a?(Hash)
    options = io
    io = nil
  end

  if !options.empty?
    OPTIONS_ALIASES.each do |option_alias,actual_option|
      if options.key?(option_alias) && !options.key?(actual_option)
        options[actual_option] = options[option_alias]
      end
    end
  end

  visitor = Psych::Visitors::YAMLTree.create(options,StyledTreeBuilder.new(*stylers,
    deref_aliases: deref_aliases))

  if objects.empty?
    # Else, will throw a cryptic NoMethodError:
    # - "psych/tree_builder.rb:in `set_end_location':
    #    undefined method `end_line=' for nil:NilClass (NoMethodError)"
    #
    # This should never occur because of the if-statement at the top of this method.
    visitor << nil
  else
    objects.each do |object|
      visitor << object
    end
  end

  return visitor.tree.yaml(io,options)
end
hierarchy(*objects,**kargs) click to toggle source

Get a visual hierarchy of the levels as a String.

This is useful for determining the correct level/position when writing a {Styler}.

@example

require 'psychgus'

burgers = {
  :burgers => {
    :classic => {:sauce  => %w(Ketchup Mustard),
                 :cheese => 'American',
                 :bun    => 'Sesame Seed'},
    :bbq     => {:sauce  => 'Honey BBQ',
                 :cheese => 'Cheddar',
                 :bun    => 'Kaiser'},
    :fancy   => {:sauce  => 'Spicy Wasabi',
                 :cheese => 'Smoked Gouda',
                 :bun    => 'Hawaiian'}
  },
  :toppings => [
    'Mushrooms',
    %w(Lettuce Onions Pickles Tomatoes),
    [%w(Ketchup Mustard), %w(Salt Pepper)]
  ]
}

puts Psychgus.hierarchy(burgers)

# Output:
# ---
# (level:position):current_node - <parent:(parent_level:parent_position)>
# ---
# (1:1):Psych::Nodes::Stream - <root:(0:0)>
# (1:1):Psych::Nodes::Document - <stream:(1:1)>
# (1:1):Psych::Nodes::Mapping - <doc:(1:1)>
#  (2:1)::burgers - <map:(1:1)>
#   (3:1):Psych::Nodes::Mapping - <:burgers:(2:1)>
#    (4:1)::classic - <map:(3:1)>
#     (5:1):Psych::Nodes::Mapping - <:classic:(4:1)>
#      (6:1)::sauce - <map:(5:1)>
#       (7:1):Psych::Nodes::Sequence - <:sauce:(6:1)>
#        (8:1):Ketchup - <seq:(7:1)>
#        (8:2):Mustard - <seq:(7:1)>
#      (6:2)::cheese - <map:(5:1)>
#       (7:1):American - <:cheese:(6:2)>
#      (6:3)::bun - <map:(5:1)>
#       (7:1):Sesame Seed - <:bun:(6:3)>
#    (4:2)::bbq - <map:(3:1)>
#     (5:1):Psych::Nodes::Mapping - <:bbq:(4:2)>
#      (6:1)::sauce - <map:(5:1)>
#       (7:1):Honey BBQ - <:sauce:(6:1)>
#      (6:2)::cheese - <map:(5:1)>
#       (7:1):Cheddar - <:cheese:(6:2)>
#      (6:3)::bun - <map:(5:1)>
#       (7:1):Kaiser - <:bun:(6:3)>
#    (4:3)::fancy - <map:(3:1)>
#     (5:1):Psych::Nodes::Mapping - <:fancy:(4:3)>
#      (6:1)::sauce - <map:(5:1)>
#       (7:1):Spicy Wasabi - <:sauce:(6:1)>
#      (6:2)::cheese - <map:(5:1)>
#       (7:1):Smoked Gouda - <:cheese:(6:2)>
#      (6:3)::bun - <map:(5:1)>
#       (7:1):Hawaiian - <:bun:(6:3)>
#  (2:2)::toppings - <map:(1:1)>
#   (3:1):Psych::Nodes::Sequence - <:toppings:(2:2)>
#    (4:1):Mushrooms - <seq:(3:1)>
#    (4:2):Psych::Nodes::Sequence - <seq:(3:1)>
#     (5:1):Lettuce - <seq:(4:2)>
#     (5:2):Onions - <seq:(4:2)>
#     (5:3):Pickles - <seq:(4:2)>
#     (5:4):Tomatoes - <seq:(4:2)>
#    (4:3):Psych::Nodes::Sequence - <seq:(3:1)>
#     (5:1):Psych::Nodes::Sequence - <seq:(4:3)>
#      (6:1):Ketchup - <seq:(5:1)>
#      (6:2):Mustard - <seq:(5:1)>
#     (5:2):Psych::Nodes::Sequence - <seq:(4:3)>
#      (6:1):Salt - <seq:(5:2)>
#      (6:2):Pepper - <seq:(5:2)>

@param objects [Object,Array<Object>] the Object(s) to get a visual hierarchy of @param kargs [Hash] the keyword args to pass to {Stylers::HierarchyStyler} and to {dump_stream}

@return [String] the visual hierarchy of levels

@see Stylers::HierarchyStyler @see dump_stream

@since 1.2.0

# File lib/psychgus.rb, line 582
def self.hierarchy(*objects,**kargs)
  styler = Stylers::HierarchyStyler.new(**kargs)

  dump_stream(*objects,stylers: styler,**kargs)

  return styler.to_s
end
node_class(name) click to toggle source

Get a Class (constant) from Psych::Nodes.

Some +name+s have aliases:

:doc => :document
:map => :mapping
:seq => :sequence

@param name [Symbol,String] the name of the class from Psych::Nodes

@return [Class] a class from Psych::Nodes

@see Psych::Nodes @see NODE_CLASS_ALIASES

# File lib/psychgus.rb, line 322
def self.node_class(name)
  name = name.to_sym.capitalize

  actual_name = NODE_CLASS_ALIASES[name]
  name = actual_name unless actual_name.nil?

  return Psych::Nodes.const_get(name)
end
node_const(class_name,const_name,lenient=true) click to toggle source

Get a constant from a Psych::Nodes class (using {.node_class}).

@param class_name [Symbol,String] the name of the class to get using {.node_class} @param const_name [Symbol,String] the constant to get from the class @param lenient [true,false] if true, will return 0 if not const_defined?(), else raise an error

@return [Integer,Object] the constant value from the class (usually an int)

@see .node_class

# File lib/psychgus.rb, line 340
def self.node_const(class_name,const_name,lenient=true)
  node_class = node_class(class_name)
  const_name = const_name.to_sym.upcase

  return 0 if lenient && !node_class.const_defined?(const_name,true)
  return node_class.const_get(const_name,true)
end
parse(yaml,**kargs) click to toggle source

Parse yaml into a Psych::Nodes::Document.

If you're just going to call to_ruby(), then using this method is unnecessary, and the styler(s) will do nothing for you.

@param yaml [String] the YAML to parse @param kargs [Hash] the keyword args to use; see {.parse_stream}

@return [Psych::Nodes::Document] the parsed Document node

@see .parse_stream @see Psych.parse @see Psych::Nodes::Document

# File lib/psychgus.rb, line 603
def self.parse(yaml,**kargs)
  parse_stream(yaml,**kargs) do |node|
    return node
  end

  return false
end
parse_file(filename,fallback: false,mode: 'r:BOM|UTF-8',opt: nil,**kargs) click to toggle source

Parse a YAML file into a Psych::Nodes::Document.

If you're just going to call to_ruby(), then using this method is unnecessary, and the styler(s) will do nothing for you.

@param filename [String] the name of the YAML file (and path) to parse @param fallback [Object] the return value when nothing is parsed @param mode [String,Integer] the IO open mode to use; example: +'r:BOM|UTF-8'+ @param opt [Hash] Hash of keyword args to pass to +File.open()+ @param kargs [Hash] the keyword args to use; see {.parse_stream}

@return [Psych::Nodes::Document] the parsed Document node

@see .parse_stream @see Psych.parse_file @see Psych::Nodes::Document @see File.open @see IO.new

# File lib/psychgus.rb, line 629
def self.parse_file(filename,fallback: false,mode: 'r:BOM|UTF-8',opt: nil,**kargs)
  opt = Hash(opt)

  result = File.open(filename,mode,**opt) do |file|
    parse(file,filename: filename,**kargs)
  end

  return result || fallback
end
parse_stream(yaml,filename: nil,stylers: nil,deref_aliases: false,**options,&block) click to toggle source

Parse yaml into a Psych::Nodes::Stream for one document or for multiple documents in one YAML.

If you're just going to call to_ruby(), then using this method is unnecessary, and the styler(s) will do nothing for you.

@example

burgers = <<EOY
---
Burgers:
  Classic:
    BBQ: {Sauce: Honey BBQ, Cheese: Cheddar, Bun: Kaiser}
---
Toppings:
- [Mushrooms, Mustard]
- [Salt, Pepper, Pickles]
---
`Invalid`
EOY

i = 0

begin
  Psychgus.parse_stream(burgers,filename: 'burgers.yaml') do |document|
    puts "Document ##{i += 1}"
    puts document.to_ruby
  end
rescue Psych::SyntaxError => err
  puts "File: #{err.file}"
end

# Output:
#   Document #1
#   {"Burgers"=>{"Classic"=>{"BBQ"=>{"Sauce"=>"Honey BBQ", "Cheese"=>"Cheddar", "Bun"=>"Kaiser"}}}}
#   Document #2
#   {"Toppings"=>[["Mushrooms", "Mustard"], ["Salt", "Pepper", "Pickles"]]}
#   File: burgers.yaml

@param yaml [String] the YAML to parse @param filename [String] the filename to pass as file to the Error potentially raised @param stylers [nil,Styler,Array<Styler>] the Styler(s) to use when parsing the YAML @param deref_aliases [true,false] whether to dereference aliases; output the actual value

instead of the alias

@param block [Proc] an optional block for parsing multiple documents

@return [Psych::Nodes::Stream] the parsed Stream node

@see StyledDocumentStream @see Psych.parse_stream @see Psych::Nodes::Stream @see Psych::SyntaxError

# File lib/psychgus.rb, line 689
def self.parse_stream(yaml,filename: nil,stylers: nil,deref_aliases: false,**options,&block)
  if block_given?
    parser = Psych::Parser.new(StyledDocumentStream.new(*stylers,deref_aliases: deref_aliases,**options,
      &block))

    return parser.parse(yaml,filename)
  else
    parser = self.parser(stylers: stylers,deref_aliases: deref_aliases,**options)
    parser.parse(yaml,filename)

    return parser.handler.root
  end
end
parser(stylers: nil,deref_aliases: false,**options) click to toggle source

Create a new styled Psych::Parser for parsing YAML.

@example

class CoffeeStyler
  include Psychgus::Styler

  def style_sequence(sniffer,node)
    node.style = Psychgus::SEQUENCE_FLOW
  end
end

coffee = <<EOY
Coffee:
  Roast:
    - Light
    - Medium
    - Dark
  Style:
    - Cappuccino
    - Latte
    - Mocha
EOY

parser = Psychgus.parser(stylers: CoffeeStyler.new)
parser.parse(coffee)
puts parser.handler.root.to_yaml

# Output:
#   Coffee:
#     Roast: [Light, Medium, Dark]
#     Style: [Cappuccino, Latte, Mocha]

@param stylers [nil,Styler,Array<Styler>] the Styler(s) to use when parsing the YAML @param deref_aliases [true,false] whether to dereference aliases; output the actual value

instead of the alias

@return [Psych::Parser] the new styled Parser

@see StyledTreeBuilder @see Psych.parser

# File lib/psychgus.rb, line 743
def self.parser(stylers: nil,deref_aliases: false,**options)
  return Psych::Parser.new(StyledTreeBuilder.new(*stylers,deref_aliases: deref_aliases,**options))
end