class XmlSimple
Easy API to maintain XML (especially configuration files).
Constants
- DEF_ANONYMOUS_TAG
- DEF_ATTR_TO_SYMBOL
- DEF_CONTENT_KEY
- DEF_FORCE_ARRAY
- DEF_INDENTATION
- DEF_KEBAB_TO_SNAKE
- DEF_KEY_ATTRIBUTES
-
Define some reasonable defaults.
- DEF_KEY_TO_SYMBOL
- DEF_ROOT_NAME
- DEF_XML_DECLARATION
- KNOWN_OPTIONS
-
Declare options that are valid for
xml_in
and xml_out.
Public Class Methods
Source
# File lib/xmlsimple.rb, line 128 def initialize(defaults = nil) unless defaults.nil? || defaults.is_a?(Hash) raise ArgumentError, "Options have to be a Hash." end @default_options = normalize_option_names(defaults, (KNOWN_OPTIONS['in'] + KNOWN_OPTIONS['out']).uniq) @options = Hash.new @_var_values = nil end
Creates and initializes a new XmlSimple
object.
- defaults
-
Default values for options.
Source
# File lib/xmlsimple.rb, line 201 def XmlSimple.xml_in(string = nil, options = nil) xml_simple = XmlSimple.new xml_simple.xml_in(string, options) end
This is the functional version of the instance method xml_in.
Source
# File lib/xmlsimple.rb, line 257 def XmlSimple.xml_out(hash, options = nil) xml_simple = XmlSimple.new xml_simple.xml_out(hash, options) end
This is the functional version of the instance method xml_out.
Public Instance Methods
Source
# File lib/xmlsimple.rb, line 149 def xml_in(string = nil, options = nil) handle_options('in', options) # If no XML string or filename was supplied look for scriptname.xml. if string.nil? string = File::basename($0).dup string.sub!(/\.[^.]+$/, '') string += '.xml' directory = File::dirname($0) @options['searchpath'].unshift(directory) unless directory.nil? end if string.is_a?(String) if string =~ /<.*?>/m @doc = parse(string) elsif string == '-' @doc = parse($stdin.read) else filename = find_xml_file(string, @options['searchpath']) if @options.has_key?('cache') @options['cache'].each { |scheme| case(scheme) when 'storable' content = @@cache.restore_storable(filename) when 'mem_share' content = @@cache.restore_mem_share(filename) when 'mem_copy' content = @@cache.restore_mem_copy(filename) else raise ArgumentError, "Unsupported caching scheme: <#{scheme}>." end return content if content } end @doc = load_xml_file(filename) end elsif string.respond_to?(:read) @doc = parse(string.read) else raise ArgumentError, "Could not parse object of type: <#{string.class}>." end result = collapse(@doc.root) result = @options['keeproot'] ? merge({}, @doc.root.name, result) : result put_into_cache(result, filename) result end
Converts an XML document in the same way as the Perl module XML::Simple.
- string
-
XML source. Could be one of the following:
-
nil: Tries to load and parse ‘<scriptname>.xml’.
-
filename: Tries to load and parse filename.
-
IO object: Reads from object until EOF is detected and parses result.
-
XML string: Parses string.
-
- options
-
Options to be used.
Source
# File lib/xmlsimple.rb, line 212 def xml_out(ref, options = nil) handle_options('out', options) if ref.is_a?(Array) ref = { @options['anonymoustag'] => ref } end if @options['keeproot'] keys = ref.keys if keys.size == 1 ref = ref[keys[0]] @options['rootname'] = keys[0] end elsif @options['rootname'] == '' if ref.is_a?(Hash) refsave = ref ref = {} refsave.each { |key, value| if !scalar(value) ref[key] = value else ref[key] = [ value.to_s ] end } end end @ancestors = [] xml = value_to_xml(ref, @options['rootname'], '') @ancestors = nil if @options['xmldeclaration'] xml = @options['xmldeclaration'] + "\n" + xml end if @options.has_key?('outputfile') if @options['outputfile'].kind_of?(IO) return @options['outputfile'].write(xml) else File.open(@options['outputfile'], "w") { |file| file.write(xml) } end end xml end
Converts a data structure into an XML document.
- ref
-
Reference to data structure to be converted into XML.
- options
-
Options to be used.
Private Instance Methods
Source
# File lib/xmlsimple.rb, line 466 def collapse(element) result = @options['noattr'] ? {} : get_attributes(element) if @options['normalisespace'] == 2 result.each { |k, v| result[k] = normalise_space(v) } end if element.has_elements? element.each_element { |child| value = collapse(child) if empty(value) && (element.attributes.empty? || @options['noattr']) next if @options.has_key?('suppressempty') && @options['suppressempty'] == true end result = merge(result, child.name, value) } if has_mixed_content?(element) # normalisespace? content = element.texts.map { |x| x.to_s } content = content[0] if content.size == 1 result[@options['contentkey']] = content end elsif element.has_text? # i.e. it has only text. return collapse_text_node(result, element) # calls merge, which converts end # Turn Arrays into Hashes if key fields present. count = fold_arrays(result) # Disintermediate grouped tags. if @options.has_key?('grouptags') result.each { |key, value| # In results, key should already be converted raise("Unconverted key '#{key}' found. Should be '#{kebab_to_snake_case key}'.") if (key != kebab_to_snake_case(key)) next unless (value.is_a?(Hash) && (value.size == 1)) child_key, child_value = value.to_a[0] child_key = kebab_to_snake_case child_key # todo test whether necessary if @options['grouptags'][key] == child_key result[key] = child_value end } end # Fold Hashes containing a single anonymous Array up into just the Array. if count == 1 anonymoustag = @options['anonymoustag'] if result.has_key?(anonymoustag) && result[anonymoustag].is_a?(Array) return result[anonymoustag] end end if result.empty? && @options.has_key?('suppressempty') return @options['suppressempty'] == '' ? '' : nil end result end
Actually converts an XML document element into a data structure.
- element
-
The document element to be collapsed.
Source
# File lib/xmlsimple.rb, line 633 def collapse_content(hash) content_key = @options['contentkey'] hash.each_value { |value| return hash unless value.is_a?(Hash) && value.size == 1 && value.has_key?(content_key) hash.each_key { |key| hash[key] = hash[key][content_key] } } hash end
Tries to collapse a Hash even more ;-)
- hash
-
Hash to be collapsed again.
Source
# File lib/xmlsimple.rb, line 531 def collapse_text_node(hash, element) value = node_to_text(element) if empty(value) && !element.has_attributes? return {} end if element.has_attributes? && !@options['noattr'] return merge(hash, @options['contentkey'], value) else if @options['forcecontent'] return merge(hash, @options['contentkey'], value) else return value end end end
Collapses a text node and merges it with an existing Hash, if possible. Thanks to Curtis Schofield for reporting a subtle bug.
- hash
-
Hash to merge text node value with, if possible.
- element
-
Text node to be collapsed.
Source
# File lib/xmlsimple.rb, line 938 def empty(value) case value when Hash return value.empty? when String return value !~ /\S/m else return value.nil? end end
Checks, if an object is nil, an empty String or an empty Hash. Thanks to Norbert Gawor for a bugfix.
- value
-
Value to be checked for emptyness.
Source
# File lib/xmlsimple.rb, line 920 def escape_value(data) Text::normalize(data) end
Replaces XML markup characters by their external entities.
- data
-
The string to be escaped.
Source
# File lib/xmlsimple.rb, line 990 def find_xml_file(file, searchpath) filename = File::basename(file) if filename != file return file if File::file?(file) else searchpath.each { |path| full_path = File::join(path, filename) return full_path if File::file?(full_path) } end if searchpath.empty? return file if File::file?(file) raise ArgumentError, "File does not exist: #{file}." end raise ArgumentError, "Could not find <#{filename}> in <#{searchpath.join(':')}>" end
Searches in a list of paths for a certain file. Returns the full path to the file, if it could be found. Otherwise, an exception will be raised.
- filename
-
Name of the file to search for.
- searchpath
-
List of paths to search in.
Source
# File lib/xmlsimple.rb, line 577 def fold_array(array) hash = Hash.new array.each { |x| return array unless x.is_a?(Hash) key_matched = false @options['keyattr'].each { |key| if x.has_key?(key) key_matched = true value = x[key] return array if value.is_a?(Hash) || value.is_a?(Array) value = normalise_space(value) if @options['normalisespace'] == 1 x.delete(key) hash[value] = x break end } return array unless key_matched } hash = collapse_content(hash) if @options['collapseagain'] hash end
Folds an Array to a Hash, if possible. Folding happens according to the content of keyattr, which has to be an array.
- array
-
Array to be folded.
Source
# File lib/xmlsimple.rb, line 607 def fold_array_by_name(name, array) return array unless @options['keyattr'].has_key?(name) key, flag = @options['keyattr'][name] hash = Hash.new array.each { |x| if x.is_a?(Hash) && x.has_key?(key) value = x[key] return array if value.is_a?(Hash) || value.is_a?(Array) value = normalise_space(value) if @options['normalisespace'] == 1 hash[value] = x hash[value]["-#{key}"] = hash[value][key] if flag == '-' hash[value].delete(key) unless flag == '+' else $stderr.puts("Warning: <#{name}> element has no '#{key}' attribute.") return array end } hash = collapse_content(hash) if @options['collapseagain'] hash end
Folds an Array to a Hash, if possible. Folding happens according to the content of keyattr, which has to be a Hash.
- name
-
Name of the attribute to be folded upon.
- array
-
Array to be folded.
Source
# File lib/xmlsimple.rb, line 552 def fold_arrays(hash) fold_amount = 0 keyattr = @options['keyattr'] if (keyattr.is_a?(Array) || keyattr.is_a?(Hash)) hash.each { |key, value| key = kebab_to_snake_case key if value.is_a?(Array) if keyattr.is_a?(Array) hash[key] = fold_array(value) else hash[key] = fold_array_by_name(key, value) end fold_amount += 1 end } end fold_amount end
Folds all arrays in a Hash.
- hash
-
Hash to be folded.
Source
# File lib/xmlsimple.rb, line 703 def force_array?(key) return false if key == @options['contentkey'] return true if @options['forcearray'] == true forcearray = @options['forcearray'] if forcearray.is_a?(Hash) return true if forcearray.has_key?(key) return false unless forcearray.has_key?('_regex') forcearray['_regex'].each { |x| return true if key =~ x } end return false end
Checks, if the ‘forcearray’ option has to be used for a certain key.
Source
# File lib/xmlsimple.rb, line 720 def get_attributes(node) attributes = {} if @options['attrprefix'] node.attributes.each { |n,v| attributes["@" + kebab_to_snake_case(n)] = v } elsif @options.has_key?('attrtosymbol') and @options['attrtosymbol'] == true #patch for converting attribute names to symbols node.attributes.each { |n,v| attributes[kebab_to_snake_case(n).to_sym] = v } else node.attributes.each { |n,v| attributes[kebab_to_snake_case(n)] = v } end attributes end
Converts the attributes array of a document node into a Hash. Returns an empty Hash, if node has no attributes.
- node
-
Document node to extract attributes from.
Source
# File lib/xmlsimple.rb, line 754 def get_var(name) if @_var_values.has_key?(name) return @_var_values[name] else return "${#{name}}" end end
Called during variable substitution to get the value for the named variable.
Source
# File lib/xmlsimple.rb, line 320 def handle_options(direction, options) @options = options || Hash.new raise ArgumentError, "Options must be a Hash!" unless @options.is_a?(Hash) unless KNOWN_OPTIONS.has_key?(direction) raise ArgumentError, "Unknown direction: <#{direction}>." end known_options = KNOWN_OPTIONS[direction] @options = normalize_option_names(@options, known_options) unless @default_options.nil? known_options.each { |option| unless @options.has_key?(option) if @default_options.has_key?(option) @options[option] = @default_options[option] end end } end unless @options.has_key?('noattr') @options['noattr'] = false end if @options.has_key?('rootname') @options['rootname'] = '' if @options['rootname'].nil? else @options['rootname'] = DEF_ROOT_NAME end if @options.has_key?('xmldeclaration') && @options['xmldeclaration'] == true @options['xmldeclaration'] = DEF_XML_DECLARATION end @options['keytosymbol'] = DEF_KEY_TO_SYMBOL unless @options.has_key?('keytosymbol') @options['attrtosymbol'] = DEF_ATTR_TO_SYMBOL unless @options.has_key?('attrtosymbol') if @options.has_key?('contentkey') if @options['contentkey'] =~ /^-(.*)$/ @options['contentkey'] = $1 @options['collapseagain'] = true end else @options['contentkey'] = DEF_CONTENT_KEY end unless @options.has_key?('normalisespace') @options['normalisespace'] = @options['normalizespace'] end @options['normalisespace'] = 0 if @options['normalisespace'].nil? if @options.has_key?('searchpath') unless @options['searchpath'].is_a?(Array) @options['searchpath'] = [ @options['searchpath'] ] end else @options['searchpath'] = [] end if @options.has_key?('cache') && scalar(@options['cache']) @options['cache'] = [ @options['cache'] ] end @options['anonymoustag'] = DEF_ANONYMOUS_TAG unless @options.has_key?('anonymoustag') if !@options.has_key?('indent') || @options['indent'].nil? @options['indent'] = DEF_INDENTATION end @options['indent'] = '' if @options.has_key?('noindent') # Special cleanup for 'keyattr' which could be an array or # a hash or left to default to array. if @options.has_key?('keyattr') if !scalar(@options['keyattr']) # Convert keyattr => { elem => '+attr' } # to keyattr => { elem => ['attr', '+'] } if @options['keyattr'].is_a?(Hash) @options['keyattr'].each { |key, value| if value =~ /^([-+])?(.*)$/ @options['keyattr'][key] = [$2, $1 ? $1 : ''] end } elsif !@options['keyattr'].is_a?(Array) raise ArgumentError, "'keyattr' must be String, Hash, or Array!" end else @options['keyattr'] = [ @options['keyattr'] ] end else @options['keyattr'] = DEF_KEY_ATTRIBUTES end if @options.has_key?('forcearray') if @options['forcearray'].is_a?(Regexp) @options['forcearray'] = [ @options['forcearray'] ] end if @options['forcearray'].is_a?(Array) force_list = @options['forcearray'] unless force_list.empty? @options['forcearray'] = {} force_list.each { |tag| if tag.is_a?(Regexp) unless @options['forcearray']['_regex'].is_a?(Array) @options['forcearray']['_regex'] = [] end @options['forcearray']['_regex'] << tag else @options['forcearray'][tag] = true end } else @options['forcearray'] = false end else @options['forcearray'] = @options['forcearray'] ? true : false end else @options['forcearray'] = DEF_FORCE_ARRAY end if @options.has_key?('grouptags') && !@options['grouptags'].is_a?(Hash) raise ArgumentError, "Illegal value for 'GroupTags' option - expected a Hash." end if @options.has_key?('variables') && !@options['variables'].is_a?(Hash) raise ArgumentError, "Illegal value for 'Variables' option - expected a Hash." end if @options.has_key?('variables') @_var_values = @options['variables'] elsif @options.has_key?('varattr') @_var_values = {} end @options['kebabtosnakecase'] = DEF_KEBAB_TO_SNAKE unless @options.has_key?('kebabtosnakecase') end
Merges a set of options with the default options.
- direction
-
‘in’: If options should be handled for xml_in. ‘out’: If options should be handled for xml_out.
- options
-
Options to be merged with the default options.
Source
# File lib/xmlsimple.rb, line 738 def has_mixed_content?(element) element.has_text? && element.has_elements? && !element.texts.join('').strip.empty? end
Determines, if a document element has mixed content.
- element
-
Document element to be checked.
Source
# File lib/xmlsimple.rb, line 901 def hash_to_array(parent, hashref) arrayref = [] hashref.each { |key, value| return hashref unless value.is_a?(Hash) if @options['keyattr'].is_a?(Hash) return hashref unless @options['keyattr'].has_key?(parent) arrayref << { @options['keyattr'][parent][0] => key }.update(value) else arrayref << { @options['keyattr'][0] => key }.update(value) end } arrayref end
Attempts to unfold a hash of hashes into an array of hashes. Returns a reference to th array on success or the original hash, if unfolding is not possible.
- parent
- hashref
-
Reference to the hash to be unfolded.
Source
# File lib/xmlsimple.rb, line 1052 def kebab_to_snake_case(key) return key unless (@options['kebabtosnakecase']) is_symbol = key.is_a? Symbol key = key.to_s.gsub(/-/, '_') key = key.to_sym if is_symbol key end
Substitutes underscores for hyphens if the KebabToSnakeCase option is selected. For when you don’t want to refer to keys by hash but instead as hash
- key
-
Key to be converted.
Source
# File lib/xmlsimple.rb, line 1020 def load_xml_file(filename) parse(IO::read(filename)) end
Loads and parses an XML configuration file.
- filename
-
Name of the configuration file to be loaded.
The following exceptions may be raised:
- Errno::ENOENT
-
If the specified file does not exist.
- REXML::ParseException
-
If the specified file is not wellformed.
Source
# File lib/xmlsimple.rb, line 653 def merge(hash, key, value) key = kebab_to_snake_case key if value.is_a?(String) value = normalise_space(value) if @options['normalisespace'] == 2 if conv = @options['conversions'] and conv = conv.find {|c,_| c.match(key)} and conv = conv.at(1) value = conv.call(value) end # do variable substitutions unless @_var_values.nil? || @_var_values.empty? value.gsub!(/\$\{(\w+)\}/) { |x| get_var($1) } end # look for variable definitions if @options.has_key?('varattr') varattr = kebab_to_snake_case @options['varattr'] if hash.has_key?(varattr) set_var(hash[varattr], value) end end end #patch for converting keys to symbols if @options.has_key?('keytosymbol') if @options['keytosymbol'] == true key = key.to_s.downcase.to_sym end end if hash.has_key?(key) if hash[key].is_a?(Array) hash[key] << value else hash[key] = [ hash[key], value ] end elsif value.is_a?(Array) # Handle anonymous arrays. hash[key] = [ value ] else if force_array?(key) hash[key] = [ value ] else hash[key] = value end end hash end
Adds a new key/value pair to an existing Hash. If the key to be added does already exist and the existing value associated with key is not an Array, it will be converted into an Array. Then the new value is appended to that Array.
- hash
-
Hash to add key/value pair to.
- key
-
Key to be added.
- value
-
Value to be associated with key.
Source
# File lib/xmlsimple.rb, line 957 def node_to_text(node, default = nil) if node.is_a?(REXML::Element) node.texts.map { |t| t.value }.join('') elsif node.is_a?(REXML::Attribute) node.value.nil? ? default : node.value.strip elsif node.is_a?(REXML::Text) node.value.strip else default end end
Converts a document node into a String. If the node could not be converted into a String for any reason, default will be returned.
- node
-
Document node to be converted.
- default
-
Value to be returned, if node could not be converted.
Source
# File lib/xmlsimple.rb, line 929 def normalise_space(text) text.strip.gsub(/\s\s+/, ' ') end
Removes leading and trailing whitespace and sequences of whitespaces from a string.
- text
-
String to be normalised.
Source
# File lib/xmlsimple.rb, line 300 def normalize_option_names(options, known_options) return nil if options.nil? result = Hash.new options.each { |key, value| lkey = key.to_s.downcase.gsub(/_/, '') if !known_options.member?(lkey) raise ArgumentError, "Unrecognized option: #{lkey}." end result[lkey] = value } result end
Normalizes option names in a hash, i.e., turns all characters to lower case and removes all underscores. Additionally, this method checks if an unknown option was used, and raises an according exception.
- options
-
Hash to be normalized.
- known_options
-
List of known options.
Source
# File lib/xmlsimple.rb, line 978 def parse(xml_string) Document.new(xml_string) end
Parses an XML string and returns the according document.
- xml_string
-
XML string to be parsed.
The following exception may be raised:
- REXML::ParseException
-
If the specified file is not wellformed.
Source
# File lib/xmlsimple.rb, line 1030 def put_into_cache(data, filename) if @options.has_key?('cache') @options['cache'].each { |scheme| case(scheme) when 'storable' @@cache.save_storable(data, filename) when 'mem_share' @@cache.save_mem_share(data, filename) when 'mem_copy' @@cache.save_mem_copy(data, filename) else raise ArgumentError, "Unsupported caching scheme: <#{scheme}>." end } end end
Caches the data belonging to a certain file.
- data
-
Data to be cached.
- filename
-
Name of file the data was read from.
Source
# File lib/xmlsimple.rb, line 888 def scalar(value) return false if value.is_a?(Hash) || value.is_a?(Array) return true end
Checks, if a certain value is a “scalar” value. Whatever that will be in Ruby … ;-)
- value
-
Value to be checked.
Source
# File lib/xmlsimple.rb, line 748 def set_var(name, value) @_var_values[name] = value end
Called when a variable definition is encountered in the XML. A variable definition looks like
<element attrname="name">value</element>
where attrname matches the varattr setting.
Source
# File lib/xmlsimple.rb, line 771 def value_to_xml(ref, name, indent) named = !name.nil? && name != '' nl = @options.has_key?('noindent') ? '' : "\n" if !scalar(ref) if @ancestors.member?(ref) raise ArgumentError, "Circular data structures not supported!" end @ancestors << ref else if named return [indent, '<', name, '>', @options['noescape'] ? ref.to_s : escape_value(ref.to_s), '</', name, '>', nl].join('') else return ref.to_s + nl end end # Unfold hash to array if possible. if ref.is_a?(Hash) && !ref.empty? && !@options['keyattr'].empty? && indent != '' ref = hash_to_array(name, ref) end result = [] if ref.is_a?(Hash) # Reintermediate grouped values if applicable. if @options.has_key?('grouptags') ref.each { |key, value| if @options['grouptags'].has_key?(key) ref[key] = { @options['grouptags'][key] => value } end } end nested = [] text_content = nil if named result << indent << '<' << name end if !ref.empty? ref.each { |key, value| next if !key.nil? && key.to_s[0, 1] == '-' if value.nil? unless @options.has_key?('suppressempty') && @options['suppressempty'].nil? raise ArgumentError, "Use of uninitialized value!" end value = {} end # Check for the '@' attribute prefix to allow separation of attributes and elements if (@options['noattr'] || (@options['attrprefix'] && !(key =~ /^@(.*)/)) || !scalar(value) ) && key != @options['contentkey'] nested << value_to_xml(value, key, indent + @options['indent']) else value = value.to_s value = escape_value(value) unless @options['noescape'] if key == @options['contentkey'] text_content = value else result << ' ' << ($1||key) << '="' << value << '"' end end } elsif !@options['selfclose'] text_content = '' end if !nested.empty? || !text_content.nil? if named result << '>' if !text_content.nil? result << text_content nested[0].sub!(/^\s+/, '') if !nested.empty? else result << nl end if !nested.empty? result << nested << indent end result << '</' << name << '>' << nl else result << nested end else result << ' />' << nl end elsif ref.is_a?(Array) ref.each { |value| if scalar(value) result << indent << '<' << name << '>' result << (@options['noescape'] ? value.to_s : escape_value(value.to_s)) result << '</' << name << '>' << nl elsif value.is_a?(Hash) result << value_to_xml(value, name, indent) else result << indent << '<' << name << '>' << nl result << value_to_xml(value, @options['anonymoustag'], indent + @options['indent']) result << indent << '</' << name << '>' << nl end } else # Probably, this is obsolete. raise ArgumentError, "Can't encode a value of type: #{ref.type}." end @ancestors.pop if !scalar(ref) result.join('') end
Recurses through a data structure building up and returning an XML representation of that structure as a string.
- ref
-
Reference to the data structure to be encoded.
- name
-
The XML tag name to be used for this item.
- indent
-
A string of spaces for use as the current indent level.