diff options
Diffstat (limited to 'search.ha')
| -rw-r--r-- | search.ha | 280 |
1 files changed, 280 insertions, 0 deletions
diff --git a/search.ha b/search.ha new file mode 100644 index 0000000..84f93ec --- /dev/null +++ b/search.ha @@ -0,0 +1,280 @@ +// SPDX-FileCopyrightText: 2025 Matthew Fennell <matthew@fennell.dev> +// +// 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"); +}; |
