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
+215
View File
@@ -0,0 +1,215 @@
#include "gemini_url.h"
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <vector>
#include "storage_paths.h"
namespace roro::gemini {
namespace {
// RFC 3986, appendix B: ^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?
struct Parts {
std::string scheme, authority, path, query, fragment;
bool hasScheme = false, hasAuthority = false, hasQuery = false, hasFragment = false;
};
Parts split(const std::string& s) {
Parts p;
size_t i = 0;
size_t colon = s.find(':');
size_t stop = s.find_first_of("/?#");
if (colon != std::string::npos && colon > 0 && (stop == std::string::npos || colon < stop)) {
p.scheme = s.substr(0, colon);
p.hasScheme = true;
i = colon + 1;
}
if (s.compare(i, 2, "//") == 0) {
size_t end = s.find_first_of("/?#", i + 2);
if (end == std::string::npos) end = s.size();
p.authority = s.substr(i + 2, end - i - 2);
p.hasAuthority = true;
i = end;
}
size_t end = s.find_first_of("?#", i);
if (end == std::string::npos) end = s.size();
p.path = s.substr(i, end - i);
i = end;
if (i < s.size() && s[i] == '?') {
end = s.find('#', i);
if (end == std::string::npos) end = s.size();
p.query = s.substr(i + 1, end - i - 1);
p.hasQuery = true;
i = end;
}
if (i < s.size() && s[i] == '#') {
p.fragment = s.substr(i + 1);
p.hasFragment = true;
}
return p;
}
std::string lower(std::string s) {
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
// RFC 3986, section 5.2.4.
std::string removeDotSegments(std::string in) {
std::string out;
while (!in.empty()) {
if (in.compare(0, 3, "../") == 0) in.erase(0, 3);
else if (in.compare(0, 2, "./") == 0) in.erase(0, 2);
else if (in.compare(0, 3, "/./") == 0) in.replace(0, 3, "/");
else if (in == "/.") in = "/";
else if (in.compare(0, 4, "/../") == 0 || in == "/..") {
in = in == "/.." ? "/" : in.substr(3);
size_t slash = out.rfind('/');
out.erase(slash == std::string::npos ? 0 : slash);
} else if (in == "." || in == "..") in.clear();
else {
size_t next = in.find('/', in[0] == '/' ? 1 : 0);
if (next == std::string::npos) next = in.size();
out += in.substr(0, next);
in.erase(0, next);
}
}
return out;
}
// RFC 3986, section 5.2.3.
std::string merge(const Parts& base, const std::string& refPath) {
if (base.hasAuthority && base.path.empty()) return "/" + refPath;
size_t slash = base.path.rfind('/');
return slash == std::string::npos ? refPath : base.path.substr(0, slash + 1) + refPath;
}
// RFC 3986, section 5.3.
std::string recompose(const Parts& p) {
std::string out;
if (p.hasScheme) out += p.scheme + ":";
if (p.hasAuthority) out += "//" + p.authority;
out += p.path;
if (p.hasQuery) out += "?" + p.query;
if (p.hasFragment) out += "#" + p.fragment;
return out;
}
} // namespace
bool parseUrl(const std::string& text, Url& out) {
Parts p = split(text);
if (!p.hasScheme || !p.hasAuthority) return false;
out = Url();
out.scheme = lower(p.scheme);
out.authority = p.authority;
std::string hostPort = p.authority.substr(p.authority.find('@') == std::string::npos ? 0 : p.authority.find('@') + 1);
size_t colon = hostPort.rfind(':');
if (colon != std::string::npos && hostPort.find(']', colon) == std::string::npos) {
out.port = std::atoi(hostPort.c_str() + colon + 1);
hostPort = hostPort.substr(0, colon);
}
out.host = lower(hostPort);
if (out.host.empty()) return false;
out.path = p.path;
out.query = p.query;
out.fragment = p.fragment;
out.hasAuthority = true;
out.hasQuery = p.hasQuery;
out.hasFragment = p.hasFragment;
return true;
}
bool isGemini(const std::string& url) {
Parts p = split(url);
return p.hasScheme && lower(p.scheme) == "gemini";
}
std::string resolve(const std::string& baseText, const std::string& refText) {
Parts base = split(baseText), r = split(refText), t;
if (r.hasScheme) {
t = r;
t.path = removeDotSegments(r.path);
} else {
if (r.hasAuthority) {
t.authority = r.authority;
t.hasAuthority = true;
t.path = removeDotSegments(r.path);
t.query = r.query;
t.hasQuery = r.hasQuery;
} else {
if (r.path.empty()) {
t.path = base.path;
t.query = r.hasQuery ? r.query : base.query;
t.hasQuery = r.hasQuery || base.hasQuery;
} else {
t.path = removeDotSegments(r.path[0] == '/' ? r.path : merge(base, r.path));
t.query = r.query;
t.hasQuery = r.hasQuery;
}
t.authority = base.authority;
t.hasAuthority = base.hasAuthority;
}
t.scheme = base.scheme;
t.hasScheme = base.hasScheme;
}
t.fragment = r.fragment;
t.hasFragment = r.hasFragment;
return recompose(t);
}
std::string requestUrl(const std::string& url) {
Url u;
if (!parseUrl(url, u)) return url;
std::string out = u.scheme + "://" + u.host;
if (u.port > 0 && u.port != 1965) out += ":" + std::to_string(u.port);
out += u.path.empty() ? "/" : u.path;
if (u.hasQuery) out += "?" + u.query;
return out;
}
std::string encodeQuery(const std::string& text) {
std::string out;
for (unsigned char c : text) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') out += static_cast<char>(c);
else {
char buf[4];
std::snprintf(buf, sizeof buf, "%%%02X", c);
out += buf;
}
}
return out;
}
std::string savedPath(const std::string& url) {
Url u;
if (!parseUrl(url, u)) return "/gemini/saved/unknown.gmi";
std::string out = "/gemini/saved/" + storage::sanitize(u.host + (u.port > 0 && u.port != 1965 ? "_" + std::to_string(u.port) : ""));
std::string path = u.path.empty() ? "/" : u.path;
std::vector<std::string> parts;
size_t start = 1;
while (start <= path.size()) {
size_t slash = path.find('/', start);
if (slash == std::string::npos) slash = path.size();
parts.push_back(path.substr(start, slash - start));
start = slash + 1;
}
if (parts.empty() || parts.back().empty()) {
if (!parts.empty()) parts.pop_back();
parts.push_back("index");
}
for (size_t i = 0; i < parts.size(); i++) {
std::string name = parts[i];
if (i + 1 == parts.size()) {
if (u.hasQuery) name += "~" + u.query;
if (name.size() < 4 || name.compare(name.size() - 4, 4, ".gmi") != 0) name += ".gmi";
}
out += "/" + storage::sanitize(name);
}
return out;
}
} // namespace roro::gemini