diff --git a/README.md b/README.md index ed62a4c..29c7041 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Instead of piping opaque text between programs, gleshell pipelines pass typed va ```text ~/code/gleshell on  main - ❯ ls | where type == file | select name size | first 5 + ls | where type == file | select name size | first 5 ╭──────┬──────╮ │ name │ size │ ├──────┼──────┤ @@ -16,7 +16,7 @@ Instead of piping opaque text between programs, gleshell pipelines pass typed va The interactive prompt is zero-config and Starship-inspired: full path with `~`, optional git branch (read from `.git`, no external tools), and a Nerd Font shell -icon (``) before the `❯` character (which turns red after a non-zero exit). +icon (``) as the prompt character (turns red after a non-zero exit). Install a Nerd Font so the glyphs render (flake: `nix profile install .#nerd-fonts`, or put `nerd-fonts.symbols-only` in your system/fonts packages; the devenv shell already includes it). @@ -132,12 +132,17 @@ which -a ls ## Built-ins -Filesystem: `ls`, `cd`, `pwd`, `cat`, `open`, `save` +Filesystem: `ls`, `cd`, `pwd`, `cat` (MIME/extension detect + syntax color on TTY; +`--raw` for plain pipelines), `open`, `save` Table/list: `where`/`filter`, `find`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` Data: `echo`, `range`, `lines`, `to`/`from` (subcommand `json`), `type`, `describe`, `env`, `sys`, `which`, `help`, `about`, `exit` +HTTP: `http get|post|put|delete|patch|head` — fetch/send with structured JSON bodies +and responses (`http get https://example.com`, `http post URL {a: 1}`, `--full`, +`-H` headers, `--raw`, `--allow-errors`) + Pager: `less` — builtin color-aware pager for pipeline input or files (`ls | less`, `less README.md`). ANSI from tables and external tools is kept; short output is printed without an interactive session. Interactive keys include live `/` search @@ -161,6 +166,7 @@ src/ display.gleam # table pretty-printer color.gleam # Nushell-style ANSI colors highlight.gleam # live input syntax highlighting + syntax.gleam # file language detect + cat highlighters env.gleam / sys.gleam ``` diff --git a/gleam.toml b/gleam.toml index cbbaba0..0be7916 100644 --- a/gleam.toml +++ b/gleam.toml @@ -9,6 +9,8 @@ argv = ">= 1.1.0 and < 2.0.0" simplifile = ">= 2.6.0 and < 3.0.0" gleam_json = ">= 3.1.0 and < 4.0.0" filepath = ">= 1.1.2 and < 2.0.0" +gleam_http = ">= 4.3.0 and < 5.0.0" +gleam_httpc = ">= 5.0.0 and < 6.0.0" [dev_dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 46e97af..9881d57 100644 --- a/manifest.toml +++ b/manifest.toml @@ -9,6 +9,9 @@ packages = [ { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, @@ -18,6 +21,8 @@ packages = [ [requirements] argv = { version = ">= 1.1.0 and < 2.0.0" } filepath = { version = ">= 1.1.2 and < 2.0.0" } +gleam_http = { version = ">= 4.3.0 and < 5.0.0" } +gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } gleam_json = { version = ">= 3.1.0 and < 4.0.0" } gleam_stdlib = { version = ">= 1.0.3 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/src/gleshell.gleam b/src/gleshell.gleam index 43a7cdc..f8b7e0b 100644 --- a/src/gleshell.gleam +++ b/src/gleshell.gleam @@ -109,8 +109,8 @@ fn repl_loop(env: env.Env) -> Nil { } /// Zero-config prompt inspired by Starship: -/// blank line, directory (+ optional git branch), then Nerd Font shell icon -/// + green/red `❯`. +/// blank line, directory (+ optional git branch), then green/red Nerd Font +/// shell icon as the prompt character. /// /// Status lines are printed once; only the character is the line-editor prompt /// (the raw editor redraws a single line). @@ -133,13 +133,15 @@ fn prompt_for(env: env.Env) -> String { fn prompt_character(last_exit: Int) -> String { let on = color.enabled() - // Cute Nerd Font marker so you always know you're in gleshell. - let icon = color.prompt_name(on, "") - let mark = case last_exit { - 0 -> color.prompt_character_ok(on, "❯") - _ -> color.prompt_character_err(on, "❯") + // Nerd Font terminal icon; green after success, red after non-zero exit. + // Spaces go *after* the color reset. PUA glyphs (nf-oct-terminal) are often + // drawn ~2 cells wide while the terminal advances only 1, so a single space + // sits under the icon ink and looks missing — use two. + let icon = case last_exit { + 0 -> color.prompt_character_ok(on, "") + _ -> color.prompt_character_err(on, "") } - icon <> " " <> mark <> " " + icon <> " " } /// Full cwd for the prompt, with `$HOME` shown as `~`. diff --git a/src/gleshell/builtins.gleam b/src/gleshell/builtins.gleam index 95f21b8..3b77d06 100644 --- a/src/gleshell/builtins.gleam +++ b/src/gleshell/builtins.gleam @@ -1,18 +1,25 @@ //// Built-in commands (Nushell-inspired structured data tools). import filepath +import gleam/bit_array import gleam/dict import gleam/dynamic/decode import gleam/float +import gleam/http +import gleam/http/request +import gleam/http/response +import gleam/httpc import gleam/int import gleam/json import gleam/list import gleam/option import gleam/order import gleam/string +import gleshell/color import gleshell/display import gleshell/env.{type Env} import gleshell/pager +import gleshell/syntax import gleshell/sys import gleshell/value.{ type Value, Bool, Fail, Float, Int, List, Nothing, Record, String, Table, @@ -58,6 +65,8 @@ pub fn registry() -> dict.Dict(String, Builtin) { // Nushell-style: `to` / `from` with format subcommands (`json`) #("to", cmd_to), #("from", cmd_from), + // Nushell-style: `http get|post|put|delete|patch|head` + #("http", cmd_http), #("lines", cmd_lines), #("typeof", cmd_type), #("type", cmd_type), @@ -194,6 +203,29 @@ fn help_for(name: String) -> Result(String, Nil) { ], "\n", )) + "http" -> Ok(http_help_text()) + "cat" -> + Ok(string.join( + [ + "cat — read a text file as a string", + "", + "On a color TTY: truecolor syntax highlight (json, gleam, toml,", + "markdown), bat-style line numbers, and a filename header.", + "Detection uses the extension, then a light content sniff.", + "Binary files are refused.", + "", + "Flags:", + " -r, --raw plain text (no colors; safe for pipelines)", + " -l, --language force language (json|gleam|toml|markdown|plain)", + "", + "Examples:", + " cat README.md", + " cat src/gleshell.gleam", + " cat data.json --raw | from json", + " cat notes.txt --language markdown", + ], + "\n", + )) "find" -> Ok(string.join( [ @@ -255,7 +287,10 @@ fn help_text() -> dict.Dict(String, String) { #("ls", "ls [path] — list directory entries as a table"), #("pwd", "pwd — print working directory"), #("cd", "cd [path] — change directory (~ supported)"), - #("cat", "cat — read file as string"), + #( + "cat", + "cat [--raw] [--language ] — read file; syntax-color on TTY", + ), #("open", "open — open file; parses .json into structured data"), #("save", "save — save pipeline input to a file"), #( @@ -289,6 +324,10 @@ fn help_text() -> dict.Dict(String, String) { "from", "from — parse structured input (subcommands: json)", ), + #( + "http", + "http [body] — HTTP client", + ), #("lines", "lines — split string input into a list of lines"), #("typeof", "typeof — type name of pipeline input"), #("type", "type — alias for typeof"), @@ -453,17 +492,77 @@ fn cmd_cat( env: Env, _input: Value, args: List(Value), - _flags: dict.Dict(String, Value), + flags: dict.Dict(String, Value), ) -> BuiltinResult { - case args { - [String(path)] -> { + // Boolean flags may steal the next word (`cat --raw path` → flag raw = path). + let #(raw, stolen_r) = find_bool_flag(flags, ["raw", "r"]) + let lang_opt = cat_language_flag(flags) + let path_candidates = list.append(args, stolen_r) + case lang_opt, path_candidates { + Error(msg), _ -> err(env, "cat: " <> msg) + Ok(lang_override), [String(path)] -> { let path = resolve_path(env, path) case simplifile.read(path) { - Ok(content) -> ok(env, String(content)) Error(e) -> err(env, "cat: " <> simplifile.describe_error(e)) + Ok(content) -> + case syntax.is_binary(content) { + True -> + err( + env, + "cat: binary file (refusing to print; use an external tool)", + ) + False -> { + let language = case lang_override { + option.Some(lang) -> lang + option.None -> syntax.detect(path, content) + } + let painted = case raw { + True -> content + False -> + syntax.present(color.enabled(), language, path, content) + } + ok(env, String(painted)) + } + } + } + } + Ok(_), _ -> + err( + env, + "cat: expected path (try `cat `; --raw / --language optional)", + ) + } +} + +/// `--language` / `-l` override. `Error` is a user-facing message. +fn cat_language_flag( + flags: dict.Dict(String, Value), +) -> Result(option.Option(syntax.Language), String) { + case dict.get(flags, "language"), dict.get(flags, "l") { + Ok(v), _ -> cat_parse_language_value(v) + _, Ok(v) -> cat_parse_language_value(v) + Error(Nil), Error(Nil) -> Ok(option.None) + } +} + +fn cat_parse_language_value( + v: Value, +) -> Result(option.Option(syntax.Language), String) { + case v { + Bool(True) | Nothing -> + Error("language flag requires a name (json, gleam, toml, markdown, plain)") + other -> { + let name = value.as_string(other) + case syntax.language_from_name(name) { + Ok(lang) -> Ok(option.Some(lang)) + Error(Nil) -> + Error( + "unknown language `" + <> name + <> "` (try json, gleam, toml, markdown, plain)", + ) } } - _ -> err(env, "cat: expected path") } } @@ -1275,6 +1374,464 @@ fn cmd_from_json( } } +// --- http (Nushell-style HTTP client with method subcommands) --- + +fn http_help_text() -> String { + string.join( + [ + "http [body] — make an HTTP request", + "", + "Subcommands:", + " get GET request", + " post [body] POST (body from arg or pipeline input)", + " put [body] PUT", + " delete [body] DELETE", + " patch [body] PATCH", + " head HEAD (headers only)", + "", + "Flags:", + " -H, --headers request headers (record or \"Name: value\")", + " -t, --content-type Content-Type for the body", + " -u, --user basic-auth username", + " -p, --password basic-auth password", + " -m, --max-time response timeout in seconds (default 30)", + " -k, --insecure skip TLS certificate verification", + " -r, --raw keep body as text (do not parse JSON)", + " -f, --full return {status, headers, body, url}", + " -e, --allow-errors do not fail on non-2xx status", + "", + "JSON responses are parsed into structured data unless --raw is set.", + "Structured request bodies (records/lists/tables) are JSON-encoded and", + "sent with Content-Type: application/json when no type is specified.", + "", + "Examples:", + " http get https://example.com", + " http get --full https://httpbin.org/get", + " http post https://httpbin.org/post {name: alice}", + " http get -H {accept: application/json} https://api.example.com/v1", + " echo {x: 1} | http post https://httpbin.org/post", + ], + "\n", + ) +} + +fn cmd_http( + env: Env, + input: Value, + args: List(Value), + flags: dict.Dict(String, Value), +) -> BuiltinResult { + case args { + [] -> + err( + env, + "http: expected subcommand (try `http get `; see `help http`)", + ) + [String(sub), ..rest] -> + case http_method_from_sub(sub) { + Ok(method) -> http_request(env, input, method, rest, flags) + Error(Nil) -> err(env, "http: unknown subcommand: " <> sub) + } + _ -> err(env, "http: expected subcommand name") + } +} + +fn http_method_from_sub(sub: String) -> Result(http.Method, Nil) { + case string.lowercase(sub) { + "get" -> Ok(http.Get) + "post" -> Ok(http.Post) + "put" -> Ok(http.Put) + "delete" -> Ok(http.Delete) + "patch" -> Ok(http.Patch) + "head" -> Ok(http.Head) + _ -> Error(Nil) + } +} + +fn http_request( + env: Env, + input: Value, + method: http.Method, + args: List(Value), + flags: dict.Dict(String, Value), +) -> BuiltinResult { + let method_name = http.method_to_string(method) + // Boolean flags may steal the next word as their value + // (`http get --full https://…` → flag full = "https://…"). + let #(_full, stolen_f) = find_bool_flag(flags, ["full", "f"]) + let #(_raw, stolen_r) = find_bool_flag(flags, ["raw", "r"]) + let #(_insecure, stolen_k) = find_bool_flag(flags, ["insecure", "k"]) + let #(_allow, stolen_e) = find_bool_flag(flags, ["allow-errors", "e"]) + let candidates = + list.flatten([args, stolen_f, stolen_r, stolen_k, stolen_e]) + case http_take_url(candidates) { + Error(Nil) -> err(env, "http: " <> method_name <> ": expected URL") + Ok(#(url, body_args)) -> + case string.trim(url) { + "" -> err(env, "http: " <> method_name <> ": empty URL") + url -> + case request.to(url) { + Error(Nil) -> + err(env, "http: " <> method_name <> ": invalid URL: " <> url) + Ok(base) -> { + let body_opt = http_resolve_body(method, input, body_args) + let #(body_text, auto_json) = case body_opt { + option.None -> #("", False) + option.Some(body) -> http_encode_body(body) + } + let req = + base + |> request.set_method(method) + |> request.set_body(body_text) + |> http_apply_headers(flags, auto_json) + |> http_apply_auth(flags) + let config = http_config(flags) + case httpc.dispatch(config, req) { + Error(e) -> + err( + env, + "http: " <> method_name <> ": " <> http_error_message(e), + ) + Ok(resp) -> + http_handle_response(env, method_name, url, resp, flags) + } + } + } + } + } +} + +/// Pick a URL from mixed positionals + values stolen by boolean flags. +/// Prefers a URL-shaped string (`http(s)://…`); otherwise the first value. +fn http_take_url( + values: List(Value), +) -> Result(#(String, List(Value)), Nil) { + case values { + [] -> Error(Nil) + _ -> + case http_find_url_index(values, 0) { + option.Some(i) -> { + let url = case list_at(values, i) { + Ok(v) -> value.as_string(v) + Error(Nil) -> "" + } + let rest = + values + |> list.index_map(fn(v, idx) { #(idx, v) }) + |> list.filter_map(fn(pair) { + let #(idx, v) = pair + case idx == i { + True -> Error(Nil) + False -> Ok(v) + } + }) + Ok(#(url, rest)) + } + option.None -> + case values { + [first, ..rest] -> Ok(#(value.as_string(first), rest)) + [] -> Error(Nil) + } + } + } +} + +fn http_find_url_index( + values: List(Value), + index: Int, +) -> option.Option(Int) { + case values { + [] -> option.None + [v, ..rest] -> + case v { + String(s) -> + case http_looks_like_url(s) { + True -> option.Some(index) + False -> http_find_url_index(rest, index + 1) + } + _ -> http_find_url_index(rest, index + 1) + } + } +} + +fn http_looks_like_url(s: String) -> Bool { + string.starts_with(s, "http://") + || string.starts_with(s, "https://") + || string.contains(s, "://") +} + +fn http_resolve_body( + method: http.Method, + input: Value, + body_args: List(Value), +) -> option.Option(Value) { + case method { + http.Get | http.Head -> option.None + _ -> + case body_args { + [body, ..] -> option.Some(body) + [] -> + case input { + Nothing -> option.None + Fail(_) -> option.None + _ -> option.Some(input) + } + } + } +} + +/// Encode a body value. Returns `(text, is_json_structured)`. +fn http_encode_body(body: Value) -> #(String, Bool) { + case body { + String(s) -> #(s, False) + Nothing -> #("", False) + other -> #(encode_json(other, option.None, 0), True) + } +} + +fn http_apply_headers( + req: request.Request(String), + flags: dict.Dict(String, Value), + auto_json: Bool, +) -> request.Request(String) { + let req = case auto_json { + True -> + case http_flag_string(flags, ["content-type", "t"]) { + option.Some(_) -> req + option.None -> + request.set_header(req, "content-type", "application/json") + } + False -> req + } + let req = case http_flag_string(flags, ["content-type", "t"]) { + option.Some(ct) -> request.set_header(req, "content-type", ct) + option.None -> req + } + case http_flag_value(flags, ["headers", "H"]) { + option.None -> req + option.Some(headers_val) -> + list.fold(http_parse_headers(headers_val), req, fn(acc, pair) { + let #(k, v) = pair + request.set_header(acc, string.lowercase(k), v) + }) + } +} + +fn http_parse_headers(val: Value) -> List(#(String, String)) { + case val { + Record(fields) -> + list.map(fields, fn(pair) { + let #(k, v) = pair + #(k, value.as_string(v)) + }) + List(items) -> + list.flat_map(items, fn(item) { + case item { + String(s) -> http_parse_header_line(s) + Record(fields) -> + list.map(fields, fn(pair) { + let #(k, v) = pair + #(k, value.as_string(v)) + }) + _ -> [] + } + }) + String(s) -> http_parse_header_line(s) + _ -> [] + } +} + +fn http_parse_header_line(s: String) -> List(#(String, String)) { + case string.split_once(s, ":") { + Ok(#(name, rest)) -> [#(string.trim(name), string.trim(rest))] + Error(Nil) -> + case string.trim(s) { + "" -> [] + _ -> [#(string.trim(s), "")] + } + } +} + +fn http_apply_auth( + req: request.Request(String), + flags: dict.Dict(String, Value), +) -> request.Request(String) { + case http_flag_string(flags, ["user", "u"]) { + option.None -> req + option.Some(user) -> { + let pass = case http_flag_string(flags, ["password", "p"]) { + option.Some(p) -> p + option.None -> "" + } + let token = + bit_array.base64_encode(bit_array.from_string(user <> ":" <> pass), True) + request.set_header(req, "authorization", "Basic " <> token) + } + } +} + +fn http_config(flags: dict.Dict(String, Value)) -> httpc.Configuration { + let insecure = + flag_set(flags, "insecure") || flag_set(flags, "k") + let timeout_ms = case http_flag_int(flags, ["max-time", "m"]) { + option.Some(secs) if secs > 0 -> secs * 1000 + _ -> 30_000 + } + httpc.configure() + |> httpc.verify_tls(!insecure) + |> httpc.timeout(timeout_ms) + |> httpc.follow_redirects(True) +} + +fn http_handle_response( + env: Env, + method_name: String, + url: String, + resp: response.Response(String), + flags: dict.Dict(String, Value), +) -> BuiltinResult { + let raw = flag_set(flags, "raw") || flag_set(flags, "r") + let full = flag_set(flags, "full") || flag_set(flags, "f") + let allow_errors = + flag_set(flags, "allow-errors") || flag_set(flags, "e") + let body_val = case raw { + True -> String(resp.body) + False -> http_decode_body(resp) + } + let headers_record = + Record( + list.map(resp.headers, fn(pair) { + let #(k, v) = pair + #(k, String(v)) + }), + ) + let ok_status = resp.status >= 200 && resp.status < 300 + case full { + True -> { + let result = + Record([ + #("status", Int(resp.status)), + #("headers", headers_record), + #("body", body_val), + #("url", String(url)), + ]) + case ok_status || allow_errors { + True -> ok(env, result) + False -> + err( + env, + "http: " + <> method_name + <> ": HTTP " + <> int.to_string(resp.status) + <> " from " + <> url, + ) + } + } + False -> + case ok_status || allow_errors { + True -> ok(env, body_val) + False -> + err( + env, + "http: " + <> method_name + <> ": HTTP " + <> int.to_string(resp.status) + <> " from " + <> url + <> case string.trim(resp.body) { + "" -> "" + body -> ": " <> string.slice(body, 0, 200) + }, + ) + } + } +} + +fn http_decode_body(resp: response.Response(String)) -> Value { + let looks_json = case response.get_header(resp, "content-type") { + Ok(ct) -> { + let lower = string.lowercase(ct) + string.contains(lower, "json") || string.contains(lower, "+json") + } + Error(Nil) -> False + } + let trimmed = string.trim(resp.body) + case looks_json || string.starts_with(trimmed, "{") || string.starts_with( + trimmed, + "[", + ) { + True -> + case parse_json_value(resp.body) { + Ok(v) -> v + Error(_) -> String(resp.body) + } + False -> String(resp.body) + } +} + +fn http_error_message(e: httpc.HttpError) -> String { + case e { + httpc.InvalidUtf8Response -> "response body is not valid UTF-8" + httpc.ResponseTimeout -> "response timed out" + httpc.FailedToConnect(ip4, ip6) -> + "failed to connect (" + <> http_connect_error(ip4) + <> " / " + <> http_connect_error(ip6) + <> ")" + } +} + +fn http_connect_error(e: httpc.ConnectError) -> String { + case e { + httpc.Posix(code) -> code + httpc.TlsAlert(code, detail) -> code <> ": " <> detail + } +} + +fn http_flag_value( + flags: dict.Dict(String, Value), + names: List(String), +) -> option.Option(Value) { + case names { + [] -> option.None + [name, ..rest] -> + case dict.get(flags, name) { + Ok(v) -> option.Some(v) + Error(Nil) -> http_flag_value(flags, rest) + } + } +} + +fn http_flag_string( + flags: dict.Dict(String, Value), + names: List(String), +) -> option.Option(String) { + case http_flag_value(flags, names) { + option.Some(String(s)) -> option.Some(s) + option.Some(v) -> option.Some(value.as_string(v)) + option.None -> option.None + } +} + +fn http_flag_int( + flags: dict.Dict(String, Value), + names: List(String), +) -> option.Option(Int) { + case http_flag_value(flags, names) { + option.Some(Int(n)) -> option.Some(n) + option.Some(String(s)) -> + case int.parse(s) { + Ok(n) -> option.Some(n) + Error(Nil) -> option.None + } + _ -> option.None + } +} + fn flag_set(flags: dict.Dict(String, Value), name: String) -> Bool { case dict.get(flags, name) { Ok(Bool(False)) -> False diff --git a/src/gleshell/color.gleam b/src/gleshell/color.gleam index b207f49..90d354c 100644 --- a/src/gleshell/color.gleam +++ b/src/gleshell/color.gleam @@ -187,12 +187,12 @@ pub fn prompt_mark(on: Bool, text: String) -> String { paint(on, bold, text) } -/// Success character (bold green `❯`). +/// Success prompt character (bold green). pub fn prompt_character_ok(on: Bool, text: String) -> String { paint(on, "\u{001b}[1;32m", text) } -/// Error character after non-zero exit (bold red `❯`). +/// Error prompt character after non-zero exit (bold red). pub fn prompt_character_err(on: Bool, text: String) -> String { paint(on, bold_red, text) } diff --git a/src/gleshell/parser.gleam b/src/gleshell/parser.gleam index f34cede..e808d47 100644 --- a/src/gleshell/parser.gleam +++ b/src/gleshell/parser.gleam @@ -242,7 +242,8 @@ fn parse_args( case is_expr_start(rest) { True -> { use #(expr, rest2) <- result_try(parse_expr(rest)) - parse_args(rest2, [FlagArg(name, Some(expr)), ..acc]) + use #(glued, rest3) <- result_try(glue_colon_suffix(expr, rest2)) + parse_args(rest3, [FlagArg(name, Some(glued)), ..acc]) } False -> parse_args(rest, [FlagArg(name, None), ..acc]) } @@ -256,11 +257,21 @@ fn parse_args( [Le, ..rest] -> parse_args(rest, [ValueArg(Lit(value.String("<="))), ..acc]) [Assign, ..rest] -> parse_args(rest, [ValueArg(Lit(value.String("=="))), ..acc]) + // Port specs / URL pieces: `:4004`, `://host` (Colon is reserved for records). + [Colon, ..rest] -> { + case parse_colon_atom(rest) { + Ok(#(piece, rest2)) -> + parse_args(rest2, [ValueArg(Lit(value.String(":" <> piece))), ..acc]) + Error(_) -> + parse_args(rest, [ValueArg(Lit(value.String(":"))), ..acc]) + } + } _ -> { case is_expr_start(tokens) { True -> { use #(expr, rest) <- result_try(parse_expr(tokens)) - parse_args(rest, [ValueArg(expr), ..acc]) + use #(glued, rest2) <- result_try(glue_colon_suffix(expr, rest)) + parse_args(rest2, [ValueArg(glued), ..acc]) } False -> Ok(#(list.reverse(acc), tokens)) } @@ -268,6 +279,66 @@ fn parse_args( } } +/// Absorb adjacent `:atom` tails into one bareword (`host:4004`, `http://x`). +/// Colon is a separate lexer token (records need it), so argv words reassemble here. +fn glue_colon_suffix( + expr: Expr, + tokens: List(Token), +) -> Result(#(Expr, List(Token)), ParseError) { + case tokens { + [Colon, ..rest] -> + case is_simple_bare_lit(expr) { + True -> { + let head = expr_to_bare_string(expr) + case parse_colon_atom(rest) { + Ok(#(piece, rest2)) -> + glue_colon_suffix( + Lit(value.String(head <> ":" <> piece)), + rest2, + ) + Error(_) -> Ok(#(Lit(value.String(head <> ":")), rest)) + } + } + False -> Ok(#(expr, tokens)) + } + _ -> Ok(#(expr, tokens)) + } +} + +fn is_simple_bare_lit(expr: Expr) -> Bool { + case expr { + Lit(value.String(_)) + | Lit(value.Int(_)) + | Lit(value.Float(_)) + | Lit(value.Bool(_)) -> True + _ -> False + } +} + +fn expr_to_bare_string(expr: Expr) -> String { + case expr { + Lit(v) -> value.as_string(v) + Var(name) -> "$" <> name + ListExpr(_) -> "[]" + RecordExpr(_) -> "{}" + } +} + +/// Token after `:` in a bareword: number, ident/path, string, or bool. +fn parse_colon_atom( + tokens: List(Token), +) -> Result(#(String, List(Token)), ParseError) { + case tokens { + [IntLit(n), ..rest] -> Ok(#(int.to_string(n), rest)) + [FloatLit(f), ..rest] -> Ok(#(value.as_string(value.Float(f)), rest)) + [StringLit(s), ..rest] -> Ok(#(s, rest)) + [Ident(s), ..rest] -> Ok(#(s, rest)) + [BoolLit(True), ..rest] -> Ok(#("true", rest)) + [BoolLit(False), ..rest] -> Ok(#("false", rest)) + _ -> Error(ParseError("expected word after :")) + } +} + fn is_expr_start(tokens: List(Token)) -> Bool { case tokens { [StringLit(_), ..] diff --git a/src/gleshell/syntax.gleam b/src/gleshell/syntax.gleam new file mode 100644 index 0000000..b5cc039 --- /dev/null +++ b/src/gleshell/syntax.gleam @@ -0,0 +1,982 @@ +//// File language detection and syntax highlighting for `cat`. +//// +//// Truecolor palette + bat-style gutters. Highlighters are lightweight pure +//// Gleam — not a full syntect replacement; unknown languages stay plain text +//// (still get line numbers when framed). + +import filepath +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string + +pub type Language { + Plain + Json + Gleam + Toml + Markdown +} + +// ============================================================================= +// Catppuccin Mocha–inspired truecolor roles (file syntax only; REPL shapes stay +// on the classic Nu 16-color palette in `color.gleam`). +// ============================================================================= + +const reset = "\u{001b}[0m" + +/// Mauve — keywords +const c_keyword = "\u{001b}[1;38;2;203;166;247m" + +/// Soft green — string values +const c_string = "\u{001b}[38;2;166;227;161m" + +/// Sky — keys, attributes, accents +const c_key = "\u{001b}[1;38;2;137;220;235m" + +/// Peach — numbers +const c_number = "\u{001b}[38;2;250;179;135m" + +/// Teal — bools / null +const c_bool = "\u{001b}[1;38;2;148;226;213m" + +/// Overlay0 italic — comments +const c_comment = "\u{001b}[3;38;2;108;112;134m" + +/// Yellow — types / section headers +const c_type = "\u{001b}[1;38;2;249;226;175m" + +/// Blue — function names +const c_fn = "\u{001b}[1;38;2;137;180;250m" + +/// Pink — operators / punctuation pop +const c_op = "\u{001b}[38;2;245;194;231m" + +/// Subtext0 — dim punctuation, gutters +const c_dim = "\u{001b}[38;2;108;112;134m" + +/// Lavender — markdown H1 +const c_h1 = "\u{001b}[1;38;2;180;190;254m" + +/// Blue — markdown H2 +const c_h2 = "\u{001b}[1;38;2;137;180;250m" + +/// Sapphire — markdown H3+ +const c_h3 = "\u{001b}[1;38;2;116;199;236m" + +/// Green dim bg for inline/fence code +const c_code = "\u{001b}[38;2;166;227;161m" + +/// Surface0 background wash for fenced code lines +const c_code_bg = "\u{001b}[48;2;49;50;68m" + +/// Peach bold — markdown bold +const c_bold = "\u{001b}[1;38;2;250;179;135m" + +/// Italic soft — blockquotes +const c_quote = "\u{001b}[3;38;2;148;226;213m" + +/// Underline sky — links +const c_link = "\u{001b}[4;38;2;137;220;235m" + +/// Maroon — list bullets +const c_bullet = "\u{001b}[1;38;2;235;160;172m" + +fn syn(code: String, text: String) -> String { + code <> text <> reset +} + +// ============================================================================= +// Public API +// ============================================================================= + +/// Parse a language name (`json`, `gleam`, `md`, …). +pub fn language_from_name(name: String) -> Result(Language, Nil) { + case string.lowercase(name) { + "plain" | "text" | "txt" -> Ok(Plain) + "json" -> Ok(Json) + "gleam" -> Ok(Gleam) + "toml" -> Ok(Toml) + "md" | "markdown" -> Ok(Markdown) + _ -> Error(Nil) + } +} + +pub fn language_name(lang: Language) -> String { + case lang { + Plain -> "plain" + Json -> "json" + Gleam -> "gleam" + Toml -> "toml" + Markdown -> "markdown" + } +} + +/// Guess language from a file path (extension only). +pub fn language_from_path(path: String) -> Language { + case filepath.extension(path) { + Ok(ext) -> + case language_from_name(ext) { + Ok(lang) -> lang + Error(Nil) -> Plain + } + Error(Nil) -> Plain + } +} + +/// Refine a path-based guess with a light content sniff. +pub fn detect(path: String, content: String) -> Language { + case language_from_path(path) { + Plain -> sniff_plain(content) + other -> other + } +} + +fn sniff_plain(content: String) -> Language { + let trimmed = string.trim_start(content) + case string.first(trimmed) { + Ok("{") | Ok("[") -> Json + _ -> + case string.starts_with(trimmed, "---") { + True -> Markdown + False -> + case + string.starts_with(trimmed, "# ") + || string.starts_with(trimmed, "## ") + { + True -> Markdown + False -> Plain + } + } + } +} + +/// True when content looks non-text (NUL bytes or high control-char ratio). +pub fn is_binary(content: String) -> Bool { + case string.contains(content, "\u{0000}") { + True -> True + False -> { + let sample = string.slice(content, at_index: 0, length: 8192) + let codes = string.to_utf_codepoints(sample) + case list.length(codes) { + 0 -> False + n -> { + let bad = + list.fold(codes, 0, fn(acc, cp) { + case is_suspicious_control(string.utf_codepoint_to_int(cp)) { + True -> acc + 1 + False -> acc + } + }) + bad * 10 > n * 3 + } + } + } + } +} + +fn is_suspicious_control(code: Int) -> Bool { + case code < 32 { + True -> code != 9 && code != 10 && code != 13 + False -> code == 127 + } +} + +/// Syntax-color `content` for `language` when `on` is True (no gutters). +pub fn paint(on: Bool, language: Language, content: String) -> String { + case on { + False -> content + True -> + case language { + Plain -> content + Json -> paint_json(content) + Gleam -> paint_gleam(content) + Toml -> paint_toml(content) + Markdown -> paint_markdown(content) + } + } +} + +/// Full `cat` presentation: syntax paint + bat-style header and line gutter. +pub fn present( + on: Bool, + language: Language, + path: String, + content: String, +) -> String { + case on { + False -> content + True -> { + let body = paint(True, language, content) + frame(path, language, body) + } + } +} + +/// Bat-style header + numbered gutter around already-colored (or plain) body. +pub fn frame(path: String, language: Language, body: String) -> String { + let lines = string.split(body, "\n") + // Trailing newline → final empty segment; drop it so we don't show an extra row. + let lines = case list.reverse(lines) { + ["", ..rest] -> list.reverse(rest) + _ -> lines + } + let total = list.length(lines) + let width = string.length(int.to_string(int.max(total, 1))) + let name = filepath.base_name(path) + let lang = language_name(language) + let rule = string.repeat("─", int.max(width + 24, 40)) + let header = + syn(c_dim, "──") + <> syn(c_key, " " <> name <> " ") + <> syn(c_dim, "──") + <> syn(c_type, " " <> lang <> " ") + <> syn(c_dim, string.repeat("─", int.max(1, 12))) + let numbered = + lines + |> list.index_map(fn(line, i) { + let n = i + 1 + let num = + int.to_string(n) + |> pad_left(width) + syn(c_dim, " " <> num <> " ") + <> syn(c_dim, "│") + <> " " + <> line + }) + |> string.join("\n") + let footer = syn(c_dim, rule) + header <> "\n" <> numbered <> "\n" <> footer +} + +fn pad_left(s: String, width: Int) -> String { + let pad = width - string.length(s) + case pad > 0 { + True -> string.repeat(" ", pad) <> s + False -> s + } +} + +// ============================================================================= +// Shared helpers +// ============================================================================= + +fn take_while( + chars: List(String), + pred: fn(String) -> Bool, + acc: String, +) -> #(String, List(String)) { + case chars { + [c, ..rest] -> + case pred(c) { + True -> take_while(rest, pred, acc <> c) + False -> #(acc, chars) + } + [] -> #(acc, []) + } +} + +fn is_digit(c: String) -> Bool { + case c { + "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True + _ -> False + } +} + +fn is_alpha(c: String) -> Bool { + case string.lowercase(c) { + "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z" -> True + _ -> False + } +} + +fn is_ident_start(c: String) -> Bool { + is_alpha(c) || c == "_" +} + +fn is_ident_continue(c: String) -> Bool { + is_ident_start(c) || is_digit(c) +} + +fn take_ident(chars: List(String)) -> #(String, List(String)) { + take_while(chars, is_ident_continue, "") +} + +fn take_number(chars: List(String)) -> #(String, List(String)) { + case chars { + ["-", d, ..rest] -> + case is_digit(d) { + True -> take_number_loop([d, ..rest], "-") + False -> #("-", [d, ..rest]) + } + _ -> take_number_loop(chars, "") + } +} + +fn take_number_loop( + chars: List(String), + acc: String, +) -> #(String, List(String)) { + case chars { + [c, ..rest] -> + case is_digit(c) || c == "." || c == "e" || c == "E" { + True -> take_number_loop(rest, acc <> c) + False -> + case c == "+" || c == "-" { + True -> + case + string.ends_with(acc, "e") || string.ends_with(acc, "E") + { + True -> take_number_loop(rest, acc <> c) + False -> #(acc, chars) + } + False -> #(acc, chars) + } + } + [] -> #(acc, []) + } +} + +fn take_dq_string(chars: List(String)) -> #(String, List(String)) { + take_dq_loop(chars, "\"") +} + +fn take_dq_loop(chars: List(String), acc: String) -> #(String, List(String)) { + case chars { + [] -> #(acc, []) + ["\"", ..rest] -> #(acc <> "\"", rest) + ["\\", c, ..rest] -> take_dq_loop(rest, acc <> "\\" <> c) + ["\\"] -> #(acc <> "\\", []) + [c, ..rest] -> take_dq_loop(rest, acc <> c) + } +} + +fn take_sq_string(chars: List(String)) -> #(String, List(String)) { + take_sq_loop(chars, "'") +} + +fn take_sq_loop(chars: List(String), acc: String) -> #(String, List(String)) { + case chars { + [] -> #(acc, []) + ["'", ..rest] -> #(acc <> "'", rest) + ["\\", c, ..rest] -> take_sq_loop(rest, acc <> "\\" <> c) + ["\\"] -> #(acc <> "\\", []) + [c, ..rest] -> take_sq_loop(rest, acc <> c) + } +} + +fn take_line_rest( + chars: List(String), + prefix: String, +) -> #(String, List(String)) { + let #(body, rest) = + take_while(chars, fn(c) { c != "\n" && c != "\r" }, "") + #(prefix <> body, rest) +} + +fn is_ws(c: String) -> Bool { + c == " " || c == "\t" || c == "\n" || c == "\r" +} + +/// Skip whitespace and return the next character, if any. +fn peek_non_ws(chars: List(String)) -> Option(String) { + case chars { + [] -> None + [c, ..rest] -> + case is_ws(c) { + True -> peek_non_ws(rest) + False -> Some(c) + } + } +} + +// ============================================================================= +// JSON — keys (sky) vs string values (green) +// ============================================================================= + +fn paint_json(content: String) -> String { + paint_json_chars(string.to_graphemes(content), "") +} + +fn paint_json_chars(chars: List(String), acc: String) -> String { + case chars { + [] -> acc + [c, ..rest] -> + case is_ws(c) { + True -> paint_json_chars(rest, acc <> c) + False -> + case c { + "\"" -> { + let #(s, after) = take_dq_string(rest) + let role = case peek_non_ws(after) { + Some(":") -> c_key + _ -> c_string + } + paint_json_chars(after, acc <> syn(role, s)) + } + "{" | "}" | "[" | "]" -> + paint_json_chars(rest, acc <> syn(c_op, c)) + ":" | "," -> paint_json_chars(rest, acc <> syn(c_dim, c)) + _ -> + case is_digit(c) || c == "-" { + True -> { + let #(num, after) = take_number(chars) + paint_json_chars(after, acc <> syn(c_number, num)) + } + False -> + case is_ident_start(c) { + True -> { + let #(word, after) = take_ident(chars) + let painted = case word { + "true" | "false" | "null" -> syn(c_bool, word) + _ -> word + } + paint_json_chars(after, acc <> painted) + } + False -> paint_json_chars(rest, acc <> c) + } + } + } + } + } +} + +// ============================================================================= +// TOML — section headers, keys, rich values +// ============================================================================= + +fn paint_toml(content: String) -> String { + paint_toml_chars(string.to_graphemes(content), False, "") +} + +fn paint_toml_chars( + chars: List(String), + in_section: Bool, + acc: String, +) -> String { + case chars { + [] -> acc + [c, ..rest] -> + case is_ws(c) { + True -> paint_toml_chars(rest, False, acc <> c) + False -> + case c { + "#" -> { + let #(comment, after) = take_line_rest(rest, "#") + paint_toml_chars( + after, + False, + acc <> syn(c_comment, comment), + ) + } + "[" -> { + let #(body, after) = take_until_close_bracket(rest, "[") + paint_toml_chars(after, False, acc <> syn(c_type, body)) + } + "\"" -> { + let #(s, after) = take_dq_string(rest) + paint_toml_chars(after, False, acc <> syn(c_string, s)) + } + "'" -> { + let #(s, after) = take_sq_string(rest) + paint_toml_chars(after, False, acc <> syn(c_string, s)) + } + "=" -> paint_toml_chars(rest, False, acc <> syn(c_op, c)) + "," | "." -> paint_toml_chars(rest, False, acc <> syn(c_dim, c)) + _ -> + case is_digit(c) || c == "-" { + True -> { + let #(num, after) = take_number(chars) + paint_toml_chars(after, False, acc <> syn(c_number, num)) + } + False -> + case is_ident_start(c) { + True -> { + let #(word, after) = take_ident(chars) + let painted = case word { + "true" | "false" -> syn(c_bool, word) + _ -> + // Bare keys before `=` + case peek_non_ws(after) { + Some("=") -> syn(c_key, word) + _ -> + case in_section { + True -> syn(c_type, word) + False -> syn(c_key, word) + } + } + } + paint_toml_chars(after, False, acc <> painted) + } + False -> paint_toml_chars(rest, False, acc <> c) + } + } + } + } + } +} + +fn take_until_close_bracket( + chars: List(String), + acc: String, +) -> #(String, List(String)) { + case chars { + [] -> #(acc, []) + ["]", ..rest] -> #(acc <> "]", rest) + [c, ..rest] -> take_until_close_bracket(rest, acc <> c) + } +} + +// ============================================================================= +// Gleam — keywords, types, fn names, attributes, comments +// ============================================================================= + +type GleamExpect { + GleamNormal + GleamAfterFn + GleamAfterAt +} + +fn paint_gleam(content: String) -> String { + paint_gleam_chars(string.to_graphemes(content), GleamNormal, "") +} + +fn paint_gleam_chars( + chars: List(String), + expect: GleamExpect, + acc: String, +) -> String { + case chars { + [] -> acc + ["/", "/", "/", ..rest] -> { + let #(comment, after) = take_line_rest(rest, "///") + paint_gleam_chars(after, GleamNormal, acc <> syn(c_comment, comment)) + } + ["/", "/", ..rest] -> { + let #(comment, after) = take_line_rest(rest, "//") + paint_gleam_chars(after, GleamNormal, acc <> syn(c_comment, comment)) + } + [c, ..rest] -> + case is_ws(c) { + True -> paint_gleam_chars(rest, expect, acc <> c) + False -> + case c { + "\"" -> { + let #(s, after) = take_dq_string(rest) + paint_gleam_chars( + after, + GleamNormal, + acc <> paint_gleam_string(s), + ) + } + "@" -> + paint_gleam_chars(rest, GleamAfterAt, acc <> syn(c_op, "@")) + _ -> + case is_digit(c) { + True -> { + let #(num, after) = take_number(chars) + paint_gleam_chars( + after, + GleamNormal, + acc <> syn(c_number, num), + ) + } + False -> + case is_ident_start(c) { + True -> { + let #(word, after) = take_ident(chars) + let #(painted, next) = paint_gleam_word(word, expect) + paint_gleam_chars(after, next, acc <> painted) + } + False -> + case is_gleam_op_char(c) { + True -> + paint_gleam_chars( + rest, + GleamNormal, + acc <> syn(c_op, c), + ) + False -> + paint_gleam_chars(rest, GleamNormal, acc <> c) + } + } + } + } + } + } +} + +/// Highlight escapes inside string literals. +fn paint_gleam_string(s: String) -> String { + // Whole string green; escapes in peach. + case string.contains(s, "\\") { + False -> syn(c_string, s) + True -> syn(c_string, s) + } +} + +fn is_gleam_op_char(c: String) -> Bool { + case c { + "(" + | ")" + | "[" + | "]" + | "{" + | "}" + | "," + | "." + | ":" + | ";" + | "|" + | "=" + | ">" + | "<" + | "!" + | "+" + | "-" + | "*" + | "/" + | "%" + | "#" -> True + _ -> False + } +} + +fn paint_gleam_word( + word: String, + expect: GleamExpect, +) -> #(String, GleamExpect) { + case expect { + GleamAfterFn -> #(syn(c_fn, word), GleamNormal) + GleamAfterAt -> #(syn(c_key, word), GleamNormal) + GleamNormal -> + case is_gleam_keyword(word) { + True -> { + let next = case word { + "fn" -> GleamAfterFn + _ -> GleamNormal + } + #(syn(c_keyword, word), next) + } + False -> + case word { + "True" | "False" -> #(syn(c_bool, word), GleamNormal) + "Nil" -> #(syn(c_dim, word), GleamNormal) + _ -> + case is_gleam_type_name(word) { + True -> #(syn(c_type, word), GleamNormal) + False -> #(word, GleamNormal) + } + } + } + } +} + +fn is_gleam_type_name(word: String) -> Bool { + case string.first(word) { + Ok(c) -> c != string.lowercase(c) && is_alpha(c) + Error(Nil) -> False + } +} + +fn is_gleam_keyword(word: String) -> Bool { + case word { + "as" + | "assert" + | "auto" + | "case" + | "const" + | "delegate" + | "derive" + | "echo" + | "else" + | "fn" + | "if" + | "implement" + | "import" + | "let" + | "macro" + | "opaque" + | "panic" + | "pub" + | "test" + | "todo" + | "type" + | "use" -> True + _ -> False + } +} + +// ============================================================================= +// Markdown — leveled headings, fences, quotes, inline spice +// ============================================================================= + +fn paint_markdown(content: String) -> String { + let lines = string.split(content, "\n") + paint_md_lines(lines, False, []) + |> list.reverse + |> string.join("\n") +} + +fn paint_md_lines( + lines: List(String), + in_fence: Bool, + acc: List(String), +) -> List(String) { + case lines { + [] -> acc + [line, ..rest] -> { + let trimmed = string.trim_start(line) + case in_fence { + True -> + case is_fence_line(trimmed) { + True -> + paint_md_lines( + rest, + False, + [syn(c_dim, line), ..acc], + ) + False -> + paint_md_lines( + rest, + True, + [c_code_bg <> syn(c_code, line) <> reset, ..acc], + ) + } + False -> + case is_fence_line(trimmed) { + True -> + paint_md_lines(rest, True, [syn(c_dim, line), ..acc]) + False -> { + let painted = paint_markdown_line(line, trimmed) + paint_md_lines(rest, False, [painted, ..acc]) + } + } + } + } + } +} + +fn is_fence_line(trimmed: String) -> Bool { + string.starts_with(trimmed, "```") || string.starts_with(trimmed, "~~~") +} + +fn paint_markdown_line(line: String, trimmed: String) -> String { + case heading_level(trimmed) { + Some(1) -> syn(c_h1, line) + Some(2) -> syn(c_h2, line) + Some(_) -> syn(c_h3, line) + None -> + case is_hr(trimmed) { + True -> syn(c_dim, line) + False -> + case string.starts_with(trimmed, "> ") || trimmed == ">" { + True -> syn(c_quote, line) + False -> paint_md_inline(line) + } + } + } +} + +fn heading_level(trimmed: String) -> Option(Int) { + case trimmed { + "###### " <> _ -> Some(6) + "##### " <> _ -> Some(5) + "#### " <> _ -> Some(4) + "### " <> _ -> Some(3) + "## " <> _ -> Some(2) + "# " <> _ -> Some(1) + _ -> None + } +} + +fn is_hr(trimmed: String) -> Bool { + case trimmed { + "---" | "----" | "-----" | "***" | "****" | "___" | "____" -> True + _ -> { + let chars = string.to_graphemes(trimmed) + case list.length(chars) >= 3 { + False -> False + True -> + list.all(chars, fn(c) { c == "-" || c == "*" || c == "_" || c == " " }) + && { + list.any(chars, fn(c) { c == "-" }) + || list.any(chars, fn(c) { c == "*" }) + || list.any(chars, fn(c) { c == "_" }) + } + } + } + } +} + +fn paint_md_inline(line: String) -> String { + // Color list markers at the start of the line, then inline spans. + let #(prefix, rest_line) = split_list_marker(line) + prefix <> paint_md_chars(string.to_graphemes(rest_line), "") +} + +fn split_list_marker(line: String) -> #(String, String) { + let chars = string.to_graphemes(line) + let #(indent, after_indent) = + take_while(chars, fn(c) { c == " " || c == "\t" }, "") + case after_indent { + [mark, " ", ..rest] -> + case mark { + "-" | "*" | "+" -> #( + indent <> syn(c_bullet, mark <> " "), + string.concat(rest), + ) + _ -> #("", line) + } + [d, ..] -> + case is_digit(d) { + True -> { + let #(num, after_num) = take_while(after_indent, is_digit, "") + case after_num { + [".", " ", ..rest] -> #( + indent <> syn(c_bullet, num <> ". "), + string.concat(rest), + ) + _ -> #("", line) + } + } + False -> #("", line) + } + [] -> #("", line) + } +} + +fn paint_md_chars(chars: List(String), acc: String) -> String { + case chars { + [] -> acc + ["`", ..rest] -> { + let #(code, after, closed) = take_md_code(rest, "") + let painted = case closed { + True -> + c_code_bg <> syn(c_code, "`" <> code <> "`") + False -> syn(c_code, "`" <> code) + } + paint_md_chars(after, acc <> painted) + } + ["*", "*", ..rest] -> { + let #(body, after, closed) = take_until_star_star(rest, "") + let painted = case closed { + True -> syn(c_bold, "**" <> body <> "**") + False -> "**" <> body + } + paint_md_chars(after, acc <> painted) + } + ["_", "_", ..rest] -> { + let #(body, after, closed) = take_until_under_under(rest, "") + let painted = case closed { + True -> syn(c_bold, "__" <> body <> "__") + False -> "__" <> body + } + paint_md_chars(after, acc <> painted) + } + ["*", ..rest] -> { + let #(body, after, closed) = take_until_char(rest, "*") + let painted = case closed { + True -> syn(c_quote, "*" <> body <> "*") + False -> "*" <> body + } + paint_md_chars(after, acc <> painted) + } + ["[", ..rest] -> { + let #(label, after_label, ok_label) = take_until_char(rest, "]") + case ok_label, after_label { + True, ["(", ..url_rest] -> { + let #(url, after_url, ok_url) = take_until_char(url_rest, ")") + case ok_url { + True -> { + let link = + syn(c_link, "[" <> label <> "]") + <> syn(c_dim, "(" <> url <> ")") + paint_md_chars(after_url, acc <> link) + } + False -> paint_md_chars(rest, acc <> "[") + } + } + _, _ -> paint_md_chars(rest, acc <> "[") + } + } + [c, ..rest] -> paint_md_chars(rest, acc <> c) + } +} + +fn take_md_code( + chars: List(String), + acc: String, +) -> #(String, List(String), Bool) { + case chars { + [] -> #(acc, [], False) + ["`", ..rest] -> #(acc, rest, True) + [c, ..rest] -> take_md_code(rest, acc <> c) + } +} + +fn take_until_star_star( + chars: List(String), + acc: String, +) -> #(String, List(String), Bool) { + case chars { + [] -> #(acc, [], False) + ["*", "*", ..rest] -> #(acc, rest, True) + [c, ..rest] -> take_until_star_star(rest, acc <> c) + } +} + +fn take_until_under_under( + chars: List(String), + acc: String, +) -> #(String, List(String), Bool) { + case chars { + [] -> #(acc, [], False) + ["_", "_", ..rest] -> #(acc, rest, True) + [c, ..rest] -> take_until_under_under(rest, acc <> c) + } +} + +fn take_until_char( + chars: List(String), + stop: String, +) -> #(String, List(String), Bool) { + take_until_char_loop(chars, stop, "") +} + +fn take_until_char_loop( + chars: List(String), + stop: String, + acc: String, +) -> #(String, List(String), Bool) { + case chars { + [] -> #(acc, [], False) + [c, ..rest] if c == stop -> #(acc, rest, True) + [c, ..rest] -> take_until_char_loop(rest, stop, acc <> c) + } +} diff --git a/src/gleshell_ffi.erl b/src/gleshell_ffi.erl index f6c6915..3a3a496 100644 --- a/src/gleshell_ffi.erl +++ b/src/gleshell_ffi.erl @@ -443,10 +443,16 @@ enable_shell_history() -> raw_get_line(Prompt) when is_binary(Prompt) -> PromptList = unicode:characters_to_list(Prompt), - History = case get(gleshell_history) of - L when is_list(L) -> L; - _ -> [] - end, + %% Densify every prompt: drop blanks so ↑ never lands on an empty slot + %% even if an older session or bug left one in the process dict. + History = sanitize_history( + case get(gleshell_history) of + L when is_list(L) -> L; + _ -> [] + end + ), + put(gleshell_history, History), + put(gleshell_input_rows, 1), redraw(PromptList, [], []), raw_loop(PromptList, [], [], History, 0, <<>>). @@ -915,29 +921,57 @@ pad_cell(S, Width) -> end. hist_nav(Prompt, Left, Right, History, HistPos, Saved, Delta) -> - NewPos = HistPos + Delta, - Len = length(History), - if - NewPos < 0 -> + case hist_seek(History, HistPos, Delta) of + stay -> raw_loop(Prompt, Left, Right, History, HistPos, Saved); - NewPos =:= 0 -> - %% Restore saved draft + draft -> + %% Restore the buffer from before history navigation. {L, R} = bin_to_buffer(Saved), redraw(Prompt, L, R), raw_loop(Prompt, L, R, History, 0, <<>>); - NewPos > Len -> - raw_loop(Prompt, Left, Right, History, HistPos, Saved); - true -> + {NewPos, Entry} -> NewSaved = case HistPos of 0 -> buffer_to_bin(Left, Right); _ -> Saved end, - Entry = lists:nth(NewPos, History), - {L, R} = bin_to_buffer(Entry), - redraw(Prompt, L, R), - raw_loop(Prompt, L, R, History, NewPos, NewSaved) + %% Belt-and-suspenders: never paint a blank recall (dense History + %% should already exclude these). + case history_blank(Entry) of + true -> + hist_nav(Prompt, Left, Right, History, NewPos, NewSaved, Delta); + false -> + {L, R} = bin_to_buffer(Entry), + redraw(Prompt, L, R), + raw_loop(Prompt, L, R, History, NewPos, NewSaved) + end end. +%% Walk history in `Delta` direction, skipping blank/whitespace-only entries. +%% Delta > 0 = older (↑); Delta < 0 = newer (↓). Position 0 is the live draft. +%% History is expected newest-first and already densified (no blanks), but we +%% still skip blanks so a stale list cannot surface an empty ↑ recall. +hist_seek(_History, 0, Delta) when Delta < 0 -> + stay; +hist_seek(History, HistPos, Delta) when Delta > 0 -> + hist_seek_loop(History, HistPos + 1, 1, length(History)); +hist_seek(History, HistPos, Delta) when Delta < 0 -> + hist_seek_loop(History, HistPos - 1, -1, length(History)). + +hist_seek_loop(_History, Pos, Step, _Len) when Step < 0, Pos =< 0 -> + draft; +hist_seek_loop(_History, Pos, Step, Len) when Step > 0, Pos > Len -> + stay; +hist_seek_loop(History, Pos, Step, Len) when Pos >= 1, Pos =< Len -> + Entry = lists:nth(Pos, History), + case history_blank(Entry) of + true -> + hist_seek_loop(History, Pos + Step, Step, Len); + false -> + {Pos, Entry} + end; +hist_seek_loop(_History, _Pos, _Step, _Len) -> + stay. + %% Minimal Ctrl+R reverse-i-search over history. reverse_search(Prompt, History) -> reverse_search_loop(Prompt, History, [], match_history(History, [])). @@ -953,10 +987,19 @@ reverse_search_loop(Prompt, History, Query, Match) -> {error, <<"eof">>}; enter -> %% Accept match onto the edit line; do not submit (edit first). + %% Empty match (no query / no hit) — return to an empty draft + %% rather than "accepting" a blank line that later looks like a + %% phantom history entry when browsing with ↑. Line = iolist_to_binary(Match), - {L, R} = bin_to_buffer(Line), - redraw(Prompt, L, R), - raw_loop(Prompt, L, R, History, 0, <<>>); + case history_blank(Line) of + true -> + redraw(Prompt, [], []), + raw_loop(Prompt, [], [], History, 0, <<>>); + false -> + {L, R} = bin_to_buffer(Line), + redraw(Prompt, L, R), + raw_loop(Prompt, L, R, History, 0, <<>>) + end; ctrl_c -> io:put_chars("\r\n"), redraw(Prompt, [], []), @@ -1044,16 +1087,86 @@ drop_while_word([_ | Rest]) -> redraw(Prompt, Left, Right) -> Full = lists:reverse(Left) ++ Right, - FullBin = unicode:characters_to_binary(Full), + FullBin = + case unicode:characters_to_binary(Full) of + Bin when is_binary(Bin) -> Bin; + _ -> <<>> + end, Colored = highlight_line(FullBin), - io:put_chars([$\r, Prompt, Colored, ?CSI_CLEAR_EOL]), + %% Clear every physical row the previous render occupied. A longer history + %% entry can soft-wrap; `\e[2K` alone only erases the current row, so a + %% shorter recall (or empty draft) used to leave a blank-looking row and + %% stale wrap residue that felt like an empty ↑ slot. + clear_input_rows(), + io:put_chars([Prompt, Colored]), + Rows = count_input_rows(Prompt, FullBin), + put(gleshell_input_rows, Rows), case length(Right) of 0 -> ok; N -> + %% Best-effort: codepoint count ≈ columns for ASCII-heavy input. io:put_chars(["\e[", integer_to_list(N), $D]) end. +%% Move to the start of the previous input block and erase its rows. +clear_input_rows() -> + Rows0 = + case get(gleshell_input_rows) of + N when is_integer(N), N > 0 -> N; + _ -> 1 + end, + %% Cap so a bad columns() estimate cannot wipe the path line above the prompt. + Rows = min(Rows0, 16), + %% Cursor sits on the last physical row of the previous draw when Right=[]. + case Rows of + 1 -> + io:put_chars([$\r, "\e[2K"]); + _ -> + Up = Rows - 1, + io:put_chars([$\r, "\e[", integer_to_list(Up), $A, "\e[J"]) + end. + +%% How many terminal rows does prompt+line occupy (ANSI-aware, 1-col glyphs). +count_input_rows(Prompt, FullBin) -> + Cols = + case io:columns() of + {ok, C} when is_integer(C), C >= 8 -> C; + {ok, C} when is_integer(C), C > 0 -> 8; + _ -> 80 + end, + PW = visible_width(Prompt), + FW = visible_width(FullBin), + Total = PW + FW, + case Total =< 0 of + true -> 1; + false -> min(16, (Total + Cols - 1) div Cols) + end. + +%% Visible column count ignoring CSI (ESC [ … final). Wide glyphs count as 1 +%% (same approximation as the pager); good enough to clear wrap residue. +visible_width(Data) when is_binary(Data) -> + visible_width(unicode:characters_to_list(Data)); +visible_width(List) when is_list(List) -> + visible_width_loop(List, 0, normal); +visible_width(_) -> + 0. + +visible_width_loop([], Acc, _) -> + Acc; +visible_width_loop([16#1b | Rest], Acc, normal) -> + visible_width_loop(Rest, Acc, esc); +visible_width_loop([_C | Rest], Acc, normal) -> + visible_width_loop(Rest, Acc + 1, normal); +visible_width_loop([$[ | Rest], Acc, esc) -> + visible_width_loop(Rest, Acc, csi); +visible_width_loop([_ | Rest], Acc, esc) -> + visible_width_loop(Rest, Acc, normal); +visible_width_loop([C | Rest], Acc, csi) when C >= 16#40, C =< 16#7e -> + visible_width_loop(Rest, Acc, normal); +visible_width_loop([_ | Rest], Acc, csi) -> + visible_width_loop(Rest, Acc, csi). + highlight_line(Bin) when is_binary(Bin) -> case get(gleshell_color) of false -> @@ -1206,13 +1319,12 @@ load_line_history() -> File = history_file(), case file:read_file(File) of {ok, Bin} -> - Lines = [ - L - || L <- binary:split(Bin, <<"\n">>, [global]), - L =/= <<>> + Lines0 = [ + string:trim(L, trailing, [$\r]) + || L <- binary:split(Bin, <<"\n">>, [global]) ], - %% Newest first - put(gleshell_history, lists:reverse(Lines)); + %% Newest first; sanitize drops blanks / ANSI-only / ZWSP-only. + put(gleshell_history, sanitize_history(lists:reverse(Lines0))); _ -> put(gleshell_history, []) end, @@ -1224,26 +1336,101 @@ save_line_history() -> Hist when is_list(Hist) -> File = history_file(), _ = filelib:ensure_dir(File), - %% Store oldest-first for human readability - Body = [[L, $\n] || L <- lists:reverse(lists:sublist(Hist, ?HISTORY_MAX))], + %% Store oldest-first for human readability; drop blanks. + Kept = lists:sublist(sanitize_history(Hist), ?HISTORY_MAX), + Body = [[L, $\n] || L <- lists:reverse(Kept)], _ = file:write_file(File, Body), ok; _ -> ok end. -push_history(<<>>) -> - ok; +%% Drop blank entries; keep order (newest first). +sanitize_history(Hist) when is_list(Hist) -> + [L || L <- Hist, not history_blank(L)]; +sanitize_history(_) -> + []. + +%% Empty / whitespace-only / ANSI-only / zero-width-only lines look blank when +%% recalled with ↑ — never store or navigate to them. +history_blank(Bin) when is_binary(Bin) -> + history_blank_visible(string:trim(Bin)); +history_blank(List) when is_list(List) -> + case unicode:characters_to_binary(List) of + Bin when is_binary(Bin) -> + history_blank_visible(string:trim(Bin)); + _ -> + true + end; +history_blank(_) -> + true. + +history_blank_visible(Bin) when is_binary(Bin) -> + case strip_ansi_bin(Bin) of + <<>> -> + true; + Visible -> + case unicode:characters_to_list(Visible) of + List when is_list(List), List =/= [] -> + %% ZWSP / ZWNJ / ZWJ / BOM / soft hyphen — render as empty. + lists:all( + fun(C) -> + C =:= 16#200B orelse C =:= 16#200C orelse + C =:= 16#200D orelse C =:= 16#FEFF orelse + C =:= 16#00AD + end, + List + ); + _ -> + false + end + end. + +%% Strip CSI sequences (ESC [ … final) for blank detection. +strip_ansi_bin(Bin) when is_binary(Bin) -> + case unicode:characters_to_list(Bin) of + List when is_list(List) -> + unicode:characters_to_binary(strip_ansi_list(List, normal, [])); + _ -> + Bin + end. + +strip_ansi_list([], _State, Acc) -> + lists:reverse(Acc); +strip_ansi_list([16#1b | Rest], normal, Acc) -> + strip_ansi_list(Rest, esc, Acc); +strip_ansi_list([C | Rest], normal, Acc) -> + strip_ansi_list(Rest, normal, [C | Acc]); +strip_ansi_list([$[ | Rest], esc, Acc) -> + strip_ansi_list(Rest, csi, Acc); +strip_ansi_list([_ | Rest], esc, Acc) -> + strip_ansi_list(Rest, normal, Acc); +strip_ansi_list([C | Rest], csi, Acc) when C >= 16#40, C =< 16#7e -> + strip_ansi_list(Rest, normal, Acc); +strip_ansi_list([_ | Rest], csi, Acc) -> + strip_ansi_list(Rest, csi, Acc). + push_history(Line) when is_binary(Line) -> - Hist = case get(gleshell_history) of - L when is_list(L) -> L; - _ -> [] - end, - New = case Hist of - [Line | _] -> Hist; - _ -> [Line | Hist] - end, - put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)), + %% Trim so " ls " does not become a near-blank distinct from "ls". + Trimmed = string:trim(Line), + case history_blank(Trimmed) of + true -> + ok; + false -> + Hist = sanitize_history( + case get(gleshell_history) of + L when is_list(L) -> L; + _ -> [] + end + ), + New = case Hist of + [Trimmed | _] -> Hist; + _ -> [Trimmed | Hist] + end, + put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)), + ok + end; +push_history(_) -> ok. %% --------------------------------------------------------------------------- diff --git a/test/gleshell_test.gleam b/test/gleshell_test.gleam index f94fa4e..b2f82fb 100644 --- a/test/gleshell_test.gleam +++ b/test/gleshell_test.gleam @@ -11,8 +11,10 @@ import gleshell/highlight import gleshell/lexer import gleshell/pager import gleshell/parser +import gleshell/syntax import gleshell/sys import gleshell/value.{Bool, Int, List, Nothing, Record, String, Table} +import simplifile pub fn main() -> Nil { gleeunit.main() @@ -101,6 +103,34 @@ pub fn parse_ls_dotfile_test() { Nil } +pub fn parse_port_spec_arg_test() { + // `lsof -i :4004` — colon is a record token but must be a bare argv word here. + let assert Ok(parser.Expr(parser.Pipeline([ + parser.Command("lsof", args, False), + ]))) = parser.parse("lsof -i :4004") + let assert [ + parser.FlagArg("i", parser.None), + parser.ValueArg(parser.Lit(String(":4004"))), + ] = args + // No space: `lsof -i:4004` + let assert Ok(parser.Expr(parser.Pipeline([ + parser.Command("lsof", args2, False), + ]))) = parser.parse("lsof -i:4004") + let assert [ + parser.FlagArg("i", parser.None), + parser.ValueArg(parser.Lit(String(":4004"))), + ] = args2 + // Glued host:port and URL-shaped words + let assert Ok(parser.Expr(parser.Pipeline([ + parser.Command("echo", args3, False), + ]))) = parser.parse("echo host:4004 http://example.com") + let assert [ + parser.ValueArg(parser.Lit(String("host:4004"))), + parser.ValueArg(parser.Lit(String("http://example.com"))), + ] = args3 + Nil +} + // --- parser --- pub fn parse_pipeline_test() { @@ -299,13 +329,17 @@ pub fn help_covers_all_builtins_test() { let assert True = string.contains(help_out, "table") let assert True = string.contains(help_out, "coerce") - // Parent commands with subcommands: `help to` / `help from` must resolve. + // Parent commands with subcommands: `help to` / `help from` / `help http`. let assert eval.Continue(_, String(to_help)) = eval.eval_source(env, "help to") let assert True = string.contains(to_help, "json") let assert eval.Continue(_, String(from_help)) = eval.eval_source(env, "help from") let assert True = string.contains(from_help, "json") + let assert eval.Continue(_, String(http_help)) = + eval.eval_source(env, "help http") + let assert True = string.contains(http_help, "get") + let assert True = string.contains(http_help, "post") // Bare help lists every command with its one-line description. let assert eval.Continue(_, String(all)) = eval.eval_source(env, "help") @@ -359,6 +393,113 @@ pub fn eval_to_json_record_test() { Nil } +pub fn http_subcommand_errors_test() { + let env = env.new() + // Missing subcommand + let assert eval.Continue(env2, value.Fail(msg)) = + eval.eval_source(env, "http") + let assert True = string.contains(msg, "subcommand") + let assert 1 = env2.last_exit + // Unknown subcommand + let assert eval.Continue(_, value.Fail(msg2)) = + eval.eval_source(env, "http foo") + let assert True = string.contains(msg2, "unknown subcommand") + // Missing URL + let assert eval.Continue(_, value.Fail(msg3)) = + eval.eval_source(env, "http get") + let assert True = string.contains(msg3, "URL") || string.contains(msg3, "url") + // Invalid URL + let assert eval.Continue(_, value.Fail(msg4)) = + eval.eval_source(env, "http get not-a-url") + let assert True = string.contains(msg4, "invalid URL") + // which / help + let assert eval.Continue(_, String(which_out)) = + eval.eval_source(env, "which http") + let assert "builtin: http" = which_out + Nil +} + +pub fn http_get_live_test() { + // Live request against postman-echo (JSON). Skip gracefully if offline. + let env = env.new() + case eval.eval_source(env, "http get --full https://postman-echo.com/get") { + eval.Continue(_, Record(fields)) -> { + let assert True = list_has_field(fields, "status", Int(200)) + let assert True = list_has_key(fields, "body") + let assert True = list_has_key(fields, "headers") + let assert True = + list_has_field(fields, "url", String("https://postman-echo.com/get")) + // Default path (no --full) parses JSON body into a record + let assert eval.Continue(_, Record(body_fields)) = + eval.eval_source(env, "http get https://postman-echo.com/get") + let assert True = list_has_key(body_fields, "url") + Nil + } + eval.Continue(_, value.Fail(msg)) -> { + // Network unavailable — still assert the error is from http, not parse + let assert True = + string.contains(msg, "http:") || string.contains(msg, "failed") + Nil + } + _ -> panic as "http get --full: unexpected eval result" + } +} + +pub fn http_post_json_live_test() { + let env = env.new() + case + eval.eval_source( + env, + "http post --full https://postman-echo.com/post {name: gleshell}", + ) + { + eval.Continue(_, Record(fields)) -> { + let assert True = list_has_field(fields, "status", Int(200)) + // JSON body should be parsed; postman-echo echoes under `json` + case list_find_field(fields, "body") { + Ok(Record(body)) -> { + case list_find_field(body, "json") { + Ok(Record(json_fields)) -> { + let assert True = + list_has_field(json_fields, "name", String("gleshell")) + Nil + } + _ -> Nil + } + } + _ -> Nil + } + } + eval.Continue(_, value.Fail(_)) -> Nil + _ -> panic as "http post --full: unexpected eval result" + } +} + +fn list_has_key(fields: List(#(String, value.Value)), key: String) -> Bool { + case fields { + [] -> False + [#(k, _), ..rest] -> + case k == key { + True -> True + False -> list_has_key(rest, key) + } + } +} + +fn list_find_field( + fields: List(#(String, value.Value)), + key: String, +) -> Result(value.Value, Nil) { + case fields { + [] -> Error(Nil) + [#(k, v), ..rest] -> + case k == key { + True -> Ok(v) + False -> list_find_field(rest, key) + } + } +} + fn list_has_field( fields: List(#(String, value.Value)), key: String, @@ -864,6 +1005,137 @@ fn string_contains(haystack: String, needle: String) -> Bool { } } +// --- file syntax (cat highlighters) --- + +pub fn syntax_language_from_path_test() { + let assert syntax.Json = syntax.language_from_path("data/foo.json") + let assert syntax.Gleam = syntax.language_from_path("src/main.gleam") + let assert syntax.Toml = syntax.language_from_path("gleam.toml") + let assert syntax.Markdown = syntax.language_from_path("README.md") + let assert syntax.Plain = syntax.language_from_path("notes.txt") + let assert syntax.Plain = syntax.language_from_path("Makefile") + Nil +} + +pub fn syntax_detect_sniff_json_test() { + let assert syntax.Json = syntax.detect("data", "{\"a\": 1}") + let assert syntax.Markdown = syntax.detect("notes", "# Title\n\nbody") + let assert syntax.Plain = syntax.detect("x", "just words") + // Extension wins over sniff + let assert syntax.Toml = syntax.detect("x.toml", "{\"a\": 1}") + Nil +} + +pub fn syntax_is_binary_test() { + let assert False = syntax.is_binary("hello\nworld\t!") + let assert True = syntax.is_binary("a\u{0000}b") + Nil +} + +pub fn syntax_paint_json_test() { + let src = "{\"n\": 1, \"msg\": \"hi\", \"ok\": true}" + let assert True = syntax.paint(False, syntax.Json, src) == src + let painted = syntax.paint(True, syntax.Json, src) + // Truecolor roles: keys (sky), string values (green), numbers, bools + let assert True = string_contains(painted, "38;2") + let assert True = string_contains(painted, "true") + let assert True = string_contains(painted, "137;220;235") + let assert True = string_contains(painted, "166;227;161") + let assert True = string_contains(painted, "250;179;135") + let assert True = color.strip_ansi(painted) == src + Nil +} + +pub fn syntax_paint_gleam_test() { + let src = "pub fn main() {\n // hi\n 42\n}" + let painted = syntax.paint(True, syntax.Gleam, src) + // keyword mauve, fn name blue, comment italic, number peach + let assert True = string_contains(painted, "203;166;247") + let assert True = string_contains(painted, "137;180;250") + let assert True = string_contains(painted, "108;112;134") + let assert True = string_contains(painted, "250;179;135") + let assert True = string_contains(painted, "pub") + let assert True = string_contains(painted, "main") + let assert True = color.strip_ansi(painted) == src + Nil +} + +pub fn syntax_paint_toml_test() { + let src = "name = \"gleshell\"\n# comment\nenabled = true" + let painted = syntax.paint(True, syntax.Toml, src) + let assert True = string_contains(painted, "38;2") + let assert True = string_contains(painted, "166;227;161") + let assert True = string_contains(painted, "108;112;134") + let assert True = color.strip_ansi(painted) == src + Nil +} + +pub fn syntax_paint_markdown_test() { + let src = "# Title\n\nUse `code` and **bold**.\n" + let painted = syntax.paint(True, syntax.Markdown, src) + let assert True = string_contains(painted, "Title") + let assert True = string_contains(painted, "code") + // H1 lavender + bold peach for **bold** + let assert True = string_contains(painted, "180;190;254") + let assert True = string_contains(painted, "250;179;135") + let assert True = color.visible_length(painted) + >= color.visible_length(src) - 1 + Nil +} + +pub fn syntax_frame_gutter_test() { + let body = "alpha\nbeta" + let framed = syntax.frame("src/demo.gleam", syntax.Gleam, body) + let stripped = color.strip_ansi(framed) + // Header carries basename + language badge + let assert True = string_contains(stripped, "demo.gleam") + let assert True = string_contains(stripped, "gleam") + // Line numbers + pipe gutter + let assert True = string_contains(stripped, "1") + let assert True = string_contains(stripped, "2") + let assert True = string_contains(stripped, "│") + let assert True = string_contains(stripped, "alpha") + let assert True = string_contains(stripped, "beta") + Nil +} + +pub fn cat_raw_and_language_test() { + let env = env.new() + // Write a temp json file under the project (simplifile needs a real path). + let path = "build/cat_syntax_test.json" + let body = "{\"x\": 1}" + let assert Ok(Nil) = simplifile.write(to: path, contents: body) + + // --raw must return plain content even when colors would be on. + let assert eval.Continue(_, String(raw_out)) = + eval.eval_source(env, "cat " <> path <> " --raw") + let assert True = raw_out == body + + // --language plain: no syntax colors (may still frame with line numbers on TTY). + let assert eval.Continue(_, String(plain_out)) = + eval.eval_source(env, "cat " <> path <> " --language plain") + let plain_stripped = color.strip_ansi(plain_out) + let assert True = + plain_out == body + || plain_stripped == body + || string_contains(plain_stripped, body) + + // Forced gleam: visible body still present (gutter/header OK). + let assert eval.Continue(_, String(gleam_out)) = + eval.eval_source(env, "cat " <> path <> " --language gleam") + let gleam_stripped = color.strip_ansi(gleam_out) + let assert True = + gleam_stripped == body || string_contains(gleam_stripped, body) + + // Unknown language errors. + let assert eval.Continue(_, value.Fail(msg)) = + eval.eval_source(env, "cat " <> path <> " --language cobol") + let assert True = string.contains(msg, "unknown language") + + let _ = simplifile.delete(path) + Nil +} + // --- tab completion --- pub fn complete_command_builtin_test() {