From e083c634841aae6cbe4472672b019a70a174bbd2 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Sat, 23 Dec 2017 00:46:40 -0500 Subject: [PATCH] INI: Rewrite parser to avoid regular expressions `INI.parse` now parses an INI-formatted string into a `Hash` without using any regular expressions. --- spec/std/ini_spec.cr | 34 ++++++++++++++++++++++++++++++ src/ini.cr | 50 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/spec/std/ini_spec.cr b/spec/std/ini_spec.cr index 6334016741c1..a8f7bcf5fc5b 100644 --- a/spec/std/ini_spec.cr +++ b/spec/std/ini_spec.cr @@ -3,14 +3,48 @@ require "ini" describe "INI" do describe "parse from string" do + it "fails on malformed section" do + expect_raises(INI::ParseException, "unterminated section") do + INI.parse("[section") + end + end + + it "fails on data after section" do + expect_raises(INI::ParseException, "data after section") do + INI.parse("[section] foo ") + end + + expect_raises(INI::ParseException, "data after section") do + INI.parse("[]foo") + end + end + + it "fails on malformed declaration" do + expect_raises(INI::ParseException, "expected declaration") do + INI.parse("foobar") + end + + expect_raises(INI::ParseException, "expected declaration") do + INI.parse("foo: bar") + end + end + it "parses key = value" do INI.parse("key = value").should eq({"" => {"key" => "value"}}) end + it "parses empty values" do + INI.parse("key = ").should eq({"" => {"key" => ""}}) + end + it "ignores whitespaces" do INI.parse(" key = value ").should eq({"" => {"key" => "value"}}) end + it "ignores comments" do + INI.parse("; foo\n# bar\nkey = value").should eq({"" => {"key" => "value"}}) + end + it "parses sections" do INI.parse("[section]\na = 1").should eq({"section" => {"a" => "1"}}) end diff --git a/src/ini.cr b/src/ini.cr index 823779fe2f50..5bc4aae2c32c 100644 --- a/src/ini.cr +++ b/src/ini.cr @@ -1,22 +1,58 @@ class INI + # Exception thrown on an INI parse error. + class ParseException < Exception + getter line_number : Int32 + getter column_number : Int32 + + def initialize(message, @line_number, @column_number) + super "#{message} at #{@line_number}:#{@column_number}" + end + + def location + {line_number, column_number} + end + end + # Parses INI-style configuration from the given string. + # Raises a `ParseException` on any errors. # # ``` # INI.parse("[foo]\na = 1") # => {"foo" => {"a" => "1"}} # ``` def self.parse(str) : Hash(String, Hash(String, String)) ini = {} of String => Hash(String, String) + current_section_name = "" + lineno = 0 - section = "" str.each_line do |line| - if line =~ /\s*(.*[^\s])\s*=\s*(.*[^\s])/ - ini[section] ||= {} of String => String if section == "" - ini[section][$1] = $2 - elsif line =~ /\[(.*)\]/ - section = $1 - ini[section] = {} of String => String + lineno += 1 + next if line.empty? + + offset = 0 + while line[offset].ascii_whitespace? + offset += 1 + end + + case line[offset] + when '#', ';' + next + when '[' + end_idx = line.index(']', offset) + raise ParseException.new("unterminated section", lineno, line.size) unless end_idx + raise ParseException.new("data after section", lineno, end_idx + 1) unless end_idx == line.size - 1 + + current_section_name = line[1...end_idx] + ini[current_section_name] = {} of String => String + else + key, eq, value = line.partition('=') + raise ParseException.new("expected declaration", lineno, key.size) if eq != "=" + + section = ini[current_section_name]? || Hash(String, String).new + section[key.strip] = value.strip + ini[current_section_name] = section end end + ini end end