// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Finding a URL in a line of text: the one that takes in a given byte, for //! the cell under the pointer. Not a full parse — what a terminal wants is //! where a URL somebody printed begins and ends, which is a matter of where //! the scheme is and of what the text around it is likely to be. //! //! A URL begins with a scheme a desktop can open, followed by `://` (or, //! for `mailto:`, by an address), and runs to the first space, control //! character, or character that cannot be in one unescaped. Punctuation //! that ends a sentence is left off its end, and so is a closing bracket //! with no opening one in the URL, so that `(see https://example.com/)` //! and `https://example.com/a_(b)` both come out as meant. const std = @import("std"); pub const Span = struct { start: usize, end: usize, pub fn of(s: Span, text: []const u8) []const u8 { return text[s.start..s.end]; } }; /// Schemes followed by `://`. const hierarchical = [_][]const u8{ "http", "https", "ftp", "ftps", "sftp", "ssh", "file", "git", "gemini", "gopher", "irc", "ircs" }; /// Schemes followed by `:` and no slashes. const opaque_schemes = [_][]const u8{"mailto"}; /// The URL in `text` that takes in byte `at`, if there is one. pub fn find(text: []const u8, at: usize) ?Span { if (at >= text.len or !urlByte(text[at])) return null; // The run of characters a URL could be made of, around `at`. var lo = at; while (lo > 0 and urlByte(text[lo - 1])) lo -= 1; var hi = at + 1; while (hi < text.len and urlByte(text[hi])) hi += 1; // Each place in the run a scheme could end, the first that holds `at`. var i = lo; while (i < hi) : (i += 1) { if (text[i] != ':') continue; const start = schemeStart(text[lo..i]) orelse continue; const s = lo + start; if (s > at) break; const body_start = bodyStart(text, s, i, hi) orelse continue; // Up to where another URL begins, if one does. const next = nextUrl(text, body_start, hi); const e = trimEnd(text, s, body_start, next); if (e <= body_start) continue; if (at < e) return .{ .start = s, .end = e }; // A later URL in the same run may be the one. i = e - 1; } return null; } /// Where what follows the scheme `text[s..colon]` begins, if it is a /// scheme a desktop opens, followed by what it should be. fn bodyStart(text: []const u8, s: usize, colon: usize, hi: usize) ?usize { const scheme = text[s..colon]; const body = if (contains(&hierarchical, scheme)) body: { if (!std.mem.startsWith(u8, text[colon..hi], "://")) return null; break :body colon + 3; } else if (contains(&opaque_schemes, scheme)) colon + 1 else return null; return if (body < hi) body else null; } /// Where the next URL in `text[from..hi]` begins, or `hi`. fn nextUrl(text: []const u8, from: usize, hi: usize) usize { var i = from; while (i < hi) : (i += 1) { if (text[i] != ':') continue; const start = schemeStart(text[from..i]) orelse continue; if (bodyStart(text, from + start, i, hi) != null) return from + start; } return hi; } /// Where the scheme ending at the end of `before` starts: the longest run /// of scheme characters there that begins with a letter. fn schemeStart(before: []const u8) ?usize { var j = before.len; while (j > 0 and schemeByte(before[j - 1])) j -= 1; // Leading digits and the like are not part of it. while (j < before.len and !std.ascii.isAlphabetic(before[j])) j += 1; if (j == before.len) return null; return j; } /// The end of the URL from `start`, the end of its run being `hi`, with /// what is more likely the text around it than part of it taken off. fn trimEnd(text: []const u8, start: usize, body_start: usize, hi: usize) usize { var e = hi; while (e > body_start) { const c = text[e - 1]; switch (c) { '.', ',', ':', ';', '!', '?', '\'', '*' => e -= 1, ')', ']', '}' => { const open: u8 = switch (c) { ')' => '(', ']' => '[', else => '{', }; const opens = std.mem.count(u8, text[start .. e - 1], &.{open}); const closes = std.mem.count(u8, text[start .. e - 1], &.{c}); // Balanced without it: it closes something outside. if (opens <= closes) e -= 1 else break; }, else => break, } } return e; } fn urlByte(c: u8) bool { return switch (c) { 0...0x20, 0x7f, '<', '>', '"', '`' => false, else => true, }; } fn schemeByte(c: u8) bool { return std.ascii.isAlphanumeric(c) or c == '+' or c == '-' or c == '.'; } fn contains(list: []const []const u8, s: []const u8) bool { for (list) |x| if (std.ascii.eqlIgnoreCase(x, s)) return true; return false; } /// Whether `host`, from a `file:` URI, is this machine: no host, /// `localhost`, or `hostname`, this machine's name. pub fn isLocalHost(host: []const u8, hostname: []const u8) bool { return host.len == 0 or std.ascii.eqlIgnoreCase(host, "localhost") or std.ascii.eqlIgnoreCase(host, hostname); } /// The path a `file:` URI names, still percent-encoded, if it names one on /// this machine: no host, `localhost`, or `hostname`. pub fn localPath(uri: []const u8, hostname: []const u8) ?[]const u8 { const prefix = "file://"; if (uri.len < prefix.len or !std.ascii.eqlIgnoreCase(uri[0..prefix.len], prefix)) return null; const rest = uri[prefix.len..]; const slash = std.mem.findScalar(u8, rest, '/') orelse return null; if (!isLocalHost(rest[0..slash], hostname)) return null; // A query or fragment is no part of the file's name. const path = rest[slash..]; const end = std.mem.findAny(u8, path, "?#") orelse path.len; return path[0..end]; } /// `s` with its percent escapes decoded, into `out`. pub fn percentDecode(gpa: std.mem.Allocator, out: *std.ArrayList(u8), s: []const u8) std.mem.Allocator.Error!void { var i: usize = 0; while (i < s.len) : (i += 1) { if (s[i] == '%' and i + 2 < s.len) { if (std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16)) |b| { try out.append(gpa, b); i += 2; continue; } else |_| {} } try out.append(gpa, s[i]); } } const testing = std.testing; fn expectFind(text: []const u8, at_text: []const u8, want: ?[]const u8) !void { const at = std.mem.find(u8, text, at_text).?; const got = find(text, at); if (want) |w| { try testing.expect(got != null); try testing.expectEqualStrings(w, got.?.of(text)); } else { if (got) |g| std.debug.print("unexpected: {s}\n", .{g.of(text)}); try testing.expect(got == null); } } test "a URL is found from anywhere in it" { const text = "see https://example.com/a/b?c=d#e for more"; try expectFind(text, "https", "https://example.com/a/b?c=d#e"); try expectFind(text, "example", "https://example.com/a/b?c=d#e"); try expectFind(text, "#e", "https://example.com/a/b?c=d#e"); try expectFind(text, "see", null); try expectFind(text, "more", null); } test "punctuation around a URL is not part of it" { try expectFind("go to https://example.com/.", "example", "https://example.com/"); try expectFind("(see https://example.com/x)", "example", "https://example.com/x"); try expectFind("https://en.wikipedia.org/wiki/Foo_(bar)", "wiki", "https://en.wikipedia.org/wiki/Foo_(bar)"); try expectFind("'https://example.com/'", "example", "https://example.com/"); try expectFind("", "example", "https://example.com/"); try expectFind("\"https://example.com/\",", "example", "https://example.com/"); } test "schemes" { try expectFind("mail mailto:someone@example.com now", "someone", "mailto:someone@example.com"); try expectFind("file:///home/me/a.txt", "home", "file:///home/me/a.txt"); try expectFind("x=gemini://example.org/", "gemini", "gemini://example.org/"); try expectFind("HTTPS://EXAMPLE.COM", "EXAMPLE", "HTTPS://EXAMPLE.COM"); // Not a scheme a desktop opens, or not followed by anything. try expectFind("foo://bar", "bar", null); try expectFind("https://", "https", null); try expectFind("12:34 http", "34", null); } test "two URLs in one run" { const text = "https://a.example/,https://b.example/"; try expectFind(text, "a.example", "https://a.example/"); try expectFind(text, "b.example", "https://b.example/"); } test "file URIs on this machine" { try testing.expectEqualStrings("/tmp/a%20b", localPath("file:///tmp/a%20b", "box").?); try testing.expectEqualStrings("/tmp/x", localPath("file://localhost/tmp/x", "box").?); try testing.expectEqualStrings("/tmp/x", localPath("file://box/tmp/x?y#z", "box").?); try testing.expect(localPath("file://elsewhere/tmp/x", "box") == null); try testing.expect(localPath("https://box/tmp/x", "box") == null); var out: std.ArrayList(u8) = .empty; defer out.deinit(testing.allocator); try percentDecode(testing.allocator, &out, "/tmp/a%20b%zz"); try testing.expectEqualStrings("/tmp/a b%zz", out.items); }