From 2cd52ab17ee1b830bc53321b112411122dddc1c8 Mon Sep 17 00:00:00 2001
From: Ben Longbons <b.r.longbons@gmail.com>
Date: Tue, 6 Jan 2015 17:31:21 -0800
Subject: Use Spanned<T> while parsing config

---
 src/admin/ladmin.cpp          |  23 ++---
 src/char/char.cpp             | 131 +++++++++++++-------------
 src/char/inter.cpp            |  21 +++--
 src/char/inter.hpp            |   2 +-
 src/io/span.cpp               |  14 +++
 src/io/span.hpp               |   2 +
 src/login/login.cpp           | 161 ++++++++++++++++----------------
 src/map/atcommand.cpp         |  36 ++------
 src/map/battle.cpp            |  50 +++-------
 src/map/map.cpp               | 155 ++++++++++++++-----------------
 src/mmo/config_parse.cpp      | 210 ++++++++++++++++++++++++++++++++++--------
 src/mmo/config_parse.hpp      |   5 +-
 src/mmo/config_parse_test.cpp |  60 ++++++++++++
 src/mmo/version.cpp           |  24 ++++-
 src/monitor/main.cpp          |  44 ++++-----
 15 files changed, 564 insertions(+), 374 deletions(-)
 create mode 100644 src/mmo/config_parse_test.cpp

diff --git a/src/admin/ladmin.cpp b/src/admin/ladmin.cpp
index 97b570e..774d8cd 100644
--- a/src/admin/ladmin.cpp
+++ b/src/admin/ladmin.cpp
@@ -37,6 +37,7 @@
 #include "../io/cxxstdio.hpp"
 #include "../io/extract.hpp"
 #include "../io/read.hpp"
+#include "../io/span.hpp"
 #include "../io/tty.hpp"
 #include "../io/write.hpp"
 
@@ -2495,16 +2496,16 @@ int Connect_login_server(void)
 }
 
 static
-bool admin_confs(XString w1, ZString w2)
+bool admin_confs(io::Spanned<XString> w1, io::Spanned<ZString> w2)
 {
     {
-        if (w1 == "login_ip"_s)
+        if (w1.data == "login_ip"_s)
         {
-            struct hostent *h = gethostbyname(w2.c_str());
+            struct hostent *h = gethostbyname(w2.data.c_str());
             if (h != nullptr)
             {
                 Iprintf("Login server IP address: %s -> %s\n"_fmt,
-                        w2, login_ip);
+                        w2.data, login_ip);
                 login_ip = IP4Address({
                         static_cast<uint8_t>(h->h_addr[0]),
                         static_cast<uint8_t>(h->h_addr[1]),
@@ -2513,21 +2514,21 @@ bool admin_confs(XString w1, ZString w2)
                 });
             }
         }
-        else if (w1 == "login_port"_s)
+        else if (w1.data == "login_port"_s)
         {
-            login_port = atoi(w2.c_str());
+            login_port = atoi(w2.data.c_str());
         }
-        else if (w1 == "admin_pass"_s)
+        else if (w1.data == "admin_pass"_s)
         {
-            admin_pass = stringish<AccountPass>(w2);
+            admin_pass = stringish<AccountPass>(w2.data);
         }
-        else if (w1 == "ladmin_log_filename"_s)
+        else if (w1.data == "ladmin_log_filename"_s)
         {
-            ladmin_log_filename = w2;
+            ladmin_log_filename = w2.data;
         }
         else
         {
-            PRINTF("WARNING: unknown ladmin config key: %s\n"_fmt, AString(w1));
+            PRINTF("WARNING: unknown ladmin config key: %s\n"_fmt, AString(w1.data));
             return false;
         }
     }
diff --git a/src/char/char.cpp b/src/char/char.cpp
index a82a692..ff9905f 100644
--- a/src/char/char.cpp
+++ b/src/char/char.cpp
@@ -52,6 +52,7 @@
 #include "../io/extract.hpp"
 #include "../io/lock.hpp"
 #include "../io/read.hpp"
+#include "../io/span.hpp"
 #include "../io/tty.hpp"
 #include "../io/write.hpp"
 
@@ -2737,15 +2738,15 @@ void check_connect_login_server(TimerData *, tick_t)
 // Reading Lan Support configuration by [Yor]
 //-------------------------------------------
 static
-bool char_lan_config(XString w1, ZString w2)
+bool char_lan_config(io::Spanned<XString> w1, io::Spanned<ZString> w2)
 {
     struct hostent *h = nullptr;
 
     {
-        if (w1 == "lan_map_ip"_s)
+        if (w1.data == "lan_map_ip"_s)
         {
             // Read map-server Lan IP Address
-            h = gethostbyname(w2.c_str());
+            h = gethostbyname(w2.data.c_str());
             if (h != nullptr)
             {
                 lan_map_ip = IP4Address({
@@ -2757,17 +2758,17 @@ bool char_lan_config(XString w1, ZString w2)
             }
             else
             {
-                PRINTF("Bad IP value: %s\n"_fmt, w2);
+                PRINTF("Bad IP value: %s\n"_fmt, w2.data);
                 return false;
             }
             PRINTF("LAN IP of map-server: %s.\n"_fmt, lan_map_ip);
         }
-        else if (w1 == "subnet"_s /*backward compatibility*/
-                || w1 == "lan_subnet"_s)
+        else if (w1.data == "subnet"_s /*backward compatibility*/
+                || w1.data == "lan_subnet"_s)
         {
-            if (!extract(w2, &lan_subnet))
+            if (!extract(w2.data, &lan_subnet))
             {
-                PRINTF("Bad IP mask: %s\n"_fmt, w2);
+                PRINTF("Bad IP mask: %s\n"_fmt, w2.data);
                 return false;
             }
             PRINTF("Sub-network of the map-server: %s.\n"_fmt,
@@ -2798,23 +2799,23 @@ bool lan_check()
 }
 
 static
-bool char_config(XString w1, ZString w2)
+bool char_config(io::Spanned<XString> w1, io::Spanned<ZString> w2)
 {
     struct hostent *h = nullptr;
 
     {
-        if (w1 == "userid"_s)
-            userid = stringish<AccountName>(w2);
-        else if (w1 == "passwd"_s)
-            passwd = stringish<AccountPass>(w2);
-        else if (w1 == "server_name"_s)
+        if (w1.data == "userid"_s)
+            userid = stringish<AccountName>(w2.data);
+        else if (w1.data == "passwd"_s)
+            passwd = stringish<AccountPass>(w2.data);
+        else if (w1.data == "server_name"_s)
         {
-            server_name = stringish<ServerName>(w2);
-            PRINTF("%s server has been intialized\n"_fmt, w2);
+            server_name = stringish<ServerName>(w2.data);
+            PRINTF("%s server has been intialized\n"_fmt, w2.data);
         }
-        else if (w1 == "login_ip"_s)
+        else if (w1.data == "login_ip"_s)
         {
-            h = gethostbyname(w2.c_str());
+            h = gethostbyname(w2.data.c_str());
             if (h != nullptr)
             {
                 login_ip = IP4Address({
@@ -2824,21 +2825,21 @@ bool char_config(XString w1, ZString w2)
                         static_cast<uint8_t>(h->h_addr[3]),
                 });
                 PRINTF("Login server IP address : %s -> %s\n"_fmt,
-                        w2, login_ip);
+                        w2.data, login_ip);
             }
             else
             {
-                PRINTF("Bad IP value: %s\n"_fmt, w2);
+                PRINTF("Bad IP value: %s\n"_fmt, w2.data);
                 return false;
             }
         }
-        else if (w1 == "login_port"_s)
+        else if (w1.data == "login_port"_s)
         {
-            login_port = atoi(w2.c_str());
+            login_port = atoi(w2.data.c_str());
         }
-        else if (w1 == "char_ip"_s)
+        else if (w1.data == "char_ip"_s)
         {
-            h = gethostbyname(w2.c_str());
+            h = gethostbyname(w2.data.c_str());
             if (h != nullptr)
             {
                 char_ip = IP4Address({
@@ -2848,81 +2849,81 @@ bool char_config(XString w1, ZString w2)
                         static_cast<uint8_t>(h->h_addr[3]),
                 });
                 PRINTF("Character server IP address : %s -> %s\n"_fmt,
-                        w2, char_ip);
+                        w2.data, char_ip);
             }
             else
             {
-                PRINTF("Bad IP value: %s\n"_fmt, w2);
+                PRINTF("Bad IP value: %s\n"_fmt, w2.data);
                 return false;
             }
         }
-        else if (w1 == "char_port"_s)
+        else if (w1.data == "char_port"_s)
         {
-            char_port = atoi(w2.c_str());
+            char_port = atoi(w2.data.c_str());
         }
-        else if (w1 == "char_txt"_s)
+        else if (w1.data == "char_txt"_s)
         {
-            char_txt = w2;
+            char_txt = w2.data;
         }
-        else if (w1 == "max_connect_user"_s)
+        else if (w1.data == "max_connect_user"_s)
         {
-            max_connect_user = atoi(w2.c_str());
+            max_connect_user = atoi(w2.data.c_str());
             if (max_connect_user < 0)
                 max_connect_user = 0;   // unlimited online players
         }
-        else if (w1 == "autosave_time"_s)
+        else if (w1.data == "autosave_time"_s)
         {
-            autosave_time = std::chrono::seconds(atoi(w2.c_str()));
+            autosave_time = std::chrono::seconds(atoi(w2.data.c_str()));
             if (autosave_time <= std::chrono::seconds::zero())
                 autosave_time = DEFAULT_AUTOSAVE_INTERVAL;
         }
-        else if (w1 == "start_point"_s)
+        else if (w1.data == "start_point"_s)
         {
-            extract(w2, &start_point);
+            extract(w2.data, &start_point);
         }
-        else if (w1 == "unknown_char_name"_s)
+        else if (w1.data == "unknown_char_name"_s)
         {
-            unknown_char_name = stringish<CharName>(w2);
+            unknown_char_name = stringish<CharName>(w2.data);
         }
-        else if (w1 == "char_log_filename"_s)
+        else if (w1.data == "char_log_filename"_s)
         {
-            char_log_filename = w2;
+            char_log_filename = w2.data;
         }
-        else if (w1 == "char_name_letters"_s)
+        else if (w1.data == "char_name_letters"_s)
         {
-            if (!w2)
+            if (!w2.data)
                 char_name_letters.reset();
             else
-                for (uint8_t c : w2)
+                for (uint8_t c : w2.data)
                     char_name_letters[c] = true;
         }
-        else if (w1 == "online_txt_filename"_s)
+        else if (w1.data == "online_txt_filename"_s)
         {
-            online_txt_filename = w2;
+            online_txt_filename = w2.data;
         }
-        else if (w1 == "online_html_filename"_s)
+        else if (w1.data == "online_html_filename"_s)
         {
-            online_html_filename = w2;
+            online_html_filename = w2.data;
         }
-        else if (w1 == "online_gm_display_min_level"_s)
+        else if (w1.data == "online_gm_display_min_level"_s)
         {
             // minimum GM level to display 'GM' when we want to display it
-            return extract(w2, &online_gm_display_min_level);
+            return extract(w2.data, &online_gm_display_min_level);
         }
-        else if (w1 == "online_refresh_html"_s)
+        else if (w1.data == "online_refresh_html"_s)
         {
-            online_refresh_html = atoi(w2.c_str());
+            online_refresh_html = atoi(w2.data.c_str());
             if (online_refresh_html < 1)
                 online_refresh_html = 1;
         }
-        else if (w1 == "anti_freeze_enable"_s)
+        else if (w1.data == "anti_freeze_enable"_s)
         {
-            anti_freeze_enable = config_switch(w2);
+            anti_freeze_enable = config_switch(w2.data);
         }
-        else if (w1 == "anti_freeze_interval"_s)
+        else if (w1.data == "anti_freeze_interval"_s)
         {
             anti_freeze_interval = std::max(
-                    std::chrono::seconds(atoi(w2.c_str())),
+                    std::chrono::seconds(atoi(w2.data.c_str())),
                     5_s);
         }
         else
@@ -2954,15 +2955,19 @@ void term_func(void)
 }
 
 static
-bool char_confs(XString key, ZString value)
+bool char_confs(io::Spanned<XString> key, io::Spanned<ZString> value)
 {
-    unsigned sum = 0;
-    sum += char_config(key, value);
-    sum += char_lan_config(key, value);
-    sum += inter_config(key, value);
-    if (sum >= 2)
-        abort();
-    return sum;
+    bool ok;
+    ok = char_config(key, value);
+    if (!ok)
+        return ok;
+    ok = char_lan_config(key, value);
+    if (!ok)
+        return ok;
+    ok = inter_config(key, value);
+    if (!ok)
+        return ok;
+    return ok;
 }
 
 int do_init(Slice<ZString> argv)
diff --git a/src/char/inter.cpp b/src/char/inter.cpp
index 4b10dbc..df05434 100644
--- a/src/char/inter.cpp
+++ b/src/char/inter.cpp
@@ -37,8 +37,11 @@
 #include "../io/extract.hpp"
 #include "../io/lock.hpp"
 #include "../io/read.hpp"
+#include "../io/span.hpp"
 #include "../io/write.hpp"
 
+#include "../mmo/config_parse.hpp"
+
 #include "../proto2/char-map.hpp"
 
 #include "../high/extract_mmo.hpp"
@@ -157,24 +160,24 @@ int inter_accreg_save(void)
     return 0;
 }
 
-bool inter_config(XString w1, ZString w2)
+bool inter_config(io::Spanned<XString> w1, io::Spanned<ZString> w2)
 {
     {
-        if (w1 == "storage_txt"_s)
+        if (w1.data == "storage_txt"_s)
         {
-            storage_txt = w2;
+            storage_txt = w2.data;
         }
-        else if (w1 == "party_txt"_s)
+        else if (w1.data == "party_txt"_s)
         {
-            party_txt = w2;
+            party_txt = w2.data;
         }
-        else if (w1 == "accreg_txt"_s)
+        else if (w1.data == "accreg_txt"_s)
         {
-            accreg_txt = w2;
+            accreg_txt = w2.data;
         }
-        else if (w1 == "party_share_level"_s)
+        else if (w1.data == "party_share_level"_s)
         {
-            party_share_level = atoi(w2.c_str());
+            party_share_level = atoi(w2.data.c_str());
             if (party_share_level < 0)
                 party_share_level = 0;
         }
diff --git a/src/char/inter.hpp b/src/char/inter.hpp
index 31d80b1..741bc8d 100644
--- a/src/char/inter.hpp
+++ b/src/char/inter.hpp
@@ -25,7 +25,7 @@
 
 namespace tmwa
 {
-bool inter_config(XString key, ZString value);
+bool inter_config(io::Spanned<XString> key, io::Spanned<ZString> value);
 void inter_init2();
 void inter_save(void);
 RecvResult inter_parse_frommap(Session *ms, uint16_t packet_id);
diff --git a/src/io/span.cpp b/src/io/span.cpp
index f4752f0..6d116c7 100644
--- a/src/io/span.cpp
+++ b/src/io/span.cpp
@@ -31,6 +31,20 @@ namespace tmwa
 {
 namespace io
 {
+    io::LineSpan Line::to_span() const
+    {
+        io::LineSpan rv;
+        rv.begin.text = this->text;
+        rv.begin.filename = this->filename;
+        rv.begin.line = this->line;
+        rv.begin.column = 1;
+        rv.end.text = this->text;
+        rv.end.filename = this->filename;
+        rv.end.line = this->line;
+        rv.end.column = this->text.size();
+        return rv;
+    }
+
     AString Line::message_str(ZString cat, ZString msg) const
     {
         MString out;
diff --git a/src/io/span.hpp b/src/io/span.hpp
index e474a7a..9962b7c 100644
--- a/src/io/span.hpp
+++ b/src/io/span.hpp
@@ -46,6 +46,8 @@ namespace io
         void note(ZString msg) const { message("note"_s, msg); }
         void warning(ZString msg) const { message("warning"_s, msg); }
         void error(ZString msg) const { message("error"_s, msg); }
+
+        LineSpan to_span() const;
     };
 
     // psst, don't tell anyone
diff --git a/src/login/login.cpp b/src/login/login.cpp
index fb90cbe..92e7636 100644
--- a/src/login/login.cpp
+++ b/src/login/login.cpp
@@ -48,6 +48,7 @@
 #include "../io/extract.hpp"
 #include "../io/lock.hpp"
 #include "../io/read.hpp"
+#include "../io/span.hpp"
 #include "../io/tty.hpp"
 #include "../io/write.hpp"
 
@@ -3012,15 +3013,15 @@ void parse_login(Session *s)
 // Reading Lan Support configuration
 //----------------------------------
 static
-bool login_lan_config(XString w1, ZString w2)
+bool login_lan_config(io::Spanned<XString> w1, io::Spanned<ZString> w2)
 {
     struct hostent *h = nullptr;
 
     {
-        if (w1 == "lan_char_ip"_s)
+        if (w1.data == "lan_char_ip"_s)
         {
             // Read Char-Server Lan IP Address
-            h = gethostbyname(w2.c_str());
+            h = gethostbyname(w2.data.c_str());
             if (h != nullptr)
             {
                 lan_char_ip = IP4Address({
@@ -3032,17 +3033,17 @@ bool login_lan_config(XString w1, ZString w2)
             }
             else
             {
-                PRINTF("Bad IP value: %s\n"_fmt, w2);
+                PRINTF("Bad IP value: %s\n"_fmt, w2.data);
                 return false;
             }
             PRINTF("LAN IP of char-server: %s.\n"_fmt, lan_char_ip);
         }
-        else if (w1 == "subnet"_s /*backward compatibility*/
-                || w1 == "lan_subnet"_s)
+        else if (w1.data == "subnet"_s /*backward compatibility*/
+                || w1.data == "lan_subnet"_s)
         {
-            if (!extract(w2, &lan_subnet))
+            if (!extract(w2.data, &lan_subnet))
             {
-                PRINTF("Bad IP mask: %s\n"_fmt, w2);
+                PRINTF("Bad IP mask: %s\n"_fmt, w2.data);
                 return false;
             }
             PRINTF("Sub-network of the char-server: %s.\n"_fmt,
@@ -3083,195 +3084,195 @@ bool lan_check()
 // Reading general configuration file
 //-----------------------------------
 static
-bool login_config(XString w1, ZString w2)
+bool login_config(io::Spanned<XString> w1, io::Spanned<ZString> w2)
 {
     {
-        if (w1 == "admin_state"_s)
+        if (w1.data == "admin_state"_s)
         {
-            admin_state = config_switch(w2);
+            admin_state = config_switch(w2.data);
         }
-        else if (w1 == "admin_pass"_s)
+        else if (w1.data == "admin_pass"_s)
         {
-            admin_pass = stringish<AccountPass>(w2);
+            admin_pass = stringish<AccountPass>(w2.data);
         }
-        else if (w1 == "ladminallowip"_s)
+        else if (w1.data == "ladminallowip"_s)
         {
-            if (w2 == "clear"_s)
+            if (w2.data == "clear"_s)
             {
                 access_ladmin.clear();
             }
             else
             {
                 // a.b.c.d/0.0.0.0 (canonically, 0.0.0.0/0) covers all
-                if (w2 == "all"_s)
+                if (w2.data == "all"_s)
                 {
                     // reset all previous values
                     access_ladmin.clear();
                     // set to all
                     access_ladmin.push_back(IP4Mask());
                 }
-                else if (w2
+                else if (w2.data
                         && !(access_ladmin.size() == 1
                             && access_ladmin.front().mask() == IP4Address()))
                 {
                     // don't add IP if already 'all'
                     IP4Mask n;
-                    if (!extract(w2, &n))
+                    if (!extract(w2.data, &n))
                     {
-                        PRINTF("Bad IP mask: %s\n"_fmt, w2);
+                        PRINTF("Bad IP mask: %s\n"_fmt, w2.data);
                         return false;
                     }
                     access_ladmin.push_back(n);
                 }
             }
         }
-        else if (w1 == "gm_pass"_s)
+        else if (w1.data == "gm_pass"_s)
         {
-            gm_pass = w2;
+            gm_pass = w2.data;
         }
-        else if (w1 == "level_new_gm"_s)
+        else if (w1.data == "level_new_gm"_s)
         {
-            level_new_gm = GmLevel::from(static_cast<uint32_t>(atoi(w2.c_str())));
+            level_new_gm = GmLevel::from(static_cast<uint32_t>(atoi(w2.data.c_str())));
         }
-        else if (w1 == "new_account"_s)
+        else if (w1.data == "new_account"_s)
         {
-            new_account = config_switch(w2);
+            new_account = config_switch(w2.data);
         }
-        else if (w1 == "login_port"_s)
+        else if (w1.data == "login_port"_s)
         {
-            login_port = atoi(w2.c_str());
+            login_port = atoi(w2.data.c_str());
         }
-        else if (w1 == "account_filename"_s)
+        else if (w1.data == "account_filename"_s)
         {
-            account_filename = w2;
+            account_filename = w2.data;
         }
-        else if (w1 == "gm_account_filename"_s)
+        else if (w1.data == "gm_account_filename"_s)
         {
-            gm_account_filename = w2;
+            gm_account_filename = w2.data;
         }
-        else if (w1 == "gm_account_filename_check_timer"_s)
+        else if (w1.data == "gm_account_filename_check_timer"_s)
         {
-            gm_account_filename_check_timer = std::chrono::seconds(atoi(w2.c_str()));
+            gm_account_filename_check_timer = std::chrono::seconds(atoi(w2.data.c_str()));
         }
-        else if (w1 == "login_log_filename"_s)
+        else if (w1.data == "login_log_filename"_s)
         {
-            login_log_filename = w2;
+            login_log_filename = w2.data;
         }
-        else if (w1 == "display_parse_login"_s)
+        else if (w1.data == "display_parse_login"_s)
         {
-            display_parse_login = config_switch(w2);   // 0: no, 1: yes
+            display_parse_login = config_switch(w2.data);   // 0: no, 1: yes
         }
-        else if (w1 == "display_parse_admin"_s)
+        else if (w1.data == "display_parse_admin"_s)
         {
-            display_parse_admin = config_switch(w2);   // 0: no, 1: yes
+            display_parse_admin = config_switch(w2.data);   // 0: no, 1: yes
         }
-        else if (w1 == "display_parse_fromchar"_s)
+        else if (w1.data == "display_parse_fromchar"_s)
         {
-            display_parse_fromchar = config_switch(w2);    // 0: no, 1: yes (without packet 0x2714), 2: all packets
+            display_parse_fromchar = config_switch(w2.data);    // 0: no, 1: yes (without packet 0x2714), 2: all packets
         }
-        else if (w1 == "min_level_to_connect"_s)
+        else if (w1.data == "min_level_to_connect"_s)
         {
-            min_level_to_connect = GmLevel::from(static_cast<uint32_t>(atoi(w2.c_str())));
+            min_level_to_connect = GmLevel::from(static_cast<uint32_t>(atoi(w2.data.c_str())));
         }
-        else if (w1 == "order"_s)
+        else if (w1.data == "order"_s)
         {
-            if (w2 == "deny,allow"_s || w2 == "deny, allow"_s)
+            if (w2.data == "deny,allow"_s || w2.data == "deny, allow"_s)
                 access_order = ACO::DENY_ALLOW;
-            else if (w2 == "allow,deny"_s || w2 == "allow, deny"_s)
+            else if (w2.data == "allow,deny"_s || w2.data == "allow, deny"_s)
                 access_order = ACO::ALLOW_DENY;
-            else if (w2 == "mutual-failture"_s || w2 == "mutual-failure"_s)
+            else if (w2.data == "mutual-failture"_s || w2.data == "mutual-failure"_s)
                 access_order = ACO::MUTUAL_FAILURE;
             else
             {
-                PRINTF("Bad order: %s\n"_fmt, w2);
+                PRINTF("Bad order: %s\n"_fmt, w2.data);
                 return false;
             }
         }
-        else if (w1 == "allow"_s)
+        else if (w1.data == "allow"_s)
         {
-            if (w2 == "clear"_s)
+            if (w2.data == "clear"_s)
             {
                 access_allow.clear();
             }
             else
             {
-                if (w2 == "all"_s)
+                if (w2.data == "all"_s)
                 {
                     // reset all previous values
                     access_allow.clear();
                     // set to all
                     access_allow.push_back(IP4Mask());
                 }
-                else if (w2
+                else if (w2.data
                         && !(access_allow.size() == 1
                             && access_allow.front().mask() == IP4Address()))
                 {
                     // don't add IP if already 'all'
                     IP4Mask n;
-                    if (!extract(w2, &n))
+                    if (!extract(w2.data, &n))
                     {
-                        PRINTF("Bad IP mask: %s\n"_fmt, w2);
+                        PRINTF("Bad IP mask: %s\n"_fmt, w2.data);
                         return false;
                     }
                     access_allow.push_back(n);
                 }
             }
         }
-        else if (w1 == "deny"_s)
+        else if (w1.data == "deny"_s)
         {
-            if (w2 == "clear"_s)
+            if (w2.data == "clear"_s)
             {
                 access_deny.clear();
             }
             else
             {
-                if (w2 == "all"_s)
+                if (w2.data == "all"_s)
                 {
                     // reset all previous values
                     access_deny.clear();
                     // set to all
                     access_deny.push_back(IP4Mask());
                 }
-                else if (w2
+                else if (w2.data
                         && !(access_deny.size() == 1
                             && access_deny.front().mask() == IP4Address()))
                 {
                     // don't add IP if already 'all'
                     IP4Mask n;
-                    if (!extract(w2, &n))
+                    if (!extract(w2.data, &n))
                     {
-                        PRINTF("Bad IP mask: %s\n"_fmt, w2);
+                        PRINTF("Bad IP mask: %s\n"_fmt, w2.data);
                         return false;
                     }
                     access_deny.push_back(n);
                 }
             }
         }
-        else if (w1 == "anti_freeze_enable"_s)
+        else if (w1.data == "anti_freeze_enable"_s)
         {
-            anti_freeze_enable = config_switch(w2);
+            anti_freeze_enable = config_switch(w2.data);
         }
-        else if (w1 == "anti_freeze_interval"_s)
+        else if (w1.data == "anti_freeze_interval"_s)
         {
             anti_freeze_interval = std::max(
-                    std::chrono::seconds(atoi(w2.c_str())),
+                    std::chrono::seconds(atoi(w2.data.c_str())),
                     5_s);
         }
-        else if (w1 == "update_host"_s)
+        else if (w1.data == "update_host"_s)
         {
-            update_host = w2;
+            update_host = w2.data;
         }
-        else if (w1 == "main_server"_s)
+        else if (w1.data == "main_server"_s)
         {
-            main_server = stringish<ServerName>(w2);
+            main_server = stringish<ServerName>(w2.data);
         }
-        else if (w1 == "userid"_s)
+        else if (w1.data == "userid"_s)
         {
-            userid = stringish<AccountName>(w2);
+            userid = stringish<AccountName>(w2.data);
         }
-        else if (w1 == "passwd"_s)
+        else if (w1.data == "passwd"_s)
         {
-            passwd = stringish<AccountPass>(w2);
+            passwd = stringish<AccountPass>(w2.data);
         }
         else
         {
@@ -3582,14 +3583,16 @@ void term_func(void)
 }
 
 static
-bool login_confs(XString key, ZString value)
+bool login_confs(io::Spanned<XString> key, io::Spanned<ZString> value)
 {
-    unsigned sum = 0;
-    sum += login_config(key, value);
-    sum += login_lan_config(key, value);
-    if (sum >= 2)
-        abort();
-    return sum;
+    bool ok;
+    ok = login_config(key, value);
+    if (!ok)
+        return ok;
+    ok = login_lan_config(key, value);
+    if (!ok)
+        return ok;
+    return ok;
 }
 
 //------------------------------
diff --git a/src/map/atcommand.cpp b/src/map/atcommand.cpp
index f08d561..5853dc2 100644
--- a/src/map/atcommand.cpp
+++ b/src/map/atcommand.cpp
@@ -351,43 +351,22 @@ Option<Borrowed<AtCommandInfo>> get_atcommandinfo_byname(XString name)
     return atcommand_info.search(name);
 }
 
-bool atcommand_config_read(ZString cfgName)
+static
+bool atcommand_config(io::Spanned<XString> w1, io::Spanned<ZString> w2)
 {
-    io::ReadFile in(cfgName);
-    if (!in.is_open())
-    {
-        PRINTF("At commands configuration file not found: %s\n"_fmt, cfgName);
-        return false;
-    }
-
     bool rv = true;
-    AString line;
-    while (in.getline(line))
     {
-        if (is_comment(line))
-            continue;
-        XString w1;
-        ZString w2;
-        if (!config_split(line, &w1, &w2))
-        {
-            PRINTF("Bad config line: %s\n"_fmt, line);
-            rv = false;
-            continue;
-        }
-        Option<P<AtCommandInfo>> p_ = get_atcommandinfo_byname(w1);
+        Option<P<AtCommandInfo>> p_ = get_atcommandinfo_byname(w1.data);
         OMATCH_BEGIN (p_)
         {
             OMATCH_CASE_SOME (p)
             {
-                p->level = GmLevel::from(static_cast<uint32_t>(atoi(w2.c_str())));
+                p->level = GmLevel::from(static_cast<uint32_t>(atoi(w2.data.c_str())));
             }
             OMATCH_CASE_NONE ()
             {
-                if (w1 == "import"_s)
-                    rv &= atcommand_config_read(w2);
-                else
                 {
-                    PRINTF("%s: bad line: %s\n"_fmt, cfgName, line);
+                    w1.span.error("Unknown @command for permission level config."_s);
                     rv = false;
                 }
             }
@@ -398,6 +377,11 @@ bool atcommand_config_read(ZString cfgName)
     return rv;
 }
 
+bool atcommand_config_read(ZString cfgName)
+{
+    return load_config_file(cfgName, atcommand_config);
+}
+
 /// @ command processing functions
 
 static
diff --git a/src/map/battle.cpp b/src/map/battle.cpp
index a04e402..983eac3 100644
--- a/src/map/battle.cpp
+++ b/src/map/battle.cpp
@@ -33,6 +33,7 @@
 
 #include "../io/cxxstdio.hpp"
 #include "../io/read.hpp"
+#include "../io/span.hpp"
 
 #include "../mmo/config_parse.hpp"
 #include "../mmo/cxxstdio_enums.hpp"
@@ -2278,18 +2279,9 @@ Battle_Config init_battle_config()
     return battle_config;
 }
 
-bool battle_config_read(ZString cfgName)
+static
+bool battle_config_(io::Spanned<XString> w1, io::Spanned<ZString> w2)
 {
-    bool rv = true;
-    io::ReadFile in(cfgName);
-    if (!in.is_open())
-    {
-        PRINTF("file not found: %s\n"_fmt, cfgName);
-        return false;
-    }
-
-    AString line;
-    while (in.getline(line))
     {
 #define BATTLE_CONFIG_VAR(name) {#name##_s, &battle_config.name}
         const struct
@@ -2395,38 +2387,24 @@ bool battle_config_read(ZString cfgName)
             BATTLE_CONFIG_VAR(mob_splash_radius),
         };
 
-        if (is_comment(line))
-            continue;
-        XString w1;
-        ZString w2;
-        if (!config_split(line, &w1, &w2))
-        {
-            PRINTF("Bad config line: %s\n"_fmt, line);
-            rv = false;
-            continue;
-        }
-
-        if (w1 == "import"_s)
-        {
-            battle_config_read(w2);
-            continue;
-        }
-
         for (auto datum : data)
-            if (w1 == datum.str)
+        {
+            if (w1.data == datum.str)
             {
-                *datum.val = config_switch(w2);
-                goto continue_outer;
+                *datum.val = config_switch(w2.data);
+                return true;
             }
+        }
 
-        PRINTF("WARNING: unknown battle conf key: %s\n"_fmt, AString(w1));
-        rv = false;
+        PRINTF("WARNING: unknown battle conf key: %s\n"_fmt, AString(w1.data));
+        return false;
 
-    continue_outer:
-        ;
     }
+}
 
-    return rv;
+bool battle_config_read(ZString cfgName)
+{
+    return load_config_file(cfgName, battle_config_);
 }
 
 void battle_config_check()
diff --git a/src/map/map.cpp b/src/map/map.cpp
index 527d3c3..25933a9 100644
--- a/src/map/map.cpp
+++ b/src/map/map.cpp
@@ -47,6 +47,7 @@
 #include "../io/cxxstdio.hpp"
 #include "../io/extract.hpp"
 #include "../io/read.hpp"
+#include "../io/span.hpp"
 #include "../io/tty.hpp"
 #include "../io/write.hpp"
 
@@ -1477,49 +1478,25 @@ void map_log(XString line)
     log_with_timestamp(*map_logfile, line);
 }
 
-/*==========================================
- * 設定ファイルを読み込む
- *------------------------------------------
- */
 static
-bool map_config_read(ZString cfgName)
+bool map_config(io::Spanned<XString> w1, io::Spanned<ZString> w2)
 {
     struct hostent *h = nullptr;
 
-    io::ReadFile in(cfgName);
-    if (!in.is_open())
-    {
-        PRINTF("Map configuration file not found at: %s\n"_fmt, cfgName);
-        return false;
-    }
-
-    bool rv = true;
-    AString line;
-    while (in.getline(line))
     {
-        if (is_comment(line))
-            continue;
-        XString w1;
-        ZString w2;
-        if (!config_split(line, &w1, &w2))
+        if (w1.data == "userid"_s)
         {
-            PRINTF("Bad config line: %s\n"_fmt, line);
-            rv = false;
-            continue;
-        }
-        if (w1 == "userid"_s)
-        {
-            AccountName name = stringish<AccountName>(w2);
+            AccountName name = stringish<AccountName>(w2.data);
             chrif_setuserid(name);
         }
-        else if (w1 == "passwd"_s)
+        else if (w1.data == "passwd"_s)
         {
-            AccountPass pass = stringish<AccountPass>(w2);
+            AccountPass pass = stringish<AccountPass>(w2.data);
             chrif_setpasswd(pass);
         }
-        else if (w1 == "char_ip"_s)
+        else if (w1.data == "char_ip"_s)
         {
-            h = gethostbyname(w2.c_str());
+            h = gethostbyname(w2.data.c_str());
             IP4Address w2ip;
             if (h != nullptr)
             {
@@ -1530,22 +1507,22 @@ bool map_config_read(ZString cfgName)
                         static_cast<uint8_t>(h->h_addr[3]),
                 });
                 PRINTF("Character server IP address : %s -> %s\n"_fmt,
-                        w2, w2ip);
+                        w2.data, w2ip);
             }
             else
             {
-                PRINTF("Bad IP value: %s\n"_fmt, line);
+                PRINTF("Bad IP value: %s\n"_fmt, w2.data);
                 return false;
             }
             chrif_setip(w2ip);
         }
-        else if (w1 == "char_port"_s)
+        else if (w1.data == "char_port"_s)
         {
-            chrif_setport(atoi(w2.c_str()));
+            chrif_setport(atoi(w2.data.c_str()));
         }
-        else if (w1 == "map_ip"_s)
+        else if (w1.data == "map_ip"_s)
         {
-            h = gethostbyname(w2.c_str());
+            h = gethostbyname(w2.data.c_str());
             IP4Address w2ip;
             if (h != nullptr)
             {
@@ -1556,66 +1533,70 @@ bool map_config_read(ZString cfgName)
                         static_cast<uint8_t>(h->h_addr[3]),
                 });
                 PRINTF("Map server IP address : %s -> %s\n"_fmt,
-                        w2, w2ip);
+                        w2.data, w2ip);
             }
             else
             {
-                PRINTF("Bad IP value: %s\n"_fmt, line);
+                PRINTF("Bad IP value: %s\n"_fmt, w2.data);
                 return false;
             }
             clif_setip(w2ip);
         }
-        else if (w1 == "map_port"_s)
+        else if (w1.data == "map_port"_s)
         {
-            clif_setport(atoi(w2.c_str()));
+            clif_setport(atoi(w2.data.c_str()));
         }
-        else if (w1 == "map"_s)
+        else if (w1.data == "map"_s)
         {
-            MapName name = VString<15>(w2);
+            MapName name = VString<15>(w2.data);
             map_addmap(name);
         }
-        else if (w1 == "delmap"_s)
+        else if (w1.data == "delmap"_s)
         {
-            MapName name = VString<15>(w2);
+            MapName name = VString<15>(w2.data);
             map_delmap(name);
         }
-        else if (w1 == "npc"_s)
+        else if (w1.data == "npc"_s)
         {
-            npc_addsrcfile(w2);
+            npc_addsrcfile(w2.data);
         }
-        else if (w1 == "delnpc"_s)
+        else if (w1.data == "delnpc"_s)
         {
-            npc_delsrcfile(w2);
+            npc_delsrcfile(w2.data);
         }
-        else if (w1 == "autosave_time"_s)
+        else if (w1.data == "autosave_time"_s)
         {
-            autosave_time = std::chrono::seconds(atoi(w2.c_str()));
+            autosave_time = std::chrono::seconds(atoi(w2.data.c_str()));
             if (autosave_time <= interval_t::zero())
                 autosave_time = DEFAULT_AUTOSAVE_INTERVAL;
         }
-        else if (w1 == "motd_txt"_s)
+        else if (w1.data == "motd_txt"_s)
         {
-            motd_txt = w2;
+            motd_txt = w2.data;
         }
-        else if (w1 == "mapreg_txt"_s)
+        else if (w1.data == "mapreg_txt"_s)
         {
-            mapreg_txt = w2;
+            mapreg_txt = w2.data;
         }
-        else if (w1 == "gm_log"_s)
+        else if (w1.data == "gm_log"_s)
         {
-            gm_log = std::move(w2);
+            gm_log = std::move(w2.data);
         }
-        else if (w1 == "log_file"_s)
+        else if (w1.data == "log_file"_s)
         {
-            map_set_logfile(w2);
+            map_set_logfile(w2.data);
         }
-        else if (w1 == "import"_s)
+        else if (w1.data == "import"_s)
         {
-            rv &= map_config_read(w2);
+            return load_config_file(w2.data, map_config);
+        }
+        else
+        {
+            return false;
         }
     }
 
-    return rv;
+    return true;
 }
 
 static
@@ -1682,31 +1663,31 @@ int compare_item(Item *a, Item *b)
 }
 
 static
-bool map_confs(XString key, ZString value)
-{
-    if (key == "map_conf"_s)
-        return map_config_read(value);
-    if (key == "battle_conf"_s)
-        return battle_config_read(value);
-    if (key == "atcommand_conf"_s)
-        return atcommand_config_read(value);
-
-    if (key == "item_db"_s)
-        return itemdb_readdb(value);
-    if (key == "mob_db"_s)
-        return mob_readdb(value);
-    if (key == "mob_skill_db"_s)
-        return mob_readskilldb(value);
-    if (key == "skill_db"_s)
-        return skill_readdb(value);
-    if (key == "magic_conf"_s)
-        return magic::load_magic_file_v2(value);
-
-    if (key == "resnametable"_s)
-        return load_resnametable(value);
-    if (key == "const_db"_s)
-        return read_constdb(value);
-    PRINTF("unknown map conf key: %s\n"_fmt, AString(key));
+bool map_confs(io::Spanned<XString> key, io::Spanned<ZString> value)
+{
+    if (key.data == "map_conf"_s)
+        return load_config_file(value.data, map_config);
+    if (key.data == "battle_conf"_s)
+        return battle_config_read(value.data);
+    if (key.data == "atcommand_conf"_s)
+        return atcommand_config_read(value.data);
+
+    if (key.data == "item_db"_s)
+        return itemdb_readdb(value.data);
+    if (key.data == "mob_db"_s)
+        return mob_readdb(value.data);
+    if (key.data == "mob_skill_db"_s)
+        return mob_readskilldb(value.data);
+    if (key.data == "skill_db"_s)
+        return skill_readdb(value.data);
+    if (key.data == "magic_conf"_s)
+        return magic::load_magic_file_v2(value.data);
+
+    if (key.data == "resnametable"_s)
+        return load_resnametable(value.data);
+    if (key.data == "const_db"_s)
+        return read_constdb(value.data);
+    PRINTF("unknown map conf key: %s\n"_fmt, AString(key.data));
     return false;
 }
 
diff --git a/src/mmo/config_parse.cpp b/src/mmo/config_parse.cpp
index b47ee79..6e43471 100644
--- a/src/mmo/config_parse.cpp
+++ b/src/mmo/config_parse.cpp
@@ -23,6 +23,8 @@
 #include "../strings/xstring.hpp"
 #include "../strings/zstring.hpp"
 
+#include "../compat/borrow.hpp"
+
 #include "../io/cxxstdio.hpp"
 #include "../io/extract.hpp"
 #include "../io/line.hpp"
@@ -39,40 +41,139 @@ bool is_comment(XString line)
     return not line or line.startswith("//"_s);
 }
 
+template<class ZS>
+static
+bool do_split(io::Spanned<ZS> line, io::Spanned<XString> *key, io::Spanned<ZS> *value)
+{
+    typename ZS::iterator colon = std::find(line.data.begin(), line.data.end(), ':');
+    if (colon == line.data.end())
+        return false;
+    key->data = line.data.xislice_h(colon);
+    key->span = line.span;
+    key->span.end.column = key->span.begin.column + key->data.size() - 1;
+    ++colon;
+    value->data = line.data.xislice_t(colon);
+    value->span = line.span;
+    value->span.begin.column = value->span.end.column - value->data.size() + 1;
+    return true;
+}
+
+template<class ZS>
+static
+io::Spanned<ZS> do_lstrip(io::Spanned<ZS> value)
+{
+    io::Spanned<ZS> rv;
+    rv.data = value.data.lstrip();
+    rv.span = value.span;
+    rv.span.begin.column += (value.data.size() - rv.data.size());
+    return rv;
+}
+
+static
+io::Spanned<XString> do_rstrip(io::Spanned<XString> value)
+{
+    io::Spanned<XString> rv;
+    rv.data = value.data.rstrip();
+    rv.span = value.span;
+    rv.span.end.column -= (value.data.size() - rv.data.size());
+    return rv;
+}
+
+static
+io::Spanned<XString> do_strip(io::Spanned<XString> value)
+{
+    return do_lstrip(do_rstrip(value));
+}
+
 template<class ZS>
 inline
-bool config_split_impl(ZS line, XString *key, ZS *value)
+bool config_split_impl(io::Spanned<ZS> line, io::Spanned<XString> *key, io::Spanned<ZS> *value)
 {
     // unconditionally fail if line contains control characters
-    if (std::find_if(line.begin(), line.end(),
+    if (std::find_if(line.data.begin(), line.data.end(),
                 [](unsigned char c) { return c < ' '; }
-                ) != line.end())
-        return false;
-    // try to find a colon, fail if not found
-    typename ZS::iterator colon = std::find(line.begin(), line.end(), ':');
-    if (colon == line.end())
+                ) != line.data.end())
         return false;
 
-    *key = line.xislice_h(colon).strip();
-    // move past the colon and any spaces
-    ++colon;
-    *value = line.xislice_t(colon).lstrip();
+    if (!do_split(line, key, value))
+        return false;
+    *key = do_strip(*key);
+    *value = do_lstrip(*value);
     return true;
 }
 
 // eventually this should go away
-bool config_split(ZString line, XString *key, ZString *value)
+// currently the only real offenders are io::FD::open and *PRINTF
+bool config_split(io::Spanned<ZString> line, io::Spanned<XString> *key, io::Spanned<ZString> *value)
 {
     return config_split_impl(line, key, value);
 }
-// and use this instead
-bool config_split(XString line, XString *key, XString *value)
+
+static
+bool check_version(io::Spanned<XString> avers, Borrowed<bool> valid)
 {
-    return config_split_impl(line, key, value);
+    enum
+    {
+        GE, LE, GT, LT
+    } cmp;
+
+    if (avers.data.startswith(">="_s))
+    {
+        cmp = GE;
+        avers.data = avers.data.xslice_t(2);
+        avers.span.begin.column += 2;
+    }
+    else if (avers.data.startswith('>'))
+    {
+        cmp = GT;
+        avers.data = avers.data.xslice_t(1);
+        avers.span.begin.column += 1;
+    }
+    else if (avers.data.startswith("<="_s))
+    {
+        cmp = LE;
+        avers.data = avers.data.xslice_t(2);
+        avers.span.begin.column += 2;
+    }
+    else if (avers.data.startswith('<'))
+    {
+        cmp = LT;
+        avers.data = avers.data.xslice_t(1);
+        avers.span.begin.column += 1;
+    }
+    else
+    {
+        avers.span.error("Version check must begin with one of: '>=', '>', '<=', '<'"_s);
+        *valid = false;
+        return false;
+    }
+
+    Version vers;
+    if (!extract(avers.data, &vers))
+    {
+        avers.span.error("Bad value"_s);
+        *valid = false;
+        return false;
+    }
+    switch (cmp)
+    {
+    case GE:
+        return CURRENT_VERSION >= vers;
+    case GT:
+        return CURRENT_VERSION > vers;
+    case LE:
+        return CURRENT_VERSION <= vers;
+    case LT:
+        return CURRENT_VERSION < vers;
+    }
+    abort();
 }
 
 /// Master config parser. This handles 'import' and 'version-ge' etc.
 /// Then it defers to the inferior parser for a line it does not understand.
+///
+/// Note: old-style 'version-ge: 1.2.3' etc apply to the rest of the file, but
+/// new-style 'version: >= 1.2.3' apply only up to the next 'version:'
 bool load_config_file(ZString filename, ConfigItemParser slave)
 {
     io::LineReader in(filename);
@@ -81,30 +182,66 @@ bool load_config_file(ZString filename, ConfigItemParser slave)
         PRINTF("Unable to open file: %s\n"_fmt, filename);
         return false;
     }
-    io::Line line;
+    io::Line line_;
     bool rv = true;
-    while (in.read_line(line))
+    bool good_version = true;
+    while (in.read_line(line_))
     {
-        if (is_comment(line.text))
+        if (is_comment(line_.text))
             continue;
-        XString key;
-        ZString value;
-        if (!config_split(line.text, &key, &value))
+        auto line = io::respan(line_.to_span(), ZString(line_.text));
+        io::Spanned<XString> key;
+        io::Spanned<ZString> value;
+        if (!config_split(line, &key, &value))
         {
-            line.error("Bad config line"_s);
+            line.span.error("Bad config line"_s);
             rv = false;
             continue;
         }
-        if (key == "import"_s)
+        if (key.data == "version"_s)
+        {
+            if (value.data == "all"_s)
+            {
+                good_version = true;
+            }
+            else
+            {
+                good_version = true;
+                while (good_version && value.data)
+                {
+                    ZString::iterator it = std::find(value.data.begin(), value.data.end(), ' ');
+                    io::Spanned<XString> value_head;
+                    value_head.data = value.data.xislice_h(it);
+                    value_head.span = value.span;
+                    value.data = value.data.xislice_t(it).lstrip();
+
+                    value_head.span.end.column = value_head.span.begin.column + value_head.data.size() - 1;
+                    value.span.begin.column = value.span.end.column - value.data.size() + 1;
+
+                    good_version &= check_version(value_head, borrow(rv));
+                }
+            }
+            continue;
+        }
+        if (!good_version)
         {
-            rv &= load_config_file(value, slave);
             continue;
         }
-        else if (key == "version-lt"_s)
+        if (key.data == "import"_s)
+        {
+            if (!load_config_file(value.data, slave))
+            {
+                value.span.error("Failed to include file"_s);
+                rv = false;
+            }
+            continue;
+        }
+        else if (key.data == "version-lt"_s)
         {
             Version vers;
-            if (!extract(value, &vers))
+            if (!extract(value.data, &vers))
             {
+                value.span.error("Bad value"_s);
                 rv = false;
                 continue;
             }
@@ -112,47 +249,48 @@ bool load_config_file(ZString filename, ConfigItemParser slave)
                 continue;
             break;
         }
-        else if (key == "version-le"_s)
+        else if (key.data == "version-le"_s)
         {
             Version vers;
-            if (!extract(value, &vers))
+            if (!extract(value.data, &vers))
             {
                 rv = false;
+                value.span.error("Bad value"_s);
                 continue;
             }
             if (CURRENT_VERSION <= vers)
                 continue;
             break;
         }
-        else if (key == "version-gt"_s)
+        else if (key.data == "version-gt"_s)
         {
             Version vers;
-            if (!extract(value, &vers))
+            if (!extract(value.data, &vers))
             {
                 rv = false;
+                value.span.error("Bad value"_s);
                 continue;
             }
             if (CURRENT_VERSION > vers)
                 continue;
             break;
         }
-        else if (key == "version-ge"_s)
+        else if (key.data == "version-ge"_s)
         {
             Version vers;
-            if (!extract(value, &vers))
+            if (!extract(value.data, &vers))
             {
                 rv = false;
+                value.span.error("Bad value"_s);
                 continue;
             }
             if (CURRENT_VERSION >= vers)
                 continue;
             break;
         }
-        else if (!slave(key, value))
+        else
         {
-            line.error("Bad config key or value"_s);
-            rv = false;
-            continue;
+            rv &= slave(key, value);
         }
         // nothing to see here, move along
     }
diff --git a/src/mmo/config_parse.hpp b/src/mmo/config_parse.hpp
index db097e9..06432ba 100644
--- a/src/mmo/config_parse.hpp
+++ b/src/mmo/config_parse.hpp
@@ -23,11 +23,10 @@
 
 namespace tmwa
 {
-typedef bool (*ConfigItemParser)(XString key, ZString value);
+using ConfigItemParser = bool(io::Spanned<XString> key, io::Spanned<ZString> value);
 
 bool is_comment(XString line);
-bool config_split(ZString line, XString *key, ZString *value);
-bool config_split(XString line, XString *key, XString *value);
+bool config_split(io::Spanned<ZString> line, io::Spanned<XString> *key, io::Spanned<ZString> *value);
 
 /// Master config parser. This handles 'import' and 'version-ge' etc.
 /// Then it defers to the inferior parser for a line it does not understand.
diff --git a/src/mmo/config_parse_test.cpp b/src/mmo/config_parse_test.cpp
new file mode 100644
index 0000000..e1170cb
--- /dev/null
+++ b/src/mmo/config_parse_test.cpp
@@ -0,0 +1,60 @@
+#include "config_parse.hpp"
+//    config_parse_test.cpp - Testsuite for config parsers
+//
+//    Copyright © 2014 Ben Longbons <b.r.longbons@gmail.com>
+//
+//    This file is part of The Mana World (Athena server)
+//
+//    This program is free software: you can redistribute it and/or modify
+//    it under the terms of the GNU General Public License as published by
+//    the Free Software Foundation, either version 3 of the License, or
+//    (at your option) any later version.
+//
+//    This program is distributed in the hope that it will be useful,
+//    but WITHOUT ANY WARRANTY; without even the implied warranty of
+//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//    GNU General Public License for more details.
+//
+//    You should have received a copy of the GNU General Public License
+//    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#include <gtest/gtest.h>
+
+#include "../strings/literal.hpp"
+#include "../strings/rstring.hpp"
+
+#include "../io/span.hpp"
+
+#include "../poison.hpp"
+
+
+namespace tmwa
+{
+#define EXPECT_SPAN(span, bl,bc, el,ec)     \
+    ({                                      \
+        EXPECT_EQ((span).begin.line, bl);   \
+        EXPECT_EQ((span).begin.column, bc); \
+        EXPECT_EQ((span).end.line, el);     \
+        EXPECT_EQ((span).end.column, ec);   \
+    })
+TEST(configparse, keyvalue)
+{
+    //              0        1         2         3
+    //              123456789012345678901234567890
+    RString data = "  key     :      value        "_s;
+
+    io::Spanned<ZString> input, value;
+    io::Spanned<XString> key;
+    input.data = data;
+    input.span.begin.text = data;
+    input.span.begin.filename = "<config parse key/value test>"_s;
+    input.span.begin.line = 1;
+    input.span.begin.column = 1;
+    input.span.end = input.span.begin;
+    input.span.end.column = data.size();
+    EXPECT_EQ(data.size(), 30);
+    ASSERT_TRUE(config_split(input, &key, &value));
+    EXPECT_SPAN(key.span, 1,3, 1,5);
+    EXPECT_SPAN(value.span, 1,18, 1,30);
+}
+} // namespace tmwa
diff --git a/src/mmo/version.cpp b/src/mmo/version.cpp
index 93ea794..f91b748 100644
--- a/src/mmo/version.cpp
+++ b/src/mmo/version.cpp
@@ -69,8 +69,28 @@ LString CURRENT_VERSION_STRING = VERSION_STRING;
 bool impl_extract(XString str, Version *vers)
 {
     *vers = {};
-    // TODO should I try to extract dev and vend also?
-    // It would've been useful during the magic migration.
+    // versions look like:
+    //   1.2.3 (release)
+    //   1.2.3+5 (vendor patches)
+    //   1.2.3-4 (dev patches)
+    //   1.2.3-4+5 (dev patches + vendor patches)
+    XString a, b;
+    if (extract(str, record<'+'>(&a, &b)))
+    {
+        if (!extract(b, &vers->vend))
+        {
+            return false;
+        }
+        str = a;
+    }
+    if (extract(str, record<'-'>(&a, &b)))
+    {
+        if (!extract(b, &vers->devel))
+        {
+            return false;
+        }
+        str = a;
+    }
     return extract(str, record<'.'>(&vers->major, &vers->minor, &vers->patch));
 }
 
diff --git a/src/monitor/main.cpp b/src/monitor/main.cpp
index c27cf77..75bf827 100644
--- a/src/monitor/main.cpp
+++ b/src/monitor/main.cpp
@@ -34,7 +34,7 @@
 
 #include "../io/cxxstdio.hpp"
 #include "../io/fd.hpp"
-#include "../io/read.hpp"
+#include "../io/line.hpp"
 
 #include "../mmo/config_parse.hpp"
 
@@ -75,20 +75,19 @@ AString make_path(XString base, XString path)
 }
 
 static
-bool parse_option(XString name, ZString value)
+bool parse_option(io::Spanned<XString> name, io::Spanned<ZString> value)
 {
-    if (name == "login_server"_s)
-        login_server = value;
-    else if (name == "map_server"_s)
-        map_server = value;
-    else if (name == "char_server"_s)
-        char_server = value;
-    else if (name == "workdir"_s)
-        workdir = value;
+    if (name.data == "login_server"_s)
+        login_server = value.data;
+    else if (name.data == "map_server"_s)
+        map_server = value.data;
+    else if (name.data == "char_server"_s)
+        char_server = value.data;
+    else if (name.data == "workdir"_s)
+        workdir = value.data;
     else
     {
-        FPRINTF(stderr, "WARNING: ingnoring invalid option '%s' : '%s'\n"_fmt,
-                AString(name), value);
+        name.span.error("invalid option key"_s);
         return false;
     }
     return true;
@@ -98,30 +97,30 @@ static
 bool read_config(ZString filename)
 {
     bool rv = true;
-    io::ReadFile in(filename);
+    io::LineReader in(filename);
     if (!in.is_open())
     {
         FPRINTF(stderr, "Monitor config file not found: %s\n"_fmt, filename);
         exit(1);
     }
 
-    AString line;
-    while (in.getline(line))
+    io::Line line_;
+    while (in.read_line(line_))
     {
-        if (is_comment(line))
+        io::Spanned<ZString> line = respan(line_.to_span(), ZString(line_.text));
+        if (is_comment(line.data))
             continue;
-        XString name;
-        ZString value;
+        io::Spanned<XString> name;
+        io::Spanned<ZString> value;
         if (!config_split(line, &name, &value))
         {
-            PRINTF("Bad line: %s\n"_fmt, line);
+            line.span.error("Unable to split config key: value"_s);
             rv = false;
             continue;
         }
 
         if (!parse_option(name, value))
         {
-            PRINTF("Bad key/value: %s\n"_fmt, line);
             rv = false;
             continue;
         }
@@ -186,7 +185,10 @@ int main(int argc, char *argv[])
     ZString config = CONFIG;
     if (argc > 1)
         config = ZString(strings::really_construct_from_a_pointer, argv[1], nullptr);
-    read_config(config);
+    if (!read_config(config))
+    {
+        exit(1);
+    }
 
     if (chdir(workdir.c_str()) < 0)
     {
-- 
cgit v1.2.3-70-g09d2