# Provides utility methods for the YAML 1.1 core schema # with the additional independent types specified in http://yaml.org/type/ module YAML::Schema::Core # Deserializes a YAML document. # # Same as `YAML.parse`. def self.parse(data : String | IO) : YAML::Any Parser.new data, &.parse end # Deserializes multiple YAML documents. # # Same as `YAML.parse_all`. def self.parse_all(data : String | IO) : Array(YAML::Any) Parser.new data, &.parse_all end # Assuming the *pull_parser* is positioned in a scalar, # parses it according to the core schema, taking the # scalar's style and tag into account, then advances # the pull parser. def self.parse_scalar(pull_parser : YAML::PullParser) : Nil | Bool | Int64 | Float64 | String | Time | Bytes string = pull_parser.value # Check for core schema tags process_scalar_tag(pull_parser, pull_parser.tag) do |value| return value end # Non-plain scalar is always a string unless pull_parser.scalar_style.plain? return string end parse_scalar(string) end # Parses a scalar value from the given *node*. def self.parse_scalar(node : YAML::Nodes::Scalar) : Nil | Bool | Int64 | Float64 | String | Time | Bytes string = node.value # Check for core schema tags process_scalar_tag(node) do |value| return value end # Non-plain scalar is always a string unless node.style.plain? return string end parse_scalar(string) end # Parses a string according to the core schema, assuming # the string had a plain style. # # ``` # require "yaml" # # YAML::Schema::Core.parse_scalar("hello") # => "hello" # YAML::Schema::Core.parse_scalar("1.2") # => 1.2 # YAML::Schema::Core.parse_scalar("false") # => false # ``` def self.parse_scalar(string : String) : Nil | Bool | Int64 | Float64 | String | Time | Bytes if parse_null?(string) return nil end value = parse_bool?(string) return value unless value.nil? value = parse_float_infinity_and_nan?(string) return value if value # Optimizations for prefixes that either parse to # a number or are strings otherwise case string when .starts_with?("0x"), .starts_with?("+0x"), .starts_with?("-0x") value = string.to_i64?(base: 16, prefix: true) value || string when .starts_with?("0."), .starts_with?('.') value = parse_float?(string) value || string when .starts_with?('0') return 0_i64 if string.size == 1 value = string.to_i64?(base: 8, prefix: true, leading_zero_is_octal: true) value || string when .starts_with?('-'), .starts_with?('+') value = parse_number?(string) value || string else if string[0].ascii_number? value = parse_number?(string) return value if value value = parse_time?(string) return value if value end string end end # Returns whether a string is reserved and must non be output # with a plain style, according to the core schema. # # ``` # require "yaml" # # YAML::Schema::Core.reserved_string?("hello") # => false # YAML::Schema::Core.reserved_string?("1.2") # => true # YAML::Schema::Core.reserved_string?("false") # => true # ``` def self.reserved_string?(string) : Bool # There's simply no other way than parsing the string and # checking what we got. # # The performance loss is minimal because `parse_scalar` # doesn't allocate memory: it can only return primitive # types, or `Time`, which is a struct. !parse_scalar(string).is_a?(String) end # If `node` parses to a null value, returns `nil`, otherwise # invokes the given block. def self.parse_null_or(node : YAML::Nodes::Node, &) unless parse_null?(node) yield end end # Invokes the block for each of the given *node*s keys and # values, resolving merge keys (<<) when found (keys and # values of the resolved merge mappings are yielded, # recursively). def self.each(node : YAML::Nodes::Mapping, &) # We can't just traverse the nodes and invoke yield because # yield can't recurse. So, we use a stack of {Mapping, index}. # We pop from the stack and traverse the mapping values. # When we find a merge, we stop (put back in the stack with # that mapping and next index) and add solved mappings from # the merge to the stack, and continue processing. stack = [{node, 0}] # Mappings that we already visited. In case of a recursion # we want to stop. For example: # # foo: &foo # <<: *foo # # When we traverse &foo we'll put it in visited, # and when we find it in *foo we'll skip it. # # This has no use case, but we don't want to hang the program. visited = Set(YAML::Nodes::Mapping).new until stack.empty? mapping, index = stack.pop visited << mapping while index < mapping.nodes.size key = mapping.nodes[index] index += 1 value = mapping.nodes[index] index += 1 if key.is_a?(YAML::Nodes::Scalar) && key.value == "<<" && key.tag != "tag:yaml.org,2002:str" && solve_merge(stack, mapping, index, value, visited) break else yield({key, value}) end end end end private def self.solve_merge(stack, mapping, index, value, visited) value = value.value if value.is_a?(YAML::Nodes::Alias) case value when YAML::Nodes::Mapping stack.push({mapping, index}) unless visited.includes?(value) stack.push({value, 0}) end true when YAML::Nodes::Sequence all_mappings = value.nodes.all? do |elem| elem = elem.value if elem.is_a?(YAML::Nodes::Alias) elem.is_a?(YAML::Nodes::Mapping) end if all_mappings stack.push({mapping, index}) value.each do |elem| elem = elem.value if elem.is_a?(YAML::Nodes::Alias) mapping = elem.as(YAML::Nodes::Mapping) unless visited.includes?(mapping) stack.push({mapping, 0}) end end true else false end else false end end protected def self.parse_binary(string, location) : Bytes Base64.decode(string) rescue ex : Base64::Error raise YAML::ParseException.new("Error decoding Base64: #{ex.message}", *location) end protected def self.parse_bool(string, location) : Bool value = parse_bool?(string) unless value.nil? return value end raise YAML::ParseException.new("Invalid bool", *location) end protected def self.parse_int(string : String, location) : Int64 return 0_i64 if string == "0" string.to_i64?(underscore: true, prefix: true, leading_zero_is_octal: true) || raise(YAML::ParseException.new("Invalid int", *location)) end protected def self.parse_float(string, location) : Float64 parse_float_infinity_and_nan?(string) || parse_float?(string) || raise(YAML::ParseException.new("Invalid float", *location)) end protected def self.parse_null(string, location) : Nil if parse_null?(string) return nil end raise YAML::ParseException.new("Invalid null", *location) end protected def self.parse_time(string, location) : Time parse_time?(string) || raise(YAML::ParseException.new("Invalid timestamp", *location)) end protected def self.process_scalar_tag(scalar, &) process_scalar_tag(scalar, scalar.tag) do |value| yield value end end protected def self.process_scalar_tag(source, tag, &) case tag when "tag:yaml.org,2002:binary" yield parse_binary(source.value, source.location) when "tag:yaml.org,2002:bool" yield parse_bool(source.value, source.location) when "tag:yaml.org,2002:float" yield parse_float(source.value, source.location) when "tag:yaml.org,2002:int" yield parse_int(source.value, source.location) when "tag:yaml.org,2002:null" yield parse_null(source.value, source.location) when "tag:yaml.org,2002:str" yield source.value when "tag:yaml.org,2002:timestamp" yield parse_time(source.value, source.location) else # not a tag we support end end # Returns `true` if *node* parses to a null value. def self.parse_null?(node : Nodes::Node) if node.is_a?(Nodes::Scalar) parse_null?(node.value) && node.style.plain? else false end end private def self.parse_null?(string) case string when .empty?, "~", "null", "Null", "NULL" true else false end end private def self.parse_bool?(string) case string when "yes", "Yes", "YES", "true", "True", "TRUE", "on", "On", "ON" true when "no", "No", "NO", "false", "False", "FALSE", "off", "Off", "OFF" false else nil end end private def self.parse_number?(string) parse_int?(string) || parse_float?(string) end # Parses an integer of the given *type* according to the core schema. # # *type* must be a primitive integer type. Raises `YAML::ParseException` if # *node* is not a valid integer or its value is outside *type*'s range. def self.parse_int(node : Nodes::Node, type : T.class) : T forall T {% unless Int::Primitive.union_types.includes?(T) %} {% raise "Expected `type` to be a primitive integer type, not #{T}" %} {% end %} string = node.value return T.zero if string == "0" begin T.new(string, underscore: true, prefix: true, leading_zero_is_octal: true) rescue ex : ArgumentError raise YAML::ParseException.new("Can't read #{T}", *node.location) end end private def self.parse_int?(string) string.to_i64?(underscore: true, leading_zero_is_octal: true) end private def self.parse_float?(string) string = string.delete('_') if string.includes?('_') string.to_f64? end private def self.parse_float_infinity_and_nan?(string) case string when ".inf", ".Inf", ".INF", "+.inf", "+.Inf", "+.INF" Float64::INFINITY when "-.inf", "-.Inf", "-.INF" -Float64::INFINITY when ".nan", ".NaN", ".NAN" Float64::NAN else nil end end private def self.parse_time?(string) # Minimum length is that of YYYY-M-D return nil if string.size < 8 Time::Format::YAML_DATE.parse?(string) end end