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
+1
View File
@@ -23,6 +23,7 @@ Gemini (geminiprotocol.net): one request per TLS connection on port 1965, the re
| Q82 | `s` saves the page on screen as a **Saved Page**: `/gemini/saved/<host>/<path>.gmi`, the gemtext as received plus a first line with its URL and save date. Saving again replaces it, and says so. |
| Q83 | `S` saves the page and the pages it links to, one level deep: gemtext only, same host only, at most 30 pages, in the background with a progress Toast. |
| Q84 | The start page lists Saved Pages, newest first, grouped by capsule; they open with no network. In a Saved Page, a link to another Saved Page opens the saved copy; other links fetch online if Wi-Fi is up, or say "not saved, offline". A Saved Page shows when it was saved; `r` refreshes it. |
| Q86 | *Decided after step 1.* **Two floors:** free heap stays above 40 KB in steady state, and above 20 KB for the second or two of a TLS handshake (measured: 24 KB with IRC connected). A fetch refuses to start below **55 KB** free ("not enough memory: stop IRC or retry"), so nothing pushes lower. |
| Q85 | Saved Pages are deleted from the App only (`d`, with confirmation), never by Storage Clean-up's age rules, like Notes. |
## Measured (step 1)
+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
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <string>
namespace roro::gemini {
enum class Category { Input, Success, Redirect, TemporaryFailure, PermanentFailure, ClientCertificate, Unknown };
// The response header: "<two digits> <meta>", at most 1024 bytes of meta.
struct Header {
int status = 0;
std::string meta;
Category category() const;
bool sensitiveInput() const { return status == 11; }
// 2x: the MIME type, lower-cased, without parameters ("text/gemini" when meta is empty).
std::string mimeType() const;
std::string parameter(const std::string& name) const; // e.g. "charset"
};
bool parseHeader(const std::string& line, Header& out);
} // namespace roro::gemini
+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
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <string>
namespace roro::gemini {
// A URL split per RFC 3986 (appendix B). For Gemini, the host is lower-cased and the port
// defaults to 1965.
struct Url {
std::string scheme, authority, host, path, query, fragment;
int port = -1;
bool hasAuthority = false, hasQuery = false, hasFragment = false;
int portOrDefault() const { return port > 0 ? port : 1965; }
};
// False unless it has a scheme and a host: only absolute URLs are fetched.
bool parseUrl(const std::string& text, Url& out);
bool isGemini(const std::string& url);
// `ref` against `base`, per RFC 3986 section 5.2 (dot segments included).
std::string resolve(const std::string& base, const std::string& ref);
// What goes on the wire: no fragment, lower-case host, never an empty path.
std::string requestUrl(const std::string& url);
// For input prompts: everything but unreserved characters percent-encoded (UTF-8 bytes).
std::string encodeQuery(const std::string& text);
// Where a Saved Page lives (Q82): /gemini/saved/<host>[_<port>]/<path>[~<query>].gmi
std::string savedPath(const std::string& url);
} // namespace roro::gemini
+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
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <string>
#include <vector>
namespace roro::gemini {
enum class LineType { Text, Link, Heading1, Heading2, Heading3, ListItem, Quote, PreToggle, Preformatted };
// One gemtext line. Link: `url` and its label in `text` (the URL itself without a label).
// PreToggle: the ``` line, with its alt text. Preformatted: kept exactly.
struct GemLine {
LineType type = LineType::Text;
std::string text, url;
};
std::vector<GemLine> parseGemtext(const std::string& document);
// For the Latin-1 fonts (Q79): valid UTF-8 up to U+00FF kept, anything else '?', and tabs
// expanded to 4-column stops.
std::string displayText(const std::string& utf8);
} // namespace roro::gemini
+192
View File
@@ -0,0 +1,192 @@
#include <unity.h>
#include <string>
#include "gemini_response.h"
#include "gemini_url.h"
#include "gemtext.h"
using namespace roro::gemini;
void setUp() {}
void tearDown() {}
static void resolvesTo(const char* ref, const char* expected) {
TEST_ASSERT_EQUAL_STRING_MESSAGE(expected, resolve("gemini://a/b/c/d;p?q", ref).c_str(), ref);
}
// RFC 3986, section 5.4.1, with gemini:// for http://.
void test_rfc3986_normal_examples() {
resolvesTo("g:h", "g:h");
resolvesTo("g", "gemini://a/b/c/g");
resolvesTo("./g", "gemini://a/b/c/g");
resolvesTo("g/", "gemini://a/b/c/g/");
resolvesTo("/g", "gemini://a/g");
resolvesTo("//g", "gemini://g");
resolvesTo("?y", "gemini://a/b/c/d;p?y");
resolvesTo("g?y", "gemini://a/b/c/g?y");
resolvesTo("#s", "gemini://a/b/c/d;p?q#s");
resolvesTo("g#s", "gemini://a/b/c/g#s");
resolvesTo("g?y#s", "gemini://a/b/c/g?y#s");
resolvesTo(";x", "gemini://a/b/c/;x");
resolvesTo("", "gemini://a/b/c/d;p?q");
resolvesTo(".", "gemini://a/b/c/");
resolvesTo("./", "gemini://a/b/c/");
resolvesTo("..", "gemini://a/b/");
resolvesTo("../", "gemini://a/b/");
resolvesTo("../g", "gemini://a/b/g");
resolvesTo("../..", "gemini://a/");
resolvesTo("../../", "gemini://a/");
resolvesTo("../../g", "gemini://a/g");
}
// Section 5.4.2.
void test_rfc3986_abnormal_examples() {
resolvesTo("../../../g", "gemini://a/g");
resolvesTo("../../../../g", "gemini://a/g");
resolvesTo("/./g", "gemini://a/g");
resolvesTo("/../g", "gemini://a/g");
resolvesTo("g.", "gemini://a/b/c/g.");
resolvesTo(".g", "gemini://a/b/c/.g");
resolvesTo("./../g", "gemini://a/b/g");
resolvesTo("g/./h", "gemini://a/b/c/g/h");
resolvesTo("g/../h", "gemini://a/b/c/h");
resolvesTo("g;x=1/./y", "gemini://a/b/c/g;x=1/y");
resolvesTo("g;x=1/../y", "gemini://a/b/c/y");
}
void test_parse_a_gemini_url() {
Url u;
TEST_ASSERT_TRUE(parseUrl("gemini://Example.ORG:1966/a/b?c=d#e", u));
TEST_ASSERT_EQUAL_STRING("gemini", u.scheme.c_str());
TEST_ASSERT_EQUAL_STRING("example.org", u.host.c_str()); // hosts are case-insensitive
TEST_ASSERT_EQUAL(1966, u.port);
TEST_ASSERT_EQUAL_STRING("/a/b", u.path.c_str());
TEST_ASSERT_EQUAL_STRING("c=d", u.query.c_str());
TEST_ASSERT_EQUAL_STRING("e", u.fragment.c_str());
TEST_ASSERT_TRUE(parseUrl("gemini://example.org", u));
TEST_ASSERT_EQUAL(1965, u.portOrDefault());
TEST_ASSERT_FALSE(parseUrl("not a url", u));
}
void test_the_request_drops_the_fragment_and_never_has_an_empty_path() {
TEST_ASSERT_EQUAL_STRING("gemini://example.org/", requestUrl("gemini://example.org").c_str());
TEST_ASSERT_EQUAL_STRING("gemini://example.org/a?b", requestUrl("gemini://example.org/a?b#frag").c_str());
TEST_ASSERT_EQUAL_STRING("gemini://example.org:1966/", requestUrl("gemini://EXAMPLE.org:1966").c_str());
}
void test_other_schemes_are_recognised() {
TEST_ASSERT_TRUE(isGemini("gemini://a/"));
TEST_ASSERT_FALSE(isGemini("https://a/"));
TEST_ASSERT_FALSE(isGemini("gopher://a/"));
TEST_ASSERT_FALSE(isGemini("mailto:someone@example.org"));
}
void test_query_encoding() {
TEST_ASSERT_EQUAL_STRING("hello%20world", encodeQuery("hello world").c_str());
TEST_ASSERT_EQUAL_STRING("caf%C3%A9%3F%26", encodeQuery("caf\xC3\xA9?&").c_str());
TEST_ASSERT_EQUAL_STRING("a-b_c.d~e", encodeQuery("a-b_c.d~e").c_str());
}
void test_response_header() {
Header h;
TEST_ASSERT_TRUE(parseHeader("20 text/gemini; charset=utf-8; lang=en", h));
TEST_ASSERT_EQUAL(20, h.status);
TEST_ASSERT_EQUAL_STRING("text/gemini", h.mimeType().c_str());
TEST_ASSERT_EQUAL_STRING("utf-8", h.parameter("charset").c_str());
TEST_ASSERT_TRUE(h.category() == Category::Success);
TEST_ASSERT_TRUE(parseHeader("20", h)); // no MIME type: text/gemini by default
TEST_ASSERT_EQUAL_STRING("text/gemini", h.mimeType().c_str());
TEST_ASSERT_TRUE(parseHeader("31 gemini://cosmos.skyjake.fi/", h));
TEST_ASSERT_TRUE(h.category() == Category::Redirect);
TEST_ASSERT_EQUAL_STRING("gemini://cosmos.skyjake.fi/", h.meta.c_str());
TEST_ASSERT_TRUE(parseHeader("11 Password", h));
TEST_ASSERT_TRUE(h.category() == Category::Input);
TEST_ASSERT_TRUE(h.sensitiveInput());
TEST_ASSERT_TRUE(parseHeader("51 Not found", h));
TEST_ASSERT_TRUE(h.category() == Category::PermanentFailure);
TEST_ASSERT_TRUE(parseHeader("60 Certificate required", h));
TEST_ASSERT_TRUE(h.category() == Category::ClientCertificate);
}
void test_malformed_headers_are_refused() {
Header h;
TEST_ASSERT_FALSE(parseHeader("", h));
TEST_ASSERT_FALSE(parseHeader("2 text/gemini", h));
TEST_ASSERT_FALSE(parseHeader("ab text/gemini", h));
TEST_ASSERT_FALSE(parseHeader("20text/gemini", h));
TEST_ASSERT_FALSE(parseHeader("20 " + std::string(1025, 'x'), h));
}
void test_gemtext_line_types() {
auto lines = parseGemtext(
"# Title\n## Sub\n### Subsub\nPlain text\n* item\n> quoted\n=> gemini://a/b Label here\n=>/rel\n"
"```ascii art\n# not a heading\n=> not a link\n```\nafter\n");
TEST_ASSERT_EQUAL(13, static_cast<int>(lines.size()));
TEST_ASSERT_TRUE(lines[0].type == LineType::Heading1);
TEST_ASSERT_EQUAL_STRING("Title", lines[0].text.c_str());
TEST_ASSERT_TRUE(lines[1].type == LineType::Heading2);
TEST_ASSERT_TRUE(lines[2].type == LineType::Heading3);
TEST_ASSERT_TRUE(lines[3].type == LineType::Text);
TEST_ASSERT_TRUE(lines[4].type == LineType::ListItem);
TEST_ASSERT_EQUAL_STRING("item", lines[4].text.c_str());
TEST_ASSERT_TRUE(lines[5].type == LineType::Quote);
TEST_ASSERT_EQUAL_STRING("quoted", lines[5].text.c_str());
TEST_ASSERT_TRUE(lines[6].type == LineType::Link);
TEST_ASSERT_EQUAL_STRING("gemini://a/b", lines[6].url.c_str());
TEST_ASSERT_EQUAL_STRING("Label here", lines[6].text.c_str());
TEST_ASSERT_TRUE(lines[7].type == LineType::Link);
TEST_ASSERT_EQUAL_STRING("/rel", lines[7].url.c_str());
TEST_ASSERT_EQUAL_STRING("/rel", lines[7].text.c_str()); // no label: the URL is the label
TEST_ASSERT_TRUE(lines[8].type == LineType::PreToggle);
TEST_ASSERT_EQUAL_STRING("ascii art", lines[8].text.c_str());
TEST_ASSERT_TRUE(lines[9].type == LineType::Preformatted);
TEST_ASSERT_EQUAL_STRING("# not a heading", lines[9].text.c_str());
TEST_ASSERT_TRUE(lines[10].type == LineType::Preformatted);
TEST_ASSERT_TRUE(lines[11].type == LineType::PreToggle);
TEST_ASSERT_TRUE(lines[12].type == LineType::Text);
}
void test_gemtext_handles_crlf_and_a_missing_last_newline() {
auto lines = parseGemtext("one\r\n=>\tgemini://x/\t spaced label \r\ntwo");
TEST_ASSERT_EQUAL(3, static_cast<int>(lines.size()));
TEST_ASSERT_EQUAL_STRING("one", lines[0].text.c_str());
TEST_ASSERT_EQUAL_STRING("gemini://x/", lines[1].url.c_str());
TEST_ASSERT_EQUAL_STRING("spaced label", lines[1].text.c_str());
TEST_ASSERT_EQUAL_STRING("two", lines[2].text.c_str());
}
void test_display_text_keeps_latin1_and_replaces_the_rest() {
TEST_ASSERT_EQUAL_STRING("caf\xC3\xA9", displayText("caf\xC3\xA9").c_str()); // é stays
TEST_ASSERT_EQUAL_STRING("? and ?", displayText("\xE2\x9C\x93 and \xF0\x9F\x99\x82").c_str()); // ✓ and 🙂
TEST_ASSERT_EQUAL_STRING("bad ? byte", displayText("bad \xFF byte").c_str());
TEST_ASSERT_EQUAL_STRING("a b", displayText("a\tb").c_str()); // tab: three spaces here
}
void test_saved_page_paths() {
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/index.gmi", savedPath("gemini://example.org/").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/index.gmi", savedPath("gemini://example.org").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/log/2026/post.gmi",
savedPath("gemini://example.org/log/2026/post.gmi").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/log/index.gmi", savedPath("gemini://example.org/log/").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/about.gmi", savedPath("gemini://example.org/about").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org_1966/a~q_x.gmi",
savedPath("gemini://example.org:1966/a?q:x#frag").c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_rfc3986_normal_examples);
RUN_TEST(test_rfc3986_abnormal_examples);
RUN_TEST(test_parse_a_gemini_url);
RUN_TEST(test_the_request_drops_the_fragment_and_never_has_an_empty_path);
RUN_TEST(test_other_schemes_are_recognised);
RUN_TEST(test_query_encoding);
RUN_TEST(test_response_header);
RUN_TEST(test_malformed_headers_are_refused);
RUN_TEST(test_gemtext_line_types);
RUN_TEST(test_gemtext_handles_crlf_and_a_missing_last_newline);
RUN_TEST(test_display_text_keeps_latin1_and_replaces_the_rest);
RUN_TEST(test_saved_page_paths);
return UNITY_END();
}