You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
417 lines
13 KiB
417 lines
13 KiB
#include "utils/ini.hpp" |
|
|
|
#include <algorithm> |
|
#include <charconv> |
|
#include <cstddef> |
|
#include <cstdint> |
|
#include <cstring> |
|
#include <span> |
|
#include <string> |
|
#include <string_view> |
|
#include <system_error> |
|
#include <utility> |
|
#include <variant> |
|
#include <vector> |
|
|
|
#include <ankerl/unordered_dense.h> |
|
#include <expected.hpp> |
|
#include <fmt/core.h> |
|
|
|
#include "utils/algorithm/container.hpp" |
|
#include "utils/str_cat.hpp" |
|
#include "utils/string_view_hash.hpp" |
|
#include "utils/utf8.hpp" |
|
|
|
namespace devilution { |
|
|
|
// We avoid including the "appfat.h" to avoid depending on SDL in tests. |
|
[[noreturn]] extern void app_fatal(std::string_view str); |
|
|
|
namespace { |
|
|
|
// Returns a pointer to the first non-leading whitespace. |
|
// Only ' ' and '\t' are considered whitespace. |
|
// Requires: begin <= end. |
|
const char *SkipLeadingWhitespace(const char *begin, const char *end) |
|
{ |
|
while (begin != end && (*begin == ' ' || *begin == '\t')) { |
|
++begin; |
|
} |
|
return begin; |
|
} |
|
|
|
// Returns a pointer to the last non-whitespace. |
|
// Only ' ' and '\t' are considered whitespace. |
|
// Requires: begin <= end. |
|
const char *SkipTrailingWhitespace(const char *begin, const char *end) |
|
{ |
|
while (begin != end && (*(end - 1) == ' ' || *(end - 1) == '\t')) { |
|
--end; |
|
} |
|
return end; |
|
} |
|
|
|
// Skips UTF-8 byte order mark. |
|
// See https://en.wikipedia.org/wiki/Byte_order_mark |
|
const char *SkipUtf8Bom(const char *begin, const char *end) |
|
{ |
|
if (end - begin >= 3 && begin[0] == '\xEF' && begin[1] == '\xBB' && begin[2] == '\xBF') { |
|
return begin + 3; |
|
} |
|
return begin; |
|
} |
|
|
|
} // namespace |
|
|
|
Ini::Values::Values() |
|
: rep_(std::vector<Value> {}) |
|
{ |
|
} |
|
|
|
Ini::Values::Values(const Value &data) |
|
: rep_(data) |
|
{ |
|
} |
|
|
|
std::span<const Ini::Value> Ini::Values::get() const |
|
{ |
|
if (std::holds_alternative<Ini::Value>(rep_)) { |
|
return { &std::get<Ini::Value>(rep_), 1 }; |
|
} |
|
return std::get<std::vector<Ini::Value>>(rep_); |
|
} |
|
|
|
std::span<Ini::Value> Ini::Values::get() |
|
{ |
|
if (std::holds_alternative<Ini::Value>(rep_)) { |
|
return { &std::get<Ini::Value>(rep_), 1 }; |
|
} |
|
return std::get<std::vector<Ini::Value>>(rep_); |
|
} |
|
|
|
void Ini::Values::append(const Ini::Value &value) |
|
{ |
|
if (std::holds_alternative<Value>(rep_)) { |
|
rep_ = std::vector<Value> { std::get<Value>(rep_), value }; |
|
return; |
|
} |
|
std::get<std::vector<Value>>(rep_).push_back(value); |
|
} |
|
|
|
tl::expected<Ini, std::string> Ini::parse(std::string_view buffer) |
|
{ |
|
Ini::FileData fileData; |
|
ankerl::unordered_dense::map<std::string, ValuesData, StringViewHash, StringViewEquals> *sectionEntries = nullptr; |
|
const char *eof = buffer.data() + buffer.size(); |
|
const char *lineBegin = SkipUtf8Bom(buffer.data(), eof); |
|
size_t lineNum = 0; |
|
|
|
const char *commentBegin = nullptr; |
|
const char *nextLineBegin; |
|
for (; lineBegin < eof; lineBegin = nextLineBegin) { |
|
++lineNum; |
|
const char *lineEnd = static_cast<const char *>(memchr(lineBegin, '\n', eof - lineBegin)); |
|
if (lineEnd == nullptr) { |
|
lineEnd = eof; |
|
nextLineBegin = eof; |
|
} else { |
|
nextLineBegin = lineEnd + 1; |
|
if (lineBegin + 1 < lineEnd && *(lineEnd - 1) == '\r') --lineEnd; |
|
} |
|
const char *keyBegin = SkipLeadingWhitespace(lineBegin, lineEnd); |
|
if (keyBegin == lineEnd) continue; |
|
|
|
if (*keyBegin == ';') { |
|
if (commentBegin == nullptr) commentBegin = lineBegin; |
|
continue; |
|
} |
|
std::string_view comment; |
|
if (commentBegin != nullptr) { |
|
comment = std::string_view(commentBegin, lineBegin - commentBegin); |
|
} |
|
|
|
if (*keyBegin == '[') { |
|
const char *keyEnd = ++keyBegin; |
|
while (keyEnd < lineEnd && *keyEnd != ']') { |
|
++keyEnd; |
|
} |
|
if (keyEnd == lineEnd) { |
|
return tl::make_unexpected(fmt::format("line {}: unclosed section name {}", lineNum, std::string_view(keyBegin, keyEnd - keyBegin))); |
|
} |
|
if (const char *after = SkipTrailingWhitespace(keyEnd + 1, lineEnd); after != lineEnd) { |
|
return tl::make_unexpected(fmt::format("line {}: content after section [{}]: {}", lineNum, std::string_view(keyBegin, keyEnd - keyBegin), std::string_view(after, lineEnd - after))); |
|
} |
|
const std::string_view sectionName = std::string_view(keyBegin, keyEnd - keyBegin); |
|
auto it = fileData.sections.find(sectionName); |
|
if (it == fileData.sections.end()) { |
|
it = fileData.sections.emplace_hint(it, sectionName, |
|
SectionData { |
|
.comment = std::string(comment), |
|
.entries = {}, |
|
.index = static_cast<uint32_t>(fileData.sections.size()), |
|
}); |
|
} |
|
sectionEntries = &it->second.entries; |
|
commentBegin = nullptr; |
|
continue; |
|
} |
|
|
|
if (sectionEntries == nullptr) return tl::unexpected(fmt::format("line {}: key not in any section", lineNum)); |
|
const char *eqPos = static_cast<const char *>(memchr(keyBegin, '=', lineEnd - keyBegin)); |
|
if (eqPos == nullptr) { |
|
return tl::make_unexpected(fmt::format("line {}: key {} has no value", lineNum, std::string_view(keyBegin, lineEnd - keyBegin))); |
|
} |
|
const char *keyEnd = SkipTrailingWhitespace(keyBegin, eqPos); |
|
const std::string_view key = std::string_view(keyBegin, keyEnd - keyBegin); |
|
const char *valueBegin = SkipLeadingWhitespace(eqPos + 1, lineEnd); |
|
const std::string_view value = std::string_view(valueBegin, lineEnd - valueBegin); |
|
if (const auto it = sectionEntries->find(key); it != sectionEntries->end()) { |
|
it->second.values.append(Value { std::string(comment), std::string(value) }); |
|
} else { |
|
sectionEntries->emplace_hint(it, key, |
|
ValuesData { |
|
.values = Values { Value { |
|
.comment = std::string(comment), |
|
.value = std::string(value), |
|
} }, |
|
.index = static_cast<uint32_t>(sectionEntries->size()), |
|
}); |
|
} |
|
commentBegin = nullptr; |
|
} |
|
return Ini(std::move(fileData)); |
|
} |
|
|
|
std::span<const Ini::Value> Ini::get(std::string_view section, std::string_view key) const |
|
{ |
|
const auto sectionIt = data_.sections.find(section); |
|
if (sectionIt == data_.sections.end()) return {}; |
|
const auto it = sectionIt->second.entries.find(key); |
|
if (it == sectionIt->second.entries.end()) return {}; |
|
return it->second.values.get(); |
|
} |
|
|
|
std::string_view Ini::getString(std::string_view section, std::string_view key, std::string_view defaultValue) const |
|
{ |
|
const std::span<const Ini::Value> xs = get(section, key); |
|
if (xs.empty() || xs.back().value.empty()) return defaultValue; |
|
return xs.back().value; |
|
} |
|
|
|
int Ini::getInt(std::string_view section, std::string_view key, int defaultValue) const |
|
{ |
|
const std::span<const Ini::Value> xs = get(section, key); |
|
if (xs.empty() || xs.back().value.empty()) return defaultValue; |
|
const std::string_view str = xs.back().value; |
|
int value; |
|
const std::from_chars_result result = std::from_chars(str.data(), str.data() + str.size(), value); |
|
if (result.ec != std::errc()) { |
|
app_fatal(fmt::format("ini: Failed to parse {}.{}={} as int", section, key, str)); |
|
return defaultValue; |
|
} |
|
return value; |
|
} |
|
|
|
bool Ini::getBool(std::string_view section, std::string_view key, bool defaultValue) const |
|
{ |
|
const std::span<const Ini::Value> xs = get(section, key); |
|
if (xs.empty() || xs.back().value.empty()) return defaultValue; |
|
const std::string_view str = xs.back().value; |
|
if (str == "0") return false; |
|
if (str == "1") return true; |
|
app_fatal(fmt::format("ini: Failed to parse {}.{}={} as bool", section, key, str)); |
|
} |
|
|
|
float Ini::getFloat(std::string_view section, std::string_view key, float defaultValue) const |
|
{ |
|
const std::span<const Ini::Value> xs = get(section, key); |
|
if (xs.empty() || xs.back().value.empty()) return defaultValue; |
|
const std::string_view str = xs.back().value; |
|
float value; |
|
const std::from_chars_result result = std::from_chars(str.data(), str.data() + str.size(), value); |
|
if (result.ec != std::errc()) { |
|
app_fatal(fmt::format("ini: Failed to parse {}.{}={} as float", section, key, str)); |
|
return defaultValue; |
|
} |
|
return value; |
|
} |
|
|
|
void Ini::getUtf8Buf(std::string_view section, std::string_view key, std::string_view defaultValue, char *dst, size_t dstSize) const |
|
{ |
|
CopyUtf8(dst, getString(section, key, defaultValue), dstSize); |
|
} |
|
|
|
void Ini::set(std::string_view section, std::string_view key, Ini::Values &&values) |
|
{ |
|
const std::span<Value> updated = values.get(); |
|
|
|
auto sectionIt = data_.sections.find(section); |
|
if (sectionIt == data_.sections.end()) { |
|
// Deleting a key from a non-existing section |
|
if (updated.empty()) return; |
|
|
|
// Adding a new section and key |
|
data_.sections.emplace_hint(sectionIt, section, |
|
SectionData { |
|
.comment = {}, |
|
.entries = { { std::string(key), ValuesData { .values = std::move(values), .index = 0 } } }, |
|
.index = static_cast<uint32_t>(data_.sections.size()), |
|
}); |
|
changed_ = true; |
|
return; |
|
} |
|
const auto it = sectionIt->second.entries.find(key); |
|
if (it == sectionIt->second.entries.end()) { |
|
// Deleting a non-existing key |
|
if (updated.empty()) return; |
|
|
|
// Adding a new key to an existing section |
|
sectionIt->second.entries.emplace(key, |
|
ValuesData { |
|
.values = std::move(values), |
|
.index = static_cast<uint32_t>(sectionIt->second.entries.size()), |
|
}); |
|
changed_ = true; |
|
return; |
|
} |
|
|
|
// Deleting an existing key |
|
if (updated.empty()) { |
|
sectionIt->second.entries.erase(it); |
|
if (sectionIt->second.entries.empty()) data_.sections.erase(sectionIt); |
|
changed_ = true; |
|
return; |
|
} |
|
|
|
// Overriding an existing key |
|
const std::span<Value> original = it->second.values.get(); |
|
if (original.size() == updated.size()) { |
|
bool equal = true; |
|
for (size_t i = 0; i < original.size(); ++i) { |
|
if (original[i].value != updated[i].value) { |
|
equal = false; |
|
break; |
|
} |
|
} |
|
if (equal) return; |
|
} |
|
|
|
// Preserve existing comments where not overriden. |
|
for (size_t i = 0, n = std::min(original.size(), updated.size()); i < n; ++i) { |
|
if (!updated[i].comment.has_value() && original[i].comment.has_value()) { |
|
updated[i].comment = std::move(original[i].comment); |
|
} |
|
} |
|
it->second.values = std::move(values); |
|
changed_ = true; |
|
} |
|
|
|
void Ini::set(std::string_view section, std::string_view key, std::span<const std::string> strings) |
|
{ |
|
if (strings.empty()) { |
|
set(section, key, Values {}); |
|
} else if (strings.size() == 1) { |
|
set(section, key, Values { Value { .comment = {}, .value = strings[0] } }); |
|
} else { |
|
Values values; |
|
auto &items = std::get<std::vector<Value>>(values.rep_); |
|
items.reserve(strings.size()); |
|
for (const std::string &str : strings) { |
|
items.push_back(Value { .comment = {}, .value = str }); |
|
} |
|
set(section, key, std::move(values)); |
|
} |
|
} |
|
|
|
void Ini::set(std::string_view section, std::string_view key, std::string &&value) |
|
{ |
|
set(section, key, Values { Value { .comment = {}, .value = std::move(value) } }); |
|
} |
|
|
|
void Ini::set(std::string_view section, std::string_view key, std::string_view value) |
|
{ |
|
|
|
set(section, key, std::string(value)); |
|
} |
|
|
|
void Ini::set(std::string_view section, std::string_view key, int value) |
|
{ |
|
set(section, key, StrCat(value)); |
|
} |
|
|
|
void Ini::set(std::string_view section, std::string_view key, bool value) |
|
{ |
|
set(section, key, std::string(value ? "1" : "0")); |
|
} |
|
|
|
void Ini::set(std::string_view section, std::string_view key, float value) |
|
{ |
|
constexpr size_t BufSize = 64; |
|
char buf[BufSize] {}; |
|
const std::to_chars_result result = std::to_chars(buf, buf + BufSize, value); |
|
if (result.ec != std::errc()) { |
|
app_fatal("float->string failed"); // should never happen |
|
} |
|
set(section, key, std::string_view(buf, result.ptr - buf)); |
|
} |
|
|
|
namespace { |
|
|
|
template <typename T> |
|
bool OrderByValueIndex(const std::pair<std::string, T> &a, const std::pair<std::string, T> &b) |
|
{ |
|
return a.second.index < b.second.index; |
|
}; |
|
|
|
// Appends a possibly multi-line comment, converting \r\n to \n. |
|
void AppendComment(std::string_view comment, std::string &out) |
|
{ |
|
bool prevR = false; |
|
for (const char c : comment) { |
|
if (prevR) { |
|
prevR = c != '\r'; |
|
out += c; |
|
} else if (c == '\r') { |
|
prevR = true; |
|
} else { |
|
out += c; |
|
} |
|
} |
|
} |
|
|
|
void AppendSection(std::string_view sectionName, std::string &out) |
|
{ |
|
out.append("[").append(sectionName).append("]\n"); |
|
} |
|
|
|
void AppendKeyValue(std::string_view key, std::string_view value, std::string &out) |
|
{ |
|
out.append(key).append("=").append(value).append("\n"); |
|
} |
|
|
|
} // namespace |
|
|
|
std::string Ini::serialize() const |
|
{ |
|
std::string result; |
|
std::vector<std::pair<std::string, SectionData>> sections(data_.sections.begin(), data_.sections.end()); |
|
c_sort(sections, OrderByValueIndex<SectionData>); |
|
|
|
std::vector<std::pair<std::string, ValuesData>> entries; |
|
for (auto &[sectionName, section] : sections) { |
|
if (!result.empty()) result += '\n'; |
|
if (!section.comment.empty()) AppendComment(section.comment, result); |
|
AppendSection(sectionName, result); |
|
entries.assign(section.entries.begin(), section.entries.end()); |
|
c_sort(entries, OrderByValueIndex<ValuesData>); |
|
for (const auto &[key, entry] : entries) { |
|
for (const auto &[comment, value] : entry.values.get()) { |
|
if (comment.has_value() && !comment->empty()) AppendComment(*comment, result); |
|
AppendKeyValue(key, value, result); |
|
} |
|
} |
|
} |
|
return result; |
|
} |
|
|
|
} // namespace devilution
|
|
|