Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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("<https://example.com/>", "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);}