samhuri.net/lib/config/simple_toml.rb

224 lines
5.5 KiB
Ruby

require "json"
module Pressa
module Config
class ParseError < StandardError; end
class SimpleToml
def self.load_file(path)
new.parse(File.read(path))
rescue Errno::ENOENT
raise ParseError, "Config file not found: #{path}"
end
def parse(content)
root = {}
current_table = root
lines = content.each_line.to_a
line_index = 0
while line_index < lines.length
line = lines[line_index]
line_number = line_index + 1
source = strip_comments(line).strip
if source.empty?
line_index += 1
next
end
if source =~ /\A\[\[(.+)\]\]\z/
current_table = start_array_table(root, Regexp.last_match(1), line_number)
line_index += 1
next
end
if source =~ /\A\[(.+)\]\z/
current_table = start_table(root, Regexp.last_match(1), line_number)
line_index += 1
next
end
key, raw_value = parse_assignment(source, line_number)
while needs_continuation?(raw_value)
line_index += 1
raise ParseError, "Unterminated value for key '#{key}' at line #{line_number}" if line_index >= lines.length
continuation = strip_comments(lines[line_index]).strip
next if continuation.empty?
raw_value = "#{raw_value} #{continuation}"
end
if current_table.key?(key)
raise ParseError, "Duplicate key '#{key}' at line #{line_number}"
end
current_table[key] = parse_value(raw_value, line_number)
line_index += 1
end
root
end
private
def start_array_table(root, raw_path, line_number)
keys = parse_path(raw_path, line_number)
parent = ensure_path(root, keys[0..-2], line_number)
table_name = keys.last
parent[table_name] ||= []
array = parent[table_name]
unless array.is_a?(Array)
raise ParseError, "Expected array for '[[#{raw_path}]]' at line #{line_number}"
end
table = {}
array << table
table
end
def start_table(root, raw_path, line_number)
keys = parse_path(raw_path, line_number)
ensure_path(root, keys, line_number)
end
def ensure_path(root, keys, line_number)
cursor = root
keys.each do |key|
cursor[key] ||= {}
unless cursor[key].is_a?(Hash)
raise ParseError, "Expected table path '#{keys.join(".")}' at line #{line_number}"
end
cursor = cursor[key]
end
cursor
end
def parse_path(raw_path, line_number)
keys = raw_path.split(".").map(&:strip)
if keys.empty? || keys.any? { |part| part.empty? || part !~ /\A[A-Za-z0-9_]+\z/ }
raise ParseError, "Invalid table path '#{raw_path}' at line #{line_number}"
end
keys
end
def parse_assignment(source, line_number)
separator = index_of_unquoted(source, "=")
raise ParseError, "Invalid assignment at line #{line_number}" unless separator
key = source[0...separator].strip
value = source[(separator + 1)..].strip
if key.empty? || key !~ /\A[A-Za-z0-9_]+\z/
raise ParseError, "Invalid key '#{key}' at line #{line_number}"
end
raise ParseError, "Missing value for key '#{key}' at line #{line_number}" if value.empty?
[key, value]
end
def parse_value(raw_value, line_number)
JSON.parse(raw_value)
rescue JSON::ParserError
raise ParseError, "Unsupported TOML value '#{raw_value}' at line #{line_number}"
end
def needs_continuation?(source)
in_string = false
escaped = false
depth = 0
source.each_char do |char|
if in_string
if escaped
escaped = false
elsif char == "\\"
escaped = true
elsif char == '"'
in_string = false
end
next
end
case char
when '"'
in_string = true
when "[", "{"
depth += 1
when "]", "}"
depth -= 1
end
end
in_string || depth.positive?
end
def strip_comments(line)
output = +""
in_string = false
escaped = false
line.each_char do |char|
if in_string
output << char
if escaped
escaped = false
elsif char == "\\"
escaped = true
elsif char == '"'
in_string = false
end
next
end
case char
when '"'
in_string = true
output << char
when "#"
break
else
output << char
end
end
output
end
def index_of_unquoted(source, target)
in_string = false
escaped = false
source.each_char.with_index do |char, index|
if in_string
if escaped
escaped = false
elsif char == "\\"
escaped = true
elsif char == '"'
in_string = false
end
next
end
if char == '"'
in_string = true
next
end
return index if char == target
end
nil
end
end
end
end