// SPDX-FileCopyrightText: 2025 Matthew Fennell // // SPDX-License-Identifier: AGPL-3.0-or-later use dirs; use fmt; use getopt; use io; use net::uri; use os::exec; use os; use strings; type engine = struct { name: str, template: str, }; type mode = enum { BROWSER, COPY, STDOUT, }; export fn main() void = { const engines = get_engines(config_file_contents()); const (mode, name, query) = parsed(os::args, default_engine(), names(engines)); const template = match (get_chosen_template(name, engines)) { case let chosen_template: str => yield chosen_template; case => fmt::fatalf("No template found for {}", name); }; const link = get_link(query, template); defer free(link); switch (mode) { case mode::BROWSER => browser(link); case mode::COPY => copy(link); case mode::STDOUT => fmt::println(link)!; }; }; fn names(engines: []engine) []str = { return { let names: []str = []; for (const engine .. engines) { append(names, engine.name); }; yield names; }; }; fn parsed(args: []str, default_engine: str, names: []str) (mode, str, str) = { const cmd = getopt::parse(args, "search selected engine with the given query", ('b', "open browser"), ('c', "copy link"), ('l', "list engines"), "[engine]", "query"); defer getopt::finish(&cmd); let mode = mode::STDOUT; for (const opt .. cmd.opts) { switch (opt.0) { case 'b' => mode = mode::BROWSER; case 'c' => mode = mode::COPY; case 'l' => { for (let name .. names) { fmt::println(name)!; }; os::exit(os::status::SUCCESS); }; case => fmt::fatal("Unknown option given"); }; }; const specified_engine: (str | void) = { const proposed = cmd.args[0]; let engine: (str | void) = void; for (let name .. names) { if (proposed == name) { engine = proposed; break; }; }; yield engine; }; const (engine, query) = match (specified_engine) { case let specified: str => yield (specified, cmd.args[1..]); case => yield (default_engine, cmd.args); }; const query = strings::join(" ", query...); return (mode, engine, query); }; fn default_engine() str = { return "marginalia"; }; fn config_file_contents() str = { const config_file = dirs::config("search"); const config_file = strings::concat(config_file, "/engines.conf"); const engines: io::file = os::open(config_file)!; const engines: []u8 = io::drain(engines)!; return strings::fromutf8(engines)!; }; fn get_engines(ssv_contents: str) []engine = { const engines: []str = strings::split(ssv_contents, "\n"); return { let result: []engine = []; for (let entry: str .. engines) { if (entry == "") { continue; }; const slice = strings::split(entry, " "); assert(len(slice) == 2); append(result, engine { name = slice[0], template = slice[1] }); }; yield result; }; }; fn get_chosen_template(name: str, engines: []engine) (void | str) = { for (const engine .. engines) { if (engine.name == name) { return engine.template; }; }; }; // Caller must free result fn get_link(query: str, template: str) str = { const encoded = encoded(query); const link = strings::replace(template, "%s", encoded); return link; }; fn encoded(query: str) str = { const encoded = net::uri::encodequery([("search", query)]); const encoded = strings::cut(encoded, "=").1; return encoded; }; fn browser(link: str) void = { const cmd = os::exec::cmd("edbrowse", link); const cmd = match (cmd) { case let cmd: os::exec::command => yield cmd; case => fmt::fatal("Could not start browser"); }; os::exec::exec(&cmd); }; fn copy(link: str) void = { const cmd = os::exec::cmd("wl-copy", "--primary", "--trim-newline", link); const cmd = match (cmd) { case let cmd: os::exec::command => yield cmd; case => fmt::fatal("Could not copy"); }; os::exec::exec(&cmd); }; @test fn should_handle_single_entry() void = { const engines = get_engines("wikipedia https://en.wikipedia.org/wiki/%s"); assert(len(engines) == 1); const engine = engines[0]; assert(engine.name == "wikipedia"); assert(engine.template == "https://en.wikipedia.org/wiki/%s"); }; @test fn should_handle_multiple_entries() void = { const engines = get_engines("wikipedia https://en.wikipedia.org/wiki/%s\nmarginalia https://old-search.marginalia.nu/search?query=%s&js=no-js"); assert(len(engines) == 2); const wikipedia = engines[0]; assert(wikipedia.name == "wikipedia"); assert(wikipedia.template == "https://en.wikipedia.org/wiki/%s"); const marginalia = engines[1]; assert(marginalia.name == "marginalia"); assert(marginalia.template == "https://old-search.marginalia.nu/search?query=%s&js=no-js"); }; @test fn should_handle_final_newline() void = { const engines = get_engines("wikipedia https://en.wikipedia.org/wiki/%s\n"); assert(len(engines) == 1); }; @test fn should_return_none_if_no_engines() void = { const template = get_chosen_template("wikipedia", []); assert(template is void); }; @test fn should_return_none_if_engine_not_found() void = { const template = get_chosen_template("wikipedia", [ engine { name = "marginalia", template = "https://old-search.marginalia.nu/search?query=%s&js=no-js", } ]); assert(template is void); }; @test fn should_return_query_string_if_engine_found() void = { const template = get_chosen_template("wikipedia", [ engine { name = "wikipedia", template = "https://en.wikipedia.org/wiki/%s", } ]); assert(template is str); assert(template as str == "https://en.wikipedia.org/wiki/%s"); }; @test fn space_should_be_encoded() void = { const encoded_query = encoded("hello world"); assert(encoded_query == "hello%20world"); }; @test fn should_handle_equals_sign() void = { const encoded_query = encoded("does 1+1=2?"); assert(encoded_query == "does%201+1=2?"); }; @test fn should_put_query_in_template() void = { const query = "Dune (2021 film)"; const template = "https://en.wikipedia.org/wiki/%s"; const link = get_link(query, template); defer free(link); assert(link == "https://en.wikipedia.org/wiki/Dune%20(2021%20film)"); }; @test fn should_separate_name_and_query() void = { const args = ["search", "marginalia", "recipe"]; const (_, name, query) = parsed(args, "marginalia", ["marginalia"]); assert(name == "marginalia"); assert(query == "recipe"); }; @test fn query_should_contain_all_remaining_args() void = { const args = ["search", "marginalia", "recipe", "book"]; const (_, name, query) = parsed(args, "marginalia", ["marginalia"]); assert(name == "marginalia"); assert(query == "recipe book"); }; @test fn should_use_default_engine_if_none_specified() void = { const args = ["search", "recipe", "book"]; const (_, name, query) = parsed(args, "marginalia", ["marginalia", "osm"]); assert(name == "marginalia"); assert(query == "recipe book"); }; @test fn should_default_to_stdout() void = { const (mode, _, _) = parsed(["search", "marginalia", "recipe", "book"], "marginalia", ["marginalia"]); assert(mode == mode::STDOUT); }; @test fn can_request_copy() void = { const (mode, _, _) = parsed(["search", "-c", "marginalia", "recipe", "book"], "marginalia", ["marginalia"]); assert(mode == mode::COPY); }; @test fn can_request_browser() void = { const (mode, _, _) = parsed(["search", "-b", "marginalia", "recipe", "book"], "marginalia", ["marginalia"]); assert(mode == mode::BROWSER); }; @test fn returns_list_of_engine_names() void = { const names = names([ engine { name = "marginalia", template = "https://old-search.marginalia.nu/search?query=%s&js=no-js" }, engine { name = "wikipedia", template = "https://en.wikipedia.org/wiki/%s" }, ]); assert(len(names) == 2); assert(names[0] == "marginalia"); assert(names[1] == "wikipedia"); };