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
+97
View File
@@ -0,0 +1,97 @@
#include "gemtext.h"
#include <cstdint>
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);
}
} // namespace
std::vector<GemLine> parseGemtext(const std::string& document) {
std::vector<GemLine> lines;
bool pre = false;
size_t start = 0;
while (start < document.size()) {
size_t end = document.find('\n', start);
if (end == std::string::npos) end = document.size();
std::string line = document.substr(start, end - start);
if (!line.empty() && line.back() == '\r') line.pop_back();
start = end + 1;
GemLine g;
if (line.compare(0, 3, "```") == 0) {
g.type = LineType::PreToggle;
g.text = trim(line.substr(3));
pre = !pre;
} else if (pre) {
g.type = LineType::Preformatted;
g.text = line;
} else if (line.compare(0, 2, "=>") == 0) {
g.type = LineType::Link;
std::string rest = trim(line.substr(2));
size_t gap = rest.find_first_of(" \t");
g.url = rest.substr(0, gap);
g.text = gap == std::string::npos ? "" : trim(rest.substr(gap));
if (g.text.empty()) g.text = g.url;
} else if (line.compare(0, 3, "###") == 0) {
g.type = LineType::Heading3;
g.text = trim(line.substr(3));
} else if (line.compare(0, 2, "##") == 0) {
g.type = LineType::Heading2;
g.text = trim(line.substr(2));
} else if (line.compare(0, 1, "#") == 0) {
g.type = LineType::Heading1;
g.text = trim(line.substr(1));
} else if (line.compare(0, 2, "* ") == 0) {
g.type = LineType::ListItem;
g.text = trim(line.substr(2));
} else if (line.compare(0, 1, ">") == 0) {
g.type = LineType::Quote;
g.text = trim(line.substr(1));
} else {
g.text = line;
}
lines.push_back(std::move(g));
}
return lines;
}
std::string displayText(const std::string& s) {
std::string out;
size_t column = 0;
for (size_t i = 0; i < s.size();) {
unsigned char c = static_cast<unsigned char>(s[i]);
if (c == '\t') {
do out += ' ';
while (++column % 4);
i++;
continue;
}
if (c < 0x80) {
out += static_cast<char>(c);
i++;
} else {
int len = (c & 0xE0) == 0xC0 ? 2 : (c & 0xF0) == 0xE0 ? 3 : (c & 0xF8) == 0xF0 ? 4 : 0;
bool valid = len > 0 && i + len <= s.size();
for (int k = 1; valid && k < len; k++) valid = (static_cast<unsigned char>(s[i + k]) & 0xC0) == 0x80;
if (!valid) {
out += '?';
i++;
} else {
uint32_t cp = len == 2 ? (c & 0x1F) : len == 3 ? (c & 0x0F) : (c & 0x07);
for (int k = 1; k < len; k++) cp = (cp << 6) | (static_cast<unsigned char>(s[i + k]) & 0x3F);
if (cp <= 0xFF) out.append(s, i, len); // Latin-1: the fonts have it
else out += '?';
i += len;
}
}
column++;
}
return out;
}
} // namespace roro::gemini