G1 step 2: Gemini parsers (host-tested), and the memory decision (Q86)

lib/gemini: URLs split per RFC 3986 appendix B and resolved per section
5.2 (all 32 reference examples of 5.4 pass, with gemini:// for http://),
the request form (no fragment, lower-case host, never an empty path),
query encoding for input prompts, Saved Page paths; the response header
(status, category, MIME type and parameters, 1024-byte meta limit);
gemtext's line types; and display text for the Latin-1 fonts. 12 tests.

Q86, decided after step 1: free heap stays above 40 KB in steady state
and 20 KB during a handshake; a fetch won't start below 55 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-05 00:29:55 +02:00
co-authored by Claude Opus 5.5
parent 2d384f5cff
commit babb1d114e
8 changed files with 638 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
#include "gemini_response.h"
#include <cctype>
namespace roro::gemini {
namespace {
std::string trim(const std::string& s) {
size_t a = s.find_first_not_of(" \t"), b = s.find_last_not_of(" \t");
return a == std::string::npos ? "" : s.substr(a, b - a + 1);
}
std::string lower(std::string s) {
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
} // namespace
bool parseHeader(const std::string& line, Header& out) {
if (line.size() < 2 || !std::isdigit(static_cast<unsigned char>(line[0])) ||
!std::isdigit(static_cast<unsigned char>(line[1])))
return false;
if (line.size() > 2 && line[2] != ' ') return false;
std::string meta = line.size() > 3 ? line.substr(3) : "";
if (meta.size() > 1024) return false;
out.status = (line[0] - '0') * 10 + (line[1] - '0');
out.meta = meta;
return true;
}
Category Header::category() const {
switch (status / 10) {
case 1: return Category::Input;
case 2: return Category::Success;
case 3: return Category::Redirect;
case 4: return Category::TemporaryFailure;
case 5: return Category::PermanentFailure;
case 6: return Category::ClientCertificate;
default: return Category::Unknown;
}
}
std::string Header::mimeType() const {
std::string type = lower(trim(meta.substr(0, meta.find(';'))));
return type.empty() ? "text/gemini" : type;
}
std::string Header::parameter(const std::string& name) const {
size_t pos = meta.find(';');
while (pos != std::string::npos) {
size_t next = meta.find(';', pos + 1);
std::string item = trim(meta.substr(pos + 1, next == std::string::npos ? std::string::npos : next - pos - 1));
size_t eq = item.find('=');
if (eq != std::string::npos && lower(trim(item.substr(0, eq))) == lower(name)) return trim(item.substr(eq + 1));
pos = next;
}
return "";
}
} // namespace roro::gemini