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