A structured-data shell in Gleam, inspired by Nushell
0

Configure Feed

Select the types of activity you want to include in your feed.

Add HTTP client builtin and syntax-colored cat.

Introduce Nushell-style `http get|post|put|delete|patch|head` with JSON
bodies/responses, headers, and --full/--raw/--allow-errors. Add language
detection and TTY syntax highlighting for `cat` (json, gleam, toml, markdown),
plus tests and docs.

author
nandi
date (Jul 25, 2026, 8:35 PM -0700) commit 6d8622b4 parent e8c80bbb change-id muqnmtpz
+2147 -63
+9 -3
README.md
··· 6 6 7 7 ```text 8 8 ~/code/gleshell on  main 9 -  ❯ ls | where type == file | select name size | first 5 9 +  ls | where type == file | select name size | first 5 10 10 ╭──────┬──────╮ 11 11 │ name │ size │ 12 12 ├──────┼──────┤ ··· 16 16 17 17 The interactive prompt is zero-config and Starship-inspired: full path with `~`, 18 18 optional git branch (read from `.git`, no external tools), and a Nerd Font shell 19 - icon (``) before the `❯` character (which turns red after a non-zero exit). 19 + icon (``) as the prompt character (turns red after a non-zero exit). 20 20 Install a Nerd Font so the glyphs render (flake: `nix profile install 21 21 .#nerd-fonts`, or put `nerd-fonts.symbols-only` in your system/fonts packages; 22 22 the devenv shell already includes it). ··· 132 132 133 133 ## Built-ins 134 134 135 - Filesystem: `ls`, `cd`, `pwd`, `cat`, `open`, `save` 135 + Filesystem: `ls`, `cd`, `pwd`, `cat` (MIME/extension detect + syntax color on TTY; 136 + `--raw` for plain pipelines), `open`, `save` 136 137 137 138 Table/list: `where`/`filter`, `find`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` 138 139 139 140 Data: `echo`, `range`, `lines`, `to`/`from` (subcommand `json`), `type`, `describe`, `env`, `sys`, `which`, `help`, `about`, `exit` 141 + 142 + HTTP: `http get|post|put|delete|patch|head` — fetch/send with structured JSON bodies 143 + and responses (`http get https://example.com`, `http post URL {a: 1}`, `--full`, 144 + `-H` headers, `--raw`, `--allow-errors`) 140 145 141 146 Pager: `less` — builtin color-aware pager for pipeline input or files (`ls | less`, 142 147 `less README.md`). ANSI from tables and external tools is kept; short output is ··· 161 166 display.gleam # table pretty-printer 162 167 color.gleam # Nushell-style ANSI colors 163 168 highlight.gleam # live input syntax highlighting 169 + syntax.gleam # file language detect + cat highlighters 164 170 env.gleam / sys.gleam 165 171 ``` 166 172
+2
gleam.toml
··· 9 9 simplifile = ">= 2.6.0 and < 3.0.0" 10 10 gleam_json = ">= 3.1.0 and < 4.0.0" 11 11 filepath = ">= 1.1.2 and < 2.0.0" 12 + gleam_http = ">= 4.3.0 and < 5.0.0" 13 + gleam_httpc = ">= 5.0.0 and < 6.0.0" 12 14 13 15 [dev_dependencies] 14 16 gleeunit = ">= 1.0.0 and < 2.0.0"
+5
manifest.toml
··· 9 9 packages = [ 10 10 { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, 11 11 { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 12 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 13 + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 14 + { 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" }, 12 15 { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 13 16 { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, 14 17 { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, ··· 18 21 [requirements] 19 22 argv = { version = ">= 1.1.0 and < 2.0.0" } 20 23 filepath = { version = ">= 1.1.2 and < 2.0.0" } 24 + gleam_http = { version = ">= 4.3.0 and < 5.0.0" } 25 + gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } 21 26 gleam_json = { version = ">= 3.1.0 and < 4.0.0" } 22 27 gleam_stdlib = { version = ">= 1.0.3 and < 2.0.0" } 23 28 gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+10 -8
src/gleshell.gleam
··· 109 109 } 110 110 111 111 /// Zero-config prompt inspired by Starship: 112 - /// blank line, directory (+ optional git branch), then Nerd Font shell icon 113 - /// + green/red `❯`. 112 + /// blank line, directory (+ optional git branch), then green/red Nerd Font 113 + /// shell icon as the prompt character. 114 114 /// 115 115 /// Status lines are printed once; only the character is the line-editor prompt 116 116 /// (the raw editor redraws a single line). ··· 133 133 134 134 fn prompt_character(last_exit: Int) -> String { 135 135 let on = color.enabled() 136 - // Cute Nerd Font marker so you always know you're in gleshell. 137 - let icon = color.prompt_name(on, "") 138 - let mark = case last_exit { 139 - 0 -> color.prompt_character_ok(on, "❯") 140 - _ -> color.prompt_character_err(on, "❯") 136 + // Nerd Font terminal icon; green after success, red after non-zero exit. 137 + // Spaces go *after* the color reset. PUA glyphs (nf-oct-terminal) are often 138 + // drawn ~2 cells wide while the terminal advances only 1, so a single space 139 + // sits under the icon ink and looks missing — use two. 140 + let icon = case last_exit { 141 + 0 -> color.prompt_character_ok(on, "") 142 + _ -> color.prompt_character_err(on, "") 141 143 } 142 - icon <> " " <> mark <> " " 144 + icon <> " " 143 145 } 144 146 145 147 /// Full cwd for the prompt, with `$HOME` shown as `~`.
+563 -6
src/gleshell/builtins.gleam
··· 1 1 //// Built-in commands (Nushell-inspired structured data tools). 2 2 3 3 import filepath 4 + import gleam/bit_array 4 5 import gleam/dict 5 6 import gleam/dynamic/decode 6 7 import gleam/float 8 + import gleam/http 9 + import gleam/http/request 10 + import gleam/http/response 11 + import gleam/httpc 7 12 import gleam/int 8 13 import gleam/json 9 14 import gleam/list 10 15 import gleam/option 11 16 import gleam/order 12 17 import gleam/string 18 + import gleshell/color 13 19 import gleshell/display 14 20 import gleshell/env.{type Env} 15 21 import gleshell/pager 22 + import gleshell/syntax 16 23 import gleshell/sys 17 24 import gleshell/value.{ 18 25 type Value, Bool, Fail, Float, Int, List, Nothing, Record, String, Table, ··· 58 65 // Nushell-style: `to` / `from` with format subcommands (`json`) 59 66 #("to", cmd_to), 60 67 #("from", cmd_from), 68 + // Nushell-style: `http get|post|put|delete|patch|head` 69 + #("http", cmd_http), 61 70 #("lines", cmd_lines), 62 71 #("typeof", cmd_type), 63 72 #("type", cmd_type), ··· 194 203 ], 195 204 "\n", 196 205 )) 206 + "http" -> Ok(http_help_text()) 207 + "cat" -> 208 + Ok(string.join( 209 + [ 210 + "cat <path> — read a text file as a string", 211 + "", 212 + "On a color TTY: truecolor syntax highlight (json, gleam, toml,", 213 + "markdown), bat-style line numbers, and a filename header.", 214 + "Detection uses the extension, then a light content sniff.", 215 + "Binary files are refused.", 216 + "", 217 + "Flags:", 218 + " -r, --raw plain text (no colors; safe for pipelines)", 219 + " -l, --language <id> force language (json|gleam|toml|markdown|plain)", 220 + "", 221 + "Examples:", 222 + " cat README.md", 223 + " cat src/gleshell.gleam", 224 + " cat data.json --raw | from json", 225 + " cat notes.txt --language markdown", 226 + ], 227 + "\n", 228 + )) 197 229 "find" -> 198 230 Ok(string.join( 199 231 [ ··· 255 287 #("ls", "ls [path] — list directory entries as a table"), 256 288 #("pwd", "pwd — print working directory"), 257 289 #("cd", "cd [path] — change directory (~ supported)"), 258 - #("cat", "cat <path> — read file as string"), 290 + #( 291 + "cat", 292 + "cat <path> [--raw] [--language <id>] — read file; syntax-color on TTY", 293 + ), 259 294 #("open", "open <path> — open file; parses .json into structured data"), 260 295 #("save", "save <path> — save pipeline input to a file"), 261 296 #( ··· 288 323 #( 289 324 "from", 290 325 "from <format> — parse structured input (subcommands: json)", 326 + ), 327 + #( 328 + "http", 329 + "http <get|post|put|delete|patch|head> <url> [body] — HTTP client", 291 330 ), 292 331 #("lines", "lines — split string input into a list of lines"), 293 332 #("typeof", "typeof — type name of pipeline input"), ··· 453 492 env: Env, 454 493 _input: Value, 455 494 args: List(Value), 456 - _flags: dict.Dict(String, Value), 495 + flags: dict.Dict(String, Value), 457 496 ) -> BuiltinResult { 458 - case args { 459 - [String(path)] -> { 497 + // Boolean flags may steal the next word (`cat --raw path` → flag raw = path). 498 + let #(raw, stolen_r) = find_bool_flag(flags, ["raw", "r"]) 499 + let lang_opt = cat_language_flag(flags) 500 + let path_candidates = list.append(args, stolen_r) 501 + case lang_opt, path_candidates { 502 + Error(msg), _ -> err(env, "cat: " <> msg) 503 + Ok(lang_override), [String(path)] -> { 460 504 let path = resolve_path(env, path) 461 505 case simplifile.read(path) { 462 - Ok(content) -> ok(env, String(content)) 463 506 Error(e) -> err(env, "cat: " <> simplifile.describe_error(e)) 507 + Ok(content) -> 508 + case syntax.is_binary(content) { 509 + True -> 510 + err( 511 + env, 512 + "cat: binary file (refusing to print; use an external tool)", 513 + ) 514 + False -> { 515 + let language = case lang_override { 516 + option.Some(lang) -> lang 517 + option.None -> syntax.detect(path, content) 518 + } 519 + let painted = case raw { 520 + True -> content 521 + False -> 522 + syntax.present(color.enabled(), language, path, content) 523 + } 524 + ok(env, String(painted)) 525 + } 526 + } 464 527 } 465 528 } 466 - _ -> err(env, "cat: expected path") 529 + Ok(_), _ -> 530 + err( 531 + env, 532 + "cat: expected path (try `cat <path>`; --raw / --language <id> optional)", 533 + ) 534 + } 535 + } 536 + 537 + /// `--language` / `-l` override. `Error` is a user-facing message. 538 + fn cat_language_flag( 539 + flags: dict.Dict(String, Value), 540 + ) -> Result(option.Option(syntax.Language), String) { 541 + case dict.get(flags, "language"), dict.get(flags, "l") { 542 + Ok(v), _ -> cat_parse_language_value(v) 543 + _, Ok(v) -> cat_parse_language_value(v) 544 + Error(Nil), Error(Nil) -> Ok(option.None) 545 + } 546 + } 547 + 548 + fn cat_parse_language_value( 549 + v: Value, 550 + ) -> Result(option.Option(syntax.Language), String) { 551 + case v { 552 + Bool(True) | Nothing -> 553 + Error("language flag requires a name (json, gleam, toml, markdown, plain)") 554 + other -> { 555 + let name = value.as_string(other) 556 + case syntax.language_from_name(name) { 557 + Ok(lang) -> Ok(option.Some(lang)) 558 + Error(Nil) -> 559 + Error( 560 + "unknown language `" 561 + <> name 562 + <> "` (try json, gleam, toml, markdown, plain)", 563 + ) 564 + } 565 + } 467 566 } 468 567 } 469 568 ··· 1272 1371 Ok(v) -> ok(env, v) 1273 1372 Error(msg) -> err(env, "from: json: " <> msg) 1274 1373 } 1374 + } 1375 + } 1376 + 1377 + // --- http (Nushell-style HTTP client with method subcommands) --- 1378 + 1379 + fn http_help_text() -> String { 1380 + string.join( 1381 + [ 1382 + "http <method> <url> [body] — make an HTTP request", 1383 + "", 1384 + "Subcommands:", 1385 + " get <url> GET request", 1386 + " post <url> [body] POST (body from arg or pipeline input)", 1387 + " put <url> [body] PUT", 1388 + " delete <url> [body] DELETE", 1389 + " patch <url> [body] PATCH", 1390 + " head <url> HEAD (headers only)", 1391 + "", 1392 + "Flags:", 1393 + " -H, --headers <record|string> request headers (record or \"Name: value\")", 1394 + " -t, --content-type <type> Content-Type for the body", 1395 + " -u, --user <name> basic-auth username", 1396 + " -p, --password <pass> basic-auth password", 1397 + " -m, --max-time <secs> response timeout in seconds (default 30)", 1398 + " -k, --insecure skip TLS certificate verification", 1399 + " -r, --raw keep body as text (do not parse JSON)", 1400 + " -f, --full return {status, headers, body, url}", 1401 + " -e, --allow-errors do not fail on non-2xx status", 1402 + "", 1403 + "JSON responses are parsed into structured data unless --raw is set.", 1404 + "Structured request bodies (records/lists/tables) are JSON-encoded and", 1405 + "sent with Content-Type: application/json when no type is specified.", 1406 + "", 1407 + "Examples:", 1408 + " http get https://example.com", 1409 + " http get --full https://httpbin.org/get", 1410 + " http post https://httpbin.org/post {name: alice}", 1411 + " http get -H {accept: application/json} https://api.example.com/v1", 1412 + " echo {x: 1} | http post https://httpbin.org/post", 1413 + ], 1414 + "\n", 1415 + ) 1416 + } 1417 + 1418 + fn cmd_http( 1419 + env: Env, 1420 + input: Value, 1421 + args: List(Value), 1422 + flags: dict.Dict(String, Value), 1423 + ) -> BuiltinResult { 1424 + case args { 1425 + [] -> 1426 + err( 1427 + env, 1428 + "http: expected subcommand (try `http get <url>`; see `help http`)", 1429 + ) 1430 + [String(sub), ..rest] -> 1431 + case http_method_from_sub(sub) { 1432 + Ok(method) -> http_request(env, input, method, rest, flags) 1433 + Error(Nil) -> err(env, "http: unknown subcommand: " <> sub) 1434 + } 1435 + _ -> err(env, "http: expected subcommand name") 1436 + } 1437 + } 1438 + 1439 + fn http_method_from_sub(sub: String) -> Result(http.Method, Nil) { 1440 + case string.lowercase(sub) { 1441 + "get" -> Ok(http.Get) 1442 + "post" -> Ok(http.Post) 1443 + "put" -> Ok(http.Put) 1444 + "delete" -> Ok(http.Delete) 1445 + "patch" -> Ok(http.Patch) 1446 + "head" -> Ok(http.Head) 1447 + _ -> Error(Nil) 1448 + } 1449 + } 1450 + 1451 + fn http_request( 1452 + env: Env, 1453 + input: Value, 1454 + method: http.Method, 1455 + args: List(Value), 1456 + flags: dict.Dict(String, Value), 1457 + ) -> BuiltinResult { 1458 + let method_name = http.method_to_string(method) 1459 + // Boolean flags may steal the next word as their value 1460 + // (`http get --full https://…` → flag full = "https://…"). 1461 + let #(_full, stolen_f) = find_bool_flag(flags, ["full", "f"]) 1462 + let #(_raw, stolen_r) = find_bool_flag(flags, ["raw", "r"]) 1463 + let #(_insecure, stolen_k) = find_bool_flag(flags, ["insecure", "k"]) 1464 + let #(_allow, stolen_e) = find_bool_flag(flags, ["allow-errors", "e"]) 1465 + let candidates = 1466 + list.flatten([args, stolen_f, stolen_r, stolen_k, stolen_e]) 1467 + case http_take_url(candidates) { 1468 + Error(Nil) -> err(env, "http: " <> method_name <> ": expected URL") 1469 + Ok(#(url, body_args)) -> 1470 + case string.trim(url) { 1471 + "" -> err(env, "http: " <> method_name <> ": empty URL") 1472 + url -> 1473 + case request.to(url) { 1474 + Error(Nil) -> 1475 + err(env, "http: " <> method_name <> ": invalid URL: " <> url) 1476 + Ok(base) -> { 1477 + let body_opt = http_resolve_body(method, input, body_args) 1478 + let #(body_text, auto_json) = case body_opt { 1479 + option.None -> #("", False) 1480 + option.Some(body) -> http_encode_body(body) 1481 + } 1482 + let req = 1483 + base 1484 + |> request.set_method(method) 1485 + |> request.set_body(body_text) 1486 + |> http_apply_headers(flags, auto_json) 1487 + |> http_apply_auth(flags) 1488 + let config = http_config(flags) 1489 + case httpc.dispatch(config, req) { 1490 + Error(e) -> 1491 + err( 1492 + env, 1493 + "http: " <> method_name <> ": " <> http_error_message(e), 1494 + ) 1495 + Ok(resp) -> 1496 + http_handle_response(env, method_name, url, resp, flags) 1497 + } 1498 + } 1499 + } 1500 + } 1501 + } 1502 + } 1503 + 1504 + /// Pick a URL from mixed positionals + values stolen by boolean flags. 1505 + /// Prefers a URL-shaped string (`http(s)://…`); otherwise the first value. 1506 + fn http_take_url( 1507 + values: List(Value), 1508 + ) -> Result(#(String, List(Value)), Nil) { 1509 + case values { 1510 + [] -> Error(Nil) 1511 + _ -> 1512 + case http_find_url_index(values, 0) { 1513 + option.Some(i) -> { 1514 + let url = case list_at(values, i) { 1515 + Ok(v) -> value.as_string(v) 1516 + Error(Nil) -> "" 1517 + } 1518 + let rest = 1519 + values 1520 + |> list.index_map(fn(v, idx) { #(idx, v) }) 1521 + |> list.filter_map(fn(pair) { 1522 + let #(idx, v) = pair 1523 + case idx == i { 1524 + True -> Error(Nil) 1525 + False -> Ok(v) 1526 + } 1527 + }) 1528 + Ok(#(url, rest)) 1529 + } 1530 + option.None -> 1531 + case values { 1532 + [first, ..rest] -> Ok(#(value.as_string(first), rest)) 1533 + [] -> Error(Nil) 1534 + } 1535 + } 1536 + } 1537 + } 1538 + 1539 + fn http_find_url_index( 1540 + values: List(Value), 1541 + index: Int, 1542 + ) -> option.Option(Int) { 1543 + case values { 1544 + [] -> option.None 1545 + [v, ..rest] -> 1546 + case v { 1547 + String(s) -> 1548 + case http_looks_like_url(s) { 1549 + True -> option.Some(index) 1550 + False -> http_find_url_index(rest, index + 1) 1551 + } 1552 + _ -> http_find_url_index(rest, index + 1) 1553 + } 1554 + } 1555 + } 1556 + 1557 + fn http_looks_like_url(s: String) -> Bool { 1558 + string.starts_with(s, "http://") 1559 + || string.starts_with(s, "https://") 1560 + || string.contains(s, "://") 1561 + } 1562 + 1563 + fn http_resolve_body( 1564 + method: http.Method, 1565 + input: Value, 1566 + body_args: List(Value), 1567 + ) -> option.Option(Value) { 1568 + case method { 1569 + http.Get | http.Head -> option.None 1570 + _ -> 1571 + case body_args { 1572 + [body, ..] -> option.Some(body) 1573 + [] -> 1574 + case input { 1575 + Nothing -> option.None 1576 + Fail(_) -> option.None 1577 + _ -> option.Some(input) 1578 + } 1579 + } 1580 + } 1581 + } 1582 + 1583 + /// Encode a body value. Returns `(text, is_json_structured)`. 1584 + fn http_encode_body(body: Value) -> #(String, Bool) { 1585 + case body { 1586 + String(s) -> #(s, False) 1587 + Nothing -> #("", False) 1588 + other -> #(encode_json(other, option.None, 0), True) 1589 + } 1590 + } 1591 + 1592 + fn http_apply_headers( 1593 + req: request.Request(String), 1594 + flags: dict.Dict(String, Value), 1595 + auto_json: Bool, 1596 + ) -> request.Request(String) { 1597 + let req = case auto_json { 1598 + True -> 1599 + case http_flag_string(flags, ["content-type", "t"]) { 1600 + option.Some(_) -> req 1601 + option.None -> 1602 + request.set_header(req, "content-type", "application/json") 1603 + } 1604 + False -> req 1605 + } 1606 + let req = case http_flag_string(flags, ["content-type", "t"]) { 1607 + option.Some(ct) -> request.set_header(req, "content-type", ct) 1608 + option.None -> req 1609 + } 1610 + case http_flag_value(flags, ["headers", "H"]) { 1611 + option.None -> req 1612 + option.Some(headers_val) -> 1613 + list.fold(http_parse_headers(headers_val), req, fn(acc, pair) { 1614 + let #(k, v) = pair 1615 + request.set_header(acc, string.lowercase(k), v) 1616 + }) 1617 + } 1618 + } 1619 + 1620 + fn http_parse_headers(val: Value) -> List(#(String, String)) { 1621 + case val { 1622 + Record(fields) -> 1623 + list.map(fields, fn(pair) { 1624 + let #(k, v) = pair 1625 + #(k, value.as_string(v)) 1626 + }) 1627 + List(items) -> 1628 + list.flat_map(items, fn(item) { 1629 + case item { 1630 + String(s) -> http_parse_header_line(s) 1631 + Record(fields) -> 1632 + list.map(fields, fn(pair) { 1633 + let #(k, v) = pair 1634 + #(k, value.as_string(v)) 1635 + }) 1636 + _ -> [] 1637 + } 1638 + }) 1639 + String(s) -> http_parse_header_line(s) 1640 + _ -> [] 1641 + } 1642 + } 1643 + 1644 + fn http_parse_header_line(s: String) -> List(#(String, String)) { 1645 + case string.split_once(s, ":") { 1646 + Ok(#(name, rest)) -> [#(string.trim(name), string.trim(rest))] 1647 + Error(Nil) -> 1648 + case string.trim(s) { 1649 + "" -> [] 1650 + _ -> [#(string.trim(s), "")] 1651 + } 1652 + } 1653 + } 1654 + 1655 + fn http_apply_auth( 1656 + req: request.Request(String), 1657 + flags: dict.Dict(String, Value), 1658 + ) -> request.Request(String) { 1659 + case http_flag_string(flags, ["user", "u"]) { 1660 + option.None -> req 1661 + option.Some(user) -> { 1662 + let pass = case http_flag_string(flags, ["password", "p"]) { 1663 + option.Some(p) -> p 1664 + option.None -> "" 1665 + } 1666 + let token = 1667 + bit_array.base64_encode(bit_array.from_string(user <> ":" <> pass), True) 1668 + request.set_header(req, "authorization", "Basic " <> token) 1669 + } 1670 + } 1671 + } 1672 + 1673 + fn http_config(flags: dict.Dict(String, Value)) -> httpc.Configuration { 1674 + let insecure = 1675 + flag_set(flags, "insecure") || flag_set(flags, "k") 1676 + let timeout_ms = case http_flag_int(flags, ["max-time", "m"]) { 1677 + option.Some(secs) if secs > 0 -> secs * 1000 1678 + _ -> 30_000 1679 + } 1680 + httpc.configure() 1681 + |> httpc.verify_tls(!insecure) 1682 + |> httpc.timeout(timeout_ms) 1683 + |> httpc.follow_redirects(True) 1684 + } 1685 + 1686 + fn http_handle_response( 1687 + env: Env, 1688 + method_name: String, 1689 + url: String, 1690 + resp: response.Response(String), 1691 + flags: dict.Dict(String, Value), 1692 + ) -> BuiltinResult { 1693 + let raw = flag_set(flags, "raw") || flag_set(flags, "r") 1694 + let full = flag_set(flags, "full") || flag_set(flags, "f") 1695 + let allow_errors = 1696 + flag_set(flags, "allow-errors") || flag_set(flags, "e") 1697 + let body_val = case raw { 1698 + True -> String(resp.body) 1699 + False -> http_decode_body(resp) 1700 + } 1701 + let headers_record = 1702 + Record( 1703 + list.map(resp.headers, fn(pair) { 1704 + let #(k, v) = pair 1705 + #(k, String(v)) 1706 + }), 1707 + ) 1708 + let ok_status = resp.status >= 200 && resp.status < 300 1709 + case full { 1710 + True -> { 1711 + let result = 1712 + Record([ 1713 + #("status", Int(resp.status)), 1714 + #("headers", headers_record), 1715 + #("body", body_val), 1716 + #("url", String(url)), 1717 + ]) 1718 + case ok_status || allow_errors { 1719 + True -> ok(env, result) 1720 + False -> 1721 + err( 1722 + env, 1723 + "http: " 1724 + <> method_name 1725 + <> ": HTTP " 1726 + <> int.to_string(resp.status) 1727 + <> " from " 1728 + <> url, 1729 + ) 1730 + } 1731 + } 1732 + False -> 1733 + case ok_status || allow_errors { 1734 + True -> ok(env, body_val) 1735 + False -> 1736 + err( 1737 + env, 1738 + "http: " 1739 + <> method_name 1740 + <> ": HTTP " 1741 + <> int.to_string(resp.status) 1742 + <> " from " 1743 + <> url 1744 + <> case string.trim(resp.body) { 1745 + "" -> "" 1746 + body -> ": " <> string.slice(body, 0, 200) 1747 + }, 1748 + ) 1749 + } 1750 + } 1751 + } 1752 + 1753 + fn http_decode_body(resp: response.Response(String)) -> Value { 1754 + let looks_json = case response.get_header(resp, "content-type") { 1755 + Ok(ct) -> { 1756 + let lower = string.lowercase(ct) 1757 + string.contains(lower, "json") || string.contains(lower, "+json") 1758 + } 1759 + Error(Nil) -> False 1760 + } 1761 + let trimmed = string.trim(resp.body) 1762 + case looks_json || string.starts_with(trimmed, "{") || string.starts_with( 1763 + trimmed, 1764 + "[", 1765 + ) { 1766 + True -> 1767 + case parse_json_value(resp.body) { 1768 + Ok(v) -> v 1769 + Error(_) -> String(resp.body) 1770 + } 1771 + False -> String(resp.body) 1772 + } 1773 + } 1774 + 1775 + fn http_error_message(e: httpc.HttpError) -> String { 1776 + case e { 1777 + httpc.InvalidUtf8Response -> "response body is not valid UTF-8" 1778 + httpc.ResponseTimeout -> "response timed out" 1779 + httpc.FailedToConnect(ip4, ip6) -> 1780 + "failed to connect (" 1781 + <> http_connect_error(ip4) 1782 + <> " / " 1783 + <> http_connect_error(ip6) 1784 + <> ")" 1785 + } 1786 + } 1787 + 1788 + fn http_connect_error(e: httpc.ConnectError) -> String { 1789 + case e { 1790 + httpc.Posix(code) -> code 1791 + httpc.TlsAlert(code, detail) -> code <> ": " <> detail 1792 + } 1793 + } 1794 + 1795 + fn http_flag_value( 1796 + flags: dict.Dict(String, Value), 1797 + names: List(String), 1798 + ) -> option.Option(Value) { 1799 + case names { 1800 + [] -> option.None 1801 + [name, ..rest] -> 1802 + case dict.get(flags, name) { 1803 + Ok(v) -> option.Some(v) 1804 + Error(Nil) -> http_flag_value(flags, rest) 1805 + } 1806 + } 1807 + } 1808 + 1809 + fn http_flag_string( 1810 + flags: dict.Dict(String, Value), 1811 + names: List(String), 1812 + ) -> option.Option(String) { 1813 + case http_flag_value(flags, names) { 1814 + option.Some(String(s)) -> option.Some(s) 1815 + option.Some(v) -> option.Some(value.as_string(v)) 1816 + option.None -> option.None 1817 + } 1818 + } 1819 + 1820 + fn http_flag_int( 1821 + flags: dict.Dict(String, Value), 1822 + names: List(String), 1823 + ) -> option.Option(Int) { 1824 + case http_flag_value(flags, names) { 1825 + option.Some(Int(n)) -> option.Some(n) 1826 + option.Some(String(s)) -> 1827 + case int.parse(s) { 1828 + Ok(n) -> option.Some(n) 1829 + Error(Nil) -> option.None 1830 + } 1831 + _ -> option.None 1275 1832 } 1276 1833 } 1277 1834
+2 -2
src/gleshell/color.gleam
··· 187 187 paint(on, bold, text) 188 188 } 189 189 190 - /// Success character (bold green `❯`). 190 + /// Success prompt character (bold green). 191 191 pub fn prompt_character_ok(on: Bool, text: String) -> String { 192 192 paint(on, "\u{001b}[1;32m", text) 193 193 } 194 194 195 - /// Error character after non-zero exit (bold red `❯`). 195 + /// Error prompt character after non-zero exit (bold red). 196 196 pub fn prompt_character_err(on: Bool, text: String) -> String { 197 197 paint(on, bold_red, text) 198 198 }
+73 -2
src/gleshell/parser.gleam
··· 242 242 case is_expr_start(rest) { 243 243 True -> { 244 244 use #(expr, rest2) <- result_try(parse_expr(rest)) 245 - parse_args(rest2, [FlagArg(name, Some(expr)), ..acc]) 245 + use #(glued, rest3) <- result_try(glue_colon_suffix(expr, rest2)) 246 + parse_args(rest3, [FlagArg(name, Some(glued)), ..acc]) 246 247 } 247 248 False -> parse_args(rest, [FlagArg(name, None), ..acc]) 248 249 } ··· 256 257 [Le, ..rest] -> parse_args(rest, [ValueArg(Lit(value.String("<="))), ..acc]) 257 258 [Assign, ..rest] -> 258 259 parse_args(rest, [ValueArg(Lit(value.String("=="))), ..acc]) 260 + // Port specs / URL pieces: `:4004`, `://host` (Colon is reserved for records). 261 + [Colon, ..rest] -> { 262 + case parse_colon_atom(rest) { 263 + Ok(#(piece, rest2)) -> 264 + parse_args(rest2, [ValueArg(Lit(value.String(":" <> piece))), ..acc]) 265 + Error(_) -> 266 + parse_args(rest, [ValueArg(Lit(value.String(":"))), ..acc]) 267 + } 268 + } 259 269 _ -> { 260 270 case is_expr_start(tokens) { 261 271 True -> { 262 272 use #(expr, rest) <- result_try(parse_expr(tokens)) 263 - parse_args(rest, [ValueArg(expr), ..acc]) 273 + use #(glued, rest2) <- result_try(glue_colon_suffix(expr, rest)) 274 + parse_args(rest2, [ValueArg(glued), ..acc]) 264 275 } 265 276 False -> Ok(#(list.reverse(acc), tokens)) 266 277 } 267 278 } 279 + } 280 + } 281 + 282 + /// Absorb adjacent `:atom` tails into one bareword (`host:4004`, `http://x`). 283 + /// Colon is a separate lexer token (records need it), so argv words reassemble here. 284 + fn glue_colon_suffix( 285 + expr: Expr, 286 + tokens: List(Token), 287 + ) -> Result(#(Expr, List(Token)), ParseError) { 288 + case tokens { 289 + [Colon, ..rest] -> 290 + case is_simple_bare_lit(expr) { 291 + True -> { 292 + let head = expr_to_bare_string(expr) 293 + case parse_colon_atom(rest) { 294 + Ok(#(piece, rest2)) -> 295 + glue_colon_suffix( 296 + Lit(value.String(head <> ":" <> piece)), 297 + rest2, 298 + ) 299 + Error(_) -> Ok(#(Lit(value.String(head <> ":")), rest)) 300 + } 301 + } 302 + False -> Ok(#(expr, tokens)) 303 + } 304 + _ -> Ok(#(expr, tokens)) 305 + } 306 + } 307 + 308 + fn is_simple_bare_lit(expr: Expr) -> Bool { 309 + case expr { 310 + Lit(value.String(_)) 311 + | Lit(value.Int(_)) 312 + | Lit(value.Float(_)) 313 + | Lit(value.Bool(_)) -> True 314 + _ -> False 315 + } 316 + } 317 + 318 + fn expr_to_bare_string(expr: Expr) -> String { 319 + case expr { 320 + Lit(v) -> value.as_string(v) 321 + Var(name) -> "$" <> name 322 + ListExpr(_) -> "[]" 323 + RecordExpr(_) -> "{}" 324 + } 325 + } 326 + 327 + /// Token after `:` in a bareword: number, ident/path, string, or bool. 328 + fn parse_colon_atom( 329 + tokens: List(Token), 330 + ) -> Result(#(String, List(Token)), ParseError) { 331 + case tokens { 332 + [IntLit(n), ..rest] -> Ok(#(int.to_string(n), rest)) 333 + [FloatLit(f), ..rest] -> Ok(#(value.as_string(value.Float(f)), rest)) 334 + [StringLit(s), ..rest] -> Ok(#(s, rest)) 335 + [Ident(s), ..rest] -> Ok(#(s, rest)) 336 + [BoolLit(True), ..rest] -> Ok(#("true", rest)) 337 + [BoolLit(False), ..rest] -> Ok(#("false", rest)) 338 + _ -> Error(ParseError("expected word after :")) 268 339 } 269 340 } 270 341
+982
src/gleshell/syntax.gleam
··· 1 + //// File language detection and syntax highlighting for `cat`. 2 + //// 3 + //// Truecolor palette + bat-style gutters. Highlighters are lightweight pure 4 + //// Gleam — not a full syntect replacement; unknown languages stay plain text 5 + //// (still get line numbers when framed). 6 + 7 + import filepath 8 + import gleam/int 9 + import gleam/list 10 + import gleam/option.{type Option, None, Some} 11 + import gleam/string 12 + 13 + pub type Language { 14 + Plain 15 + Json 16 + Gleam 17 + Toml 18 + Markdown 19 + } 20 + 21 + // ============================================================================= 22 + // Catppuccin Mocha–inspired truecolor roles (file syntax only; REPL shapes stay 23 + // on the classic Nu 16-color palette in `color.gleam`). 24 + // ============================================================================= 25 + 26 + const reset = "\u{001b}[0m" 27 + 28 + /// Mauve — keywords 29 + const c_keyword = "\u{001b}[1;38;2;203;166;247m" 30 + 31 + /// Soft green — string values 32 + const c_string = "\u{001b}[38;2;166;227;161m" 33 + 34 + /// Sky — keys, attributes, accents 35 + const c_key = "\u{001b}[1;38;2;137;220;235m" 36 + 37 + /// Peach — numbers 38 + const c_number = "\u{001b}[38;2;250;179;135m" 39 + 40 + /// Teal — bools / null 41 + const c_bool = "\u{001b}[1;38;2;148;226;213m" 42 + 43 + /// Overlay0 italic — comments 44 + const c_comment = "\u{001b}[3;38;2;108;112;134m" 45 + 46 + /// Yellow — types / section headers 47 + const c_type = "\u{001b}[1;38;2;249;226;175m" 48 + 49 + /// Blue — function names 50 + const c_fn = "\u{001b}[1;38;2;137;180;250m" 51 + 52 + /// Pink — operators / punctuation pop 53 + const c_op = "\u{001b}[38;2;245;194;231m" 54 + 55 + /// Subtext0 — dim punctuation, gutters 56 + const c_dim = "\u{001b}[38;2;108;112;134m" 57 + 58 + /// Lavender — markdown H1 59 + const c_h1 = "\u{001b}[1;38;2;180;190;254m" 60 + 61 + /// Blue — markdown H2 62 + const c_h2 = "\u{001b}[1;38;2;137;180;250m" 63 + 64 + /// Sapphire — markdown H3+ 65 + const c_h3 = "\u{001b}[1;38;2;116;199;236m" 66 + 67 + /// Green dim bg for inline/fence code 68 + const c_code = "\u{001b}[38;2;166;227;161m" 69 + 70 + /// Surface0 background wash for fenced code lines 71 + const c_code_bg = "\u{001b}[48;2;49;50;68m" 72 + 73 + /// Peach bold — markdown bold 74 + const c_bold = "\u{001b}[1;38;2;250;179;135m" 75 + 76 + /// Italic soft — blockquotes 77 + const c_quote = "\u{001b}[3;38;2;148;226;213m" 78 + 79 + /// Underline sky — links 80 + const c_link = "\u{001b}[4;38;2;137;220;235m" 81 + 82 + /// Maroon — list bullets 83 + const c_bullet = "\u{001b}[1;38;2;235;160;172m" 84 + 85 + fn syn(code: String, text: String) -> String { 86 + code <> text <> reset 87 + } 88 + 89 + // ============================================================================= 90 + // Public API 91 + // ============================================================================= 92 + 93 + /// Parse a language name (`json`, `gleam`, `md`, …). 94 + pub fn language_from_name(name: String) -> Result(Language, Nil) { 95 + case string.lowercase(name) { 96 + "plain" | "text" | "txt" -> Ok(Plain) 97 + "json" -> Ok(Json) 98 + "gleam" -> Ok(Gleam) 99 + "toml" -> Ok(Toml) 100 + "md" | "markdown" -> Ok(Markdown) 101 + _ -> Error(Nil) 102 + } 103 + } 104 + 105 + pub fn language_name(lang: Language) -> String { 106 + case lang { 107 + Plain -> "plain" 108 + Json -> "json" 109 + Gleam -> "gleam" 110 + Toml -> "toml" 111 + Markdown -> "markdown" 112 + } 113 + } 114 + 115 + /// Guess language from a file path (extension only). 116 + pub fn language_from_path(path: String) -> Language { 117 + case filepath.extension(path) { 118 + Ok(ext) -> 119 + case language_from_name(ext) { 120 + Ok(lang) -> lang 121 + Error(Nil) -> Plain 122 + } 123 + Error(Nil) -> Plain 124 + } 125 + } 126 + 127 + /// Refine a path-based guess with a light content sniff. 128 + pub fn detect(path: String, content: String) -> Language { 129 + case language_from_path(path) { 130 + Plain -> sniff_plain(content) 131 + other -> other 132 + } 133 + } 134 + 135 + fn sniff_plain(content: String) -> Language { 136 + let trimmed = string.trim_start(content) 137 + case string.first(trimmed) { 138 + Ok("{") | Ok("[") -> Json 139 + _ -> 140 + case string.starts_with(trimmed, "---") { 141 + True -> Markdown 142 + False -> 143 + case 144 + string.starts_with(trimmed, "# ") 145 + || string.starts_with(trimmed, "## ") 146 + { 147 + True -> Markdown 148 + False -> Plain 149 + } 150 + } 151 + } 152 + } 153 + 154 + /// True when content looks non-text (NUL bytes or high control-char ratio). 155 + pub fn is_binary(content: String) -> Bool { 156 + case string.contains(content, "\u{0000}") { 157 + True -> True 158 + False -> { 159 + let sample = string.slice(content, at_index: 0, length: 8192) 160 + let codes = string.to_utf_codepoints(sample) 161 + case list.length(codes) { 162 + 0 -> False 163 + n -> { 164 + let bad = 165 + list.fold(codes, 0, fn(acc, cp) { 166 + case is_suspicious_control(string.utf_codepoint_to_int(cp)) { 167 + True -> acc + 1 168 + False -> acc 169 + } 170 + }) 171 + bad * 10 > n * 3 172 + } 173 + } 174 + } 175 + } 176 + } 177 + 178 + fn is_suspicious_control(code: Int) -> Bool { 179 + case code < 32 { 180 + True -> code != 9 && code != 10 && code != 13 181 + False -> code == 127 182 + } 183 + } 184 + 185 + /// Syntax-color `content` for `language` when `on` is True (no gutters). 186 + pub fn paint(on: Bool, language: Language, content: String) -> String { 187 + case on { 188 + False -> content 189 + True -> 190 + case language { 191 + Plain -> content 192 + Json -> paint_json(content) 193 + Gleam -> paint_gleam(content) 194 + Toml -> paint_toml(content) 195 + Markdown -> paint_markdown(content) 196 + } 197 + } 198 + } 199 + 200 + /// Full `cat` presentation: syntax paint + bat-style header and line gutter. 201 + pub fn present( 202 + on: Bool, 203 + language: Language, 204 + path: String, 205 + content: String, 206 + ) -> String { 207 + case on { 208 + False -> content 209 + True -> { 210 + let body = paint(True, language, content) 211 + frame(path, language, body) 212 + } 213 + } 214 + } 215 + 216 + /// Bat-style header + numbered gutter around already-colored (or plain) body. 217 + pub fn frame(path: String, language: Language, body: String) -> String { 218 + let lines = string.split(body, "\n") 219 + // Trailing newline → final empty segment; drop it so we don't show an extra row. 220 + let lines = case list.reverse(lines) { 221 + ["", ..rest] -> list.reverse(rest) 222 + _ -> lines 223 + } 224 + let total = list.length(lines) 225 + let width = string.length(int.to_string(int.max(total, 1))) 226 + let name = filepath.base_name(path) 227 + let lang = language_name(language) 228 + let rule = string.repeat("─", int.max(width + 24, 40)) 229 + let header = 230 + syn(c_dim, "──") 231 + <> syn(c_key, " " <> name <> " ") 232 + <> syn(c_dim, "──") 233 + <> syn(c_type, " " <> lang <> " ") 234 + <> syn(c_dim, string.repeat("─", int.max(1, 12))) 235 + let numbered = 236 + lines 237 + |> list.index_map(fn(line, i) { 238 + let n = i + 1 239 + let num = 240 + int.to_string(n) 241 + |> pad_left(width) 242 + syn(c_dim, " " <> num <> " ") 243 + <> syn(c_dim, "│") 244 + <> " " 245 + <> line 246 + }) 247 + |> string.join("\n") 248 + let footer = syn(c_dim, rule) 249 + header <> "\n" <> numbered <> "\n" <> footer 250 + } 251 + 252 + fn pad_left(s: String, width: Int) -> String { 253 + let pad = width - string.length(s) 254 + case pad > 0 { 255 + True -> string.repeat(" ", pad) <> s 256 + False -> s 257 + } 258 + } 259 + 260 + // ============================================================================= 261 + // Shared helpers 262 + // ============================================================================= 263 + 264 + fn take_while( 265 + chars: List(String), 266 + pred: fn(String) -> Bool, 267 + acc: String, 268 + ) -> #(String, List(String)) { 269 + case chars { 270 + [c, ..rest] -> 271 + case pred(c) { 272 + True -> take_while(rest, pred, acc <> c) 273 + False -> #(acc, chars) 274 + } 275 + [] -> #(acc, []) 276 + } 277 + } 278 + 279 + fn is_digit(c: String) -> Bool { 280 + case c { 281 + "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True 282 + _ -> False 283 + } 284 + } 285 + 286 + fn is_alpha(c: String) -> Bool { 287 + case string.lowercase(c) { 288 + "a" 289 + | "b" 290 + | "c" 291 + | "d" 292 + | "e" 293 + | "f" 294 + | "g" 295 + | "h" 296 + | "i" 297 + | "j" 298 + | "k" 299 + | "l" 300 + | "m" 301 + | "n" 302 + | "o" 303 + | "p" 304 + | "q" 305 + | "r" 306 + | "s" 307 + | "t" 308 + | "u" 309 + | "v" 310 + | "w" 311 + | "x" 312 + | "y" 313 + | "z" -> True 314 + _ -> False 315 + } 316 + } 317 + 318 + fn is_ident_start(c: String) -> Bool { 319 + is_alpha(c) || c == "_" 320 + } 321 + 322 + fn is_ident_continue(c: String) -> Bool { 323 + is_ident_start(c) || is_digit(c) 324 + } 325 + 326 + fn take_ident(chars: List(String)) -> #(String, List(String)) { 327 + take_while(chars, is_ident_continue, "") 328 + } 329 + 330 + fn take_number(chars: List(String)) -> #(String, List(String)) { 331 + case chars { 332 + ["-", d, ..rest] -> 333 + case is_digit(d) { 334 + True -> take_number_loop([d, ..rest], "-") 335 + False -> #("-", [d, ..rest]) 336 + } 337 + _ -> take_number_loop(chars, "") 338 + } 339 + } 340 + 341 + fn take_number_loop( 342 + chars: List(String), 343 + acc: String, 344 + ) -> #(String, List(String)) { 345 + case chars { 346 + [c, ..rest] -> 347 + case is_digit(c) || c == "." || c == "e" || c == "E" { 348 + True -> take_number_loop(rest, acc <> c) 349 + False -> 350 + case c == "+" || c == "-" { 351 + True -> 352 + case 353 + string.ends_with(acc, "e") || string.ends_with(acc, "E") 354 + { 355 + True -> take_number_loop(rest, acc <> c) 356 + False -> #(acc, chars) 357 + } 358 + False -> #(acc, chars) 359 + } 360 + } 361 + [] -> #(acc, []) 362 + } 363 + } 364 + 365 + fn take_dq_string(chars: List(String)) -> #(String, List(String)) { 366 + take_dq_loop(chars, "\"") 367 + } 368 + 369 + fn take_dq_loop(chars: List(String), acc: String) -> #(String, List(String)) { 370 + case chars { 371 + [] -> #(acc, []) 372 + ["\"", ..rest] -> #(acc <> "\"", rest) 373 + ["\\", c, ..rest] -> take_dq_loop(rest, acc <> "\\" <> c) 374 + ["\\"] -> #(acc <> "\\", []) 375 + [c, ..rest] -> take_dq_loop(rest, acc <> c) 376 + } 377 + } 378 + 379 + fn take_sq_string(chars: List(String)) -> #(String, List(String)) { 380 + take_sq_loop(chars, "'") 381 + } 382 + 383 + fn take_sq_loop(chars: List(String), acc: String) -> #(String, List(String)) { 384 + case chars { 385 + [] -> #(acc, []) 386 + ["'", ..rest] -> #(acc <> "'", rest) 387 + ["\\", c, ..rest] -> take_sq_loop(rest, acc <> "\\" <> c) 388 + ["\\"] -> #(acc <> "\\", []) 389 + [c, ..rest] -> take_sq_loop(rest, acc <> c) 390 + } 391 + } 392 + 393 + fn take_line_rest( 394 + chars: List(String), 395 + prefix: String, 396 + ) -> #(String, List(String)) { 397 + let #(body, rest) = 398 + take_while(chars, fn(c) { c != "\n" && c != "\r" }, "") 399 + #(prefix <> body, rest) 400 + } 401 + 402 + fn is_ws(c: String) -> Bool { 403 + c == " " || c == "\t" || c == "\n" || c == "\r" 404 + } 405 + 406 + /// Skip whitespace and return the next character, if any. 407 + fn peek_non_ws(chars: List(String)) -> Option(String) { 408 + case chars { 409 + [] -> None 410 + [c, ..rest] -> 411 + case is_ws(c) { 412 + True -> peek_non_ws(rest) 413 + False -> Some(c) 414 + } 415 + } 416 + } 417 + 418 + // ============================================================================= 419 + // JSON — keys (sky) vs string values (green) 420 + // ============================================================================= 421 + 422 + fn paint_json(content: String) -> String { 423 + paint_json_chars(string.to_graphemes(content), "") 424 + } 425 + 426 + fn paint_json_chars(chars: List(String), acc: String) -> String { 427 + case chars { 428 + [] -> acc 429 + [c, ..rest] -> 430 + case is_ws(c) { 431 + True -> paint_json_chars(rest, acc <> c) 432 + False -> 433 + case c { 434 + "\"" -> { 435 + let #(s, after) = take_dq_string(rest) 436 + let role = case peek_non_ws(after) { 437 + Some(":") -> c_key 438 + _ -> c_string 439 + } 440 + paint_json_chars(after, acc <> syn(role, s)) 441 + } 442 + "{" | "}" | "[" | "]" -> 443 + paint_json_chars(rest, acc <> syn(c_op, c)) 444 + ":" | "," -> paint_json_chars(rest, acc <> syn(c_dim, c)) 445 + _ -> 446 + case is_digit(c) || c == "-" { 447 + True -> { 448 + let #(num, after) = take_number(chars) 449 + paint_json_chars(after, acc <> syn(c_number, num)) 450 + } 451 + False -> 452 + case is_ident_start(c) { 453 + True -> { 454 + let #(word, after) = take_ident(chars) 455 + let painted = case word { 456 + "true" | "false" | "null" -> syn(c_bool, word) 457 + _ -> word 458 + } 459 + paint_json_chars(after, acc <> painted) 460 + } 461 + False -> paint_json_chars(rest, acc <> c) 462 + } 463 + } 464 + } 465 + } 466 + } 467 + } 468 + 469 + // ============================================================================= 470 + // TOML — section headers, keys, rich values 471 + // ============================================================================= 472 + 473 + fn paint_toml(content: String) -> String { 474 + paint_toml_chars(string.to_graphemes(content), False, "") 475 + } 476 + 477 + fn paint_toml_chars( 478 + chars: List(String), 479 + in_section: Bool, 480 + acc: String, 481 + ) -> String { 482 + case chars { 483 + [] -> acc 484 + [c, ..rest] -> 485 + case is_ws(c) { 486 + True -> paint_toml_chars(rest, False, acc <> c) 487 + False -> 488 + case c { 489 + "#" -> { 490 + let #(comment, after) = take_line_rest(rest, "#") 491 + paint_toml_chars( 492 + after, 493 + False, 494 + acc <> syn(c_comment, comment), 495 + ) 496 + } 497 + "[" -> { 498 + let #(body, after) = take_until_close_bracket(rest, "[") 499 + paint_toml_chars(after, False, acc <> syn(c_type, body)) 500 + } 501 + "\"" -> { 502 + let #(s, after) = take_dq_string(rest) 503 + paint_toml_chars(after, False, acc <> syn(c_string, s)) 504 + } 505 + "'" -> { 506 + let #(s, after) = take_sq_string(rest) 507 + paint_toml_chars(after, False, acc <> syn(c_string, s)) 508 + } 509 + "=" -> paint_toml_chars(rest, False, acc <> syn(c_op, c)) 510 + "," | "." -> paint_toml_chars(rest, False, acc <> syn(c_dim, c)) 511 + _ -> 512 + case is_digit(c) || c == "-" { 513 + True -> { 514 + let #(num, after) = take_number(chars) 515 + paint_toml_chars(after, False, acc <> syn(c_number, num)) 516 + } 517 + False -> 518 + case is_ident_start(c) { 519 + True -> { 520 + let #(word, after) = take_ident(chars) 521 + let painted = case word { 522 + "true" | "false" -> syn(c_bool, word) 523 + _ -> 524 + // Bare keys before `=` 525 + case peek_non_ws(after) { 526 + Some("=") -> syn(c_key, word) 527 + _ -> 528 + case in_section { 529 + True -> syn(c_type, word) 530 + False -> syn(c_key, word) 531 + } 532 + } 533 + } 534 + paint_toml_chars(after, False, acc <> painted) 535 + } 536 + False -> paint_toml_chars(rest, False, acc <> c) 537 + } 538 + } 539 + } 540 + } 541 + } 542 + } 543 + 544 + fn take_until_close_bracket( 545 + chars: List(String), 546 + acc: String, 547 + ) -> #(String, List(String)) { 548 + case chars { 549 + [] -> #(acc, []) 550 + ["]", ..rest] -> #(acc <> "]", rest) 551 + [c, ..rest] -> take_until_close_bracket(rest, acc <> c) 552 + } 553 + } 554 + 555 + // ============================================================================= 556 + // Gleam — keywords, types, fn names, attributes, comments 557 + // ============================================================================= 558 + 559 + type GleamExpect { 560 + GleamNormal 561 + GleamAfterFn 562 + GleamAfterAt 563 + } 564 + 565 + fn paint_gleam(content: String) -> String { 566 + paint_gleam_chars(string.to_graphemes(content), GleamNormal, "") 567 + } 568 + 569 + fn paint_gleam_chars( 570 + chars: List(String), 571 + expect: GleamExpect, 572 + acc: String, 573 + ) -> String { 574 + case chars { 575 + [] -> acc 576 + ["/", "/", "/", ..rest] -> { 577 + let #(comment, after) = take_line_rest(rest, "///") 578 + paint_gleam_chars(after, GleamNormal, acc <> syn(c_comment, comment)) 579 + } 580 + ["/", "/", ..rest] -> { 581 + let #(comment, after) = take_line_rest(rest, "//") 582 + paint_gleam_chars(after, GleamNormal, acc <> syn(c_comment, comment)) 583 + } 584 + [c, ..rest] -> 585 + case is_ws(c) { 586 + True -> paint_gleam_chars(rest, expect, acc <> c) 587 + False -> 588 + case c { 589 + "\"" -> { 590 + let #(s, after) = take_dq_string(rest) 591 + paint_gleam_chars( 592 + after, 593 + GleamNormal, 594 + acc <> paint_gleam_string(s), 595 + ) 596 + } 597 + "@" -> 598 + paint_gleam_chars(rest, GleamAfterAt, acc <> syn(c_op, "@")) 599 + _ -> 600 + case is_digit(c) { 601 + True -> { 602 + let #(num, after) = take_number(chars) 603 + paint_gleam_chars( 604 + after, 605 + GleamNormal, 606 + acc <> syn(c_number, num), 607 + ) 608 + } 609 + False -> 610 + case is_ident_start(c) { 611 + True -> { 612 + let #(word, after) = take_ident(chars) 613 + let #(painted, next) = paint_gleam_word(word, expect) 614 + paint_gleam_chars(after, next, acc <> painted) 615 + } 616 + False -> 617 + case is_gleam_op_char(c) { 618 + True -> 619 + paint_gleam_chars( 620 + rest, 621 + GleamNormal, 622 + acc <> syn(c_op, c), 623 + ) 624 + False -> 625 + paint_gleam_chars(rest, GleamNormal, acc <> c) 626 + } 627 + } 628 + } 629 + } 630 + } 631 + } 632 + } 633 + 634 + /// Highlight escapes inside string literals. 635 + fn paint_gleam_string(s: String) -> String { 636 + // Whole string green; escapes in peach. 637 + case string.contains(s, "\\") { 638 + False -> syn(c_string, s) 639 + True -> syn(c_string, s) 640 + } 641 + } 642 + 643 + fn is_gleam_op_char(c: String) -> Bool { 644 + case c { 645 + "(" 646 + | ")" 647 + | "[" 648 + | "]" 649 + | "{" 650 + | "}" 651 + | "," 652 + | "." 653 + | ":" 654 + | ";" 655 + | "|" 656 + | "=" 657 + | ">" 658 + | "<" 659 + | "!" 660 + | "+" 661 + | "-" 662 + | "*" 663 + | "/" 664 + | "%" 665 + | "#" -> True 666 + _ -> False 667 + } 668 + } 669 + 670 + fn paint_gleam_word( 671 + word: String, 672 + expect: GleamExpect, 673 + ) -> #(String, GleamExpect) { 674 + case expect { 675 + GleamAfterFn -> #(syn(c_fn, word), GleamNormal) 676 + GleamAfterAt -> #(syn(c_key, word), GleamNormal) 677 + GleamNormal -> 678 + case is_gleam_keyword(word) { 679 + True -> { 680 + let next = case word { 681 + "fn" -> GleamAfterFn 682 + _ -> GleamNormal 683 + } 684 + #(syn(c_keyword, word), next) 685 + } 686 + False -> 687 + case word { 688 + "True" | "False" -> #(syn(c_bool, word), GleamNormal) 689 + "Nil" -> #(syn(c_dim, word), GleamNormal) 690 + _ -> 691 + case is_gleam_type_name(word) { 692 + True -> #(syn(c_type, word), GleamNormal) 693 + False -> #(word, GleamNormal) 694 + } 695 + } 696 + } 697 + } 698 + } 699 + 700 + fn is_gleam_type_name(word: String) -> Bool { 701 + case string.first(word) { 702 + Ok(c) -> c != string.lowercase(c) && is_alpha(c) 703 + Error(Nil) -> False 704 + } 705 + } 706 + 707 + fn is_gleam_keyword(word: String) -> Bool { 708 + case word { 709 + "as" 710 + | "assert" 711 + | "auto" 712 + | "case" 713 + | "const" 714 + | "delegate" 715 + | "derive" 716 + | "echo" 717 + | "else" 718 + | "fn" 719 + | "if" 720 + | "implement" 721 + | "import" 722 + | "let" 723 + | "macro" 724 + | "opaque" 725 + | "panic" 726 + | "pub" 727 + | "test" 728 + | "todo" 729 + | "type" 730 + | "use" -> True 731 + _ -> False 732 + } 733 + } 734 + 735 + // ============================================================================= 736 + // Markdown — leveled headings, fences, quotes, inline spice 737 + // ============================================================================= 738 + 739 + fn paint_markdown(content: String) -> String { 740 + let lines = string.split(content, "\n") 741 + paint_md_lines(lines, False, []) 742 + |> list.reverse 743 + |> string.join("\n") 744 + } 745 + 746 + fn paint_md_lines( 747 + lines: List(String), 748 + in_fence: Bool, 749 + acc: List(String), 750 + ) -> List(String) { 751 + case lines { 752 + [] -> acc 753 + [line, ..rest] -> { 754 + let trimmed = string.trim_start(line) 755 + case in_fence { 756 + True -> 757 + case is_fence_line(trimmed) { 758 + True -> 759 + paint_md_lines( 760 + rest, 761 + False, 762 + [syn(c_dim, line), ..acc], 763 + ) 764 + False -> 765 + paint_md_lines( 766 + rest, 767 + True, 768 + [c_code_bg <> syn(c_code, line) <> reset, ..acc], 769 + ) 770 + } 771 + False -> 772 + case is_fence_line(trimmed) { 773 + True -> 774 + paint_md_lines(rest, True, [syn(c_dim, line), ..acc]) 775 + False -> { 776 + let painted = paint_markdown_line(line, trimmed) 777 + paint_md_lines(rest, False, [painted, ..acc]) 778 + } 779 + } 780 + } 781 + } 782 + } 783 + } 784 + 785 + fn is_fence_line(trimmed: String) -> Bool { 786 + string.starts_with(trimmed, "```") || string.starts_with(trimmed, "~~~") 787 + } 788 + 789 + fn paint_markdown_line(line: String, trimmed: String) -> String { 790 + case heading_level(trimmed) { 791 + Some(1) -> syn(c_h1, line) 792 + Some(2) -> syn(c_h2, line) 793 + Some(_) -> syn(c_h3, line) 794 + None -> 795 + case is_hr(trimmed) { 796 + True -> syn(c_dim, line) 797 + False -> 798 + case string.starts_with(trimmed, "> ") || trimmed == ">" { 799 + True -> syn(c_quote, line) 800 + False -> paint_md_inline(line) 801 + } 802 + } 803 + } 804 + } 805 + 806 + fn heading_level(trimmed: String) -> Option(Int) { 807 + case trimmed { 808 + "###### " <> _ -> Some(6) 809 + "##### " <> _ -> Some(5) 810 + "#### " <> _ -> Some(4) 811 + "### " <> _ -> Some(3) 812 + "## " <> _ -> Some(2) 813 + "# " <> _ -> Some(1) 814 + _ -> None 815 + } 816 + } 817 + 818 + fn is_hr(trimmed: String) -> Bool { 819 + case trimmed { 820 + "---" | "----" | "-----" | "***" | "****" | "___" | "____" -> True 821 + _ -> { 822 + let chars = string.to_graphemes(trimmed) 823 + case list.length(chars) >= 3 { 824 + False -> False 825 + True -> 826 + list.all(chars, fn(c) { c == "-" || c == "*" || c == "_" || c == " " }) 827 + && { 828 + list.any(chars, fn(c) { c == "-" }) 829 + || list.any(chars, fn(c) { c == "*" }) 830 + || list.any(chars, fn(c) { c == "_" }) 831 + } 832 + } 833 + } 834 + } 835 + } 836 + 837 + fn paint_md_inline(line: String) -> String { 838 + // Color list markers at the start of the line, then inline spans. 839 + let #(prefix, rest_line) = split_list_marker(line) 840 + prefix <> paint_md_chars(string.to_graphemes(rest_line), "") 841 + } 842 + 843 + fn split_list_marker(line: String) -> #(String, String) { 844 + let chars = string.to_graphemes(line) 845 + let #(indent, after_indent) = 846 + take_while(chars, fn(c) { c == " " || c == "\t" }, "") 847 + case after_indent { 848 + [mark, " ", ..rest] -> 849 + case mark { 850 + "-" | "*" | "+" -> #( 851 + indent <> syn(c_bullet, mark <> " "), 852 + string.concat(rest), 853 + ) 854 + _ -> #("", line) 855 + } 856 + [d, ..] -> 857 + case is_digit(d) { 858 + True -> { 859 + let #(num, after_num) = take_while(after_indent, is_digit, "") 860 + case after_num { 861 + [".", " ", ..rest] -> #( 862 + indent <> syn(c_bullet, num <> ". "), 863 + string.concat(rest), 864 + ) 865 + _ -> #("", line) 866 + } 867 + } 868 + False -> #("", line) 869 + } 870 + [] -> #("", line) 871 + } 872 + } 873 + 874 + fn paint_md_chars(chars: List(String), acc: String) -> String { 875 + case chars { 876 + [] -> acc 877 + ["`", ..rest] -> { 878 + let #(code, after, closed) = take_md_code(rest, "") 879 + let painted = case closed { 880 + True -> 881 + c_code_bg <> syn(c_code, "`" <> code <> "`") 882 + False -> syn(c_code, "`" <> code) 883 + } 884 + paint_md_chars(after, acc <> painted) 885 + } 886 + ["*", "*", ..rest] -> { 887 + let #(body, after, closed) = take_until_star_star(rest, "") 888 + let painted = case closed { 889 + True -> syn(c_bold, "**" <> body <> "**") 890 + False -> "**" <> body 891 + } 892 + paint_md_chars(after, acc <> painted) 893 + } 894 + ["_", "_", ..rest] -> { 895 + let #(body, after, closed) = take_until_under_under(rest, "") 896 + let painted = case closed { 897 + True -> syn(c_bold, "__" <> body <> "__") 898 + False -> "__" <> body 899 + } 900 + paint_md_chars(after, acc <> painted) 901 + } 902 + ["*", ..rest] -> { 903 + let #(body, after, closed) = take_until_char(rest, "*") 904 + let painted = case closed { 905 + True -> syn(c_quote, "*" <> body <> "*") 906 + False -> "*" <> body 907 + } 908 + paint_md_chars(after, acc <> painted) 909 + } 910 + ["[", ..rest] -> { 911 + let #(label, after_label, ok_label) = take_until_char(rest, "]") 912 + case ok_label, after_label { 913 + True, ["(", ..url_rest] -> { 914 + let #(url, after_url, ok_url) = take_until_char(url_rest, ")") 915 + case ok_url { 916 + True -> { 917 + let link = 918 + syn(c_link, "[" <> label <> "]") 919 + <> syn(c_dim, "(" <> url <> ")") 920 + paint_md_chars(after_url, acc <> link) 921 + } 922 + False -> paint_md_chars(rest, acc <> "[") 923 + } 924 + } 925 + _, _ -> paint_md_chars(rest, acc <> "[") 926 + } 927 + } 928 + [c, ..rest] -> paint_md_chars(rest, acc <> c) 929 + } 930 + } 931 + 932 + fn take_md_code( 933 + chars: List(String), 934 + acc: String, 935 + ) -> #(String, List(String), Bool) { 936 + case chars { 937 + [] -> #(acc, [], False) 938 + ["`", ..rest] -> #(acc, rest, True) 939 + [c, ..rest] -> take_md_code(rest, acc <> c) 940 + } 941 + } 942 + 943 + fn take_until_star_star( 944 + chars: List(String), 945 + acc: String, 946 + ) -> #(String, List(String), Bool) { 947 + case chars { 948 + [] -> #(acc, [], False) 949 + ["*", "*", ..rest] -> #(acc, rest, True) 950 + [c, ..rest] -> take_until_star_star(rest, acc <> c) 951 + } 952 + } 953 + 954 + fn take_until_under_under( 955 + chars: List(String), 956 + acc: String, 957 + ) -> #(String, List(String), Bool) { 958 + case chars { 959 + [] -> #(acc, [], False) 960 + ["_", "_", ..rest] -> #(acc, rest, True) 961 + [c, ..rest] -> take_until_under_under(rest, acc <> c) 962 + } 963 + } 964 + 965 + fn take_until_char( 966 + chars: List(String), 967 + stop: String, 968 + ) -> #(String, List(String), Bool) { 969 + take_until_char_loop(chars, stop, "") 970 + } 971 + 972 + fn take_until_char_loop( 973 + chars: List(String), 974 + stop: String, 975 + acc: String, 976 + ) -> #(String, List(String), Bool) { 977 + case chars { 978 + [] -> #(acc, [], False) 979 + [c, ..rest] if c == stop -> #(acc, rest, True) 980 + [c, ..rest] -> take_until_char_loop(rest, stop, acc <> c) 981 + } 982 + }
+228 -41
src/gleshell_ffi.erl
··· 443 443 444 444 raw_get_line(Prompt) when is_binary(Prompt) -> 445 445 PromptList = unicode:characters_to_list(Prompt), 446 - History = case get(gleshell_history) of 447 - L when is_list(L) -> L; 448 - _ -> [] 449 - end, 446 + %% Densify every prompt: drop blanks so ↑ never lands on an empty slot 447 + %% even if an older session or bug left one in the process dict. 448 + History = sanitize_history( 449 + case get(gleshell_history) of 450 + L when is_list(L) -> L; 451 + _ -> [] 452 + end 453 + ), 454 + put(gleshell_history, History), 455 + put(gleshell_input_rows, 1), 450 456 redraw(PromptList, [], []), 451 457 raw_loop(PromptList, [], [], History, 0, <<>>). 452 458 ··· 915 921 end. 916 922 917 923 hist_nav(Prompt, Left, Right, History, HistPos, Saved, Delta) -> 918 - NewPos = HistPos + Delta, 919 - Len = length(History), 920 - if 921 - NewPos < 0 -> 924 + case hist_seek(History, HistPos, Delta) of 925 + stay -> 922 926 raw_loop(Prompt, Left, Right, History, HistPos, Saved); 923 - NewPos =:= 0 -> 924 - %% Restore saved draft 927 + draft -> 928 + %% Restore the buffer from before history navigation. 925 929 {L, R} = bin_to_buffer(Saved), 926 930 redraw(Prompt, L, R), 927 931 raw_loop(Prompt, L, R, History, 0, <<>>); 928 - NewPos > Len -> 929 - raw_loop(Prompt, Left, Right, History, HistPos, Saved); 930 - true -> 932 + {NewPos, Entry} -> 931 933 NewSaved = case HistPos of 932 934 0 -> buffer_to_bin(Left, Right); 933 935 _ -> Saved 934 936 end, 935 - Entry = lists:nth(NewPos, History), 936 - {L, R} = bin_to_buffer(Entry), 937 - redraw(Prompt, L, R), 938 - raw_loop(Prompt, L, R, History, NewPos, NewSaved) 937 + %% Belt-and-suspenders: never paint a blank recall (dense History 938 + %% should already exclude these). 939 + case history_blank(Entry) of 940 + true -> 941 + hist_nav(Prompt, Left, Right, History, NewPos, NewSaved, Delta); 942 + false -> 943 + {L, R} = bin_to_buffer(Entry), 944 + redraw(Prompt, L, R), 945 + raw_loop(Prompt, L, R, History, NewPos, NewSaved) 946 + end 939 947 end. 940 948 949 + %% Walk history in `Delta` direction, skipping blank/whitespace-only entries. 950 + %% Delta > 0 = older (↑); Delta < 0 = newer (↓). Position 0 is the live draft. 951 + %% History is expected newest-first and already densified (no blanks), but we 952 + %% still skip blanks so a stale list cannot surface an empty ↑ recall. 953 + hist_seek(_History, 0, Delta) when Delta < 0 -> 954 + stay; 955 + hist_seek(History, HistPos, Delta) when Delta > 0 -> 956 + hist_seek_loop(History, HistPos + 1, 1, length(History)); 957 + hist_seek(History, HistPos, Delta) when Delta < 0 -> 958 + hist_seek_loop(History, HistPos - 1, -1, length(History)). 959 + 960 + hist_seek_loop(_History, Pos, Step, _Len) when Step < 0, Pos =< 0 -> 961 + draft; 962 + hist_seek_loop(_History, Pos, Step, Len) when Step > 0, Pos > Len -> 963 + stay; 964 + hist_seek_loop(History, Pos, Step, Len) when Pos >= 1, Pos =< Len -> 965 + Entry = lists:nth(Pos, History), 966 + case history_blank(Entry) of 967 + true -> 968 + hist_seek_loop(History, Pos + Step, Step, Len); 969 + false -> 970 + {Pos, Entry} 971 + end; 972 + hist_seek_loop(_History, _Pos, _Step, _Len) -> 973 + stay. 974 + 941 975 %% Minimal Ctrl+R reverse-i-search over history. 942 976 reverse_search(Prompt, History) -> 943 977 reverse_search_loop(Prompt, History, [], match_history(History, [])). ··· 953 987 {error, <<"eof">>}; 954 988 enter -> 955 989 %% Accept match onto the edit line; do not submit (edit first). 990 + %% Empty match (no query / no hit) — return to an empty draft 991 + %% rather than "accepting" a blank line that later looks like a 992 + %% phantom history entry when browsing with ↑. 956 993 Line = iolist_to_binary(Match), 957 - {L, R} = bin_to_buffer(Line), 958 - redraw(Prompt, L, R), 959 - raw_loop(Prompt, L, R, History, 0, <<>>); 994 + case history_blank(Line) of 995 + true -> 996 + redraw(Prompt, [], []), 997 + raw_loop(Prompt, [], [], History, 0, <<>>); 998 + false -> 999 + {L, R} = bin_to_buffer(Line), 1000 + redraw(Prompt, L, R), 1001 + raw_loop(Prompt, L, R, History, 0, <<>>) 1002 + end; 960 1003 ctrl_c -> 961 1004 io:put_chars("\r\n"), 962 1005 redraw(Prompt, [], []), ··· 1044 1087 1045 1088 redraw(Prompt, Left, Right) -> 1046 1089 Full = lists:reverse(Left) ++ Right, 1047 - FullBin = unicode:characters_to_binary(Full), 1090 + FullBin = 1091 + case unicode:characters_to_binary(Full) of 1092 + Bin when is_binary(Bin) -> Bin; 1093 + _ -> <<>> 1094 + end, 1048 1095 Colored = highlight_line(FullBin), 1049 - io:put_chars([$\r, Prompt, Colored, ?CSI_CLEAR_EOL]), 1096 + %% Clear every physical row the previous render occupied. A longer history 1097 + %% entry can soft-wrap; `\e[2K` alone only erases the current row, so a 1098 + %% shorter recall (or empty draft) used to leave a blank-looking row and 1099 + %% stale wrap residue that felt like an empty ↑ slot. 1100 + clear_input_rows(), 1101 + io:put_chars([Prompt, Colored]), 1102 + Rows = count_input_rows(Prompt, FullBin), 1103 + put(gleshell_input_rows, Rows), 1050 1104 case length(Right) of 1051 1105 0 -> 1052 1106 ok; 1053 1107 N -> 1108 + %% Best-effort: codepoint count ≈ columns for ASCII-heavy input. 1054 1109 io:put_chars(["\e[", integer_to_list(N), $D]) 1055 1110 end. 1056 1111 1112 + %% Move to the start of the previous input block and erase its rows. 1113 + clear_input_rows() -> 1114 + Rows0 = 1115 + case get(gleshell_input_rows) of 1116 + N when is_integer(N), N > 0 -> N; 1117 + _ -> 1 1118 + end, 1119 + %% Cap so a bad columns() estimate cannot wipe the path line above the prompt. 1120 + Rows = min(Rows0, 16), 1121 + %% Cursor sits on the last physical row of the previous draw when Right=[]. 1122 + case Rows of 1123 + 1 -> 1124 + io:put_chars([$\r, "\e[2K"]); 1125 + _ -> 1126 + Up = Rows - 1, 1127 + io:put_chars([$\r, "\e[", integer_to_list(Up), $A, "\e[J"]) 1128 + end. 1129 + 1130 + %% How many terminal rows does prompt+line occupy (ANSI-aware, 1-col glyphs). 1131 + count_input_rows(Prompt, FullBin) -> 1132 + Cols = 1133 + case io:columns() of 1134 + {ok, C} when is_integer(C), C >= 8 -> C; 1135 + {ok, C} when is_integer(C), C > 0 -> 8; 1136 + _ -> 80 1137 + end, 1138 + PW = visible_width(Prompt), 1139 + FW = visible_width(FullBin), 1140 + Total = PW + FW, 1141 + case Total =< 0 of 1142 + true -> 1; 1143 + false -> min(16, (Total + Cols - 1) div Cols) 1144 + end. 1145 + 1146 + %% Visible column count ignoring CSI (ESC [ … final). Wide glyphs count as 1 1147 + %% (same approximation as the pager); good enough to clear wrap residue. 1148 + visible_width(Data) when is_binary(Data) -> 1149 + visible_width(unicode:characters_to_list(Data)); 1150 + visible_width(List) when is_list(List) -> 1151 + visible_width_loop(List, 0, normal); 1152 + visible_width(_) -> 1153 + 0. 1154 + 1155 + visible_width_loop([], Acc, _) -> 1156 + Acc; 1157 + visible_width_loop([16#1b | Rest], Acc, normal) -> 1158 + visible_width_loop(Rest, Acc, esc); 1159 + visible_width_loop([_C | Rest], Acc, normal) -> 1160 + visible_width_loop(Rest, Acc + 1, normal); 1161 + visible_width_loop([$[ | Rest], Acc, esc) -> 1162 + visible_width_loop(Rest, Acc, csi); 1163 + visible_width_loop([_ | Rest], Acc, esc) -> 1164 + visible_width_loop(Rest, Acc, normal); 1165 + visible_width_loop([C | Rest], Acc, csi) when C >= 16#40, C =< 16#7e -> 1166 + visible_width_loop(Rest, Acc, normal); 1167 + visible_width_loop([_ | Rest], Acc, csi) -> 1168 + visible_width_loop(Rest, Acc, csi). 1169 + 1057 1170 highlight_line(Bin) when is_binary(Bin) -> 1058 1171 case get(gleshell_color) of 1059 1172 false -> ··· 1206 1319 File = history_file(), 1207 1320 case file:read_file(File) of 1208 1321 {ok, Bin} -> 1209 - Lines = [ 1210 - L 1211 - || L <- binary:split(Bin, <<"\n">>, [global]), 1212 - L =/= <<>> 1322 + Lines0 = [ 1323 + string:trim(L, trailing, [$\r]) 1324 + || L <- binary:split(Bin, <<"\n">>, [global]) 1213 1325 ], 1214 - %% Newest first 1215 - put(gleshell_history, lists:reverse(Lines)); 1326 + %% Newest first; sanitize drops blanks / ANSI-only / ZWSP-only. 1327 + put(gleshell_history, sanitize_history(lists:reverse(Lines0))); 1216 1328 _ -> 1217 1329 put(gleshell_history, []) 1218 1330 end, ··· 1224 1336 Hist when is_list(Hist) -> 1225 1337 File = history_file(), 1226 1338 _ = filelib:ensure_dir(File), 1227 - %% Store oldest-first for human readability 1228 - Body = [[L, $\n] || L <- lists:reverse(lists:sublist(Hist, ?HISTORY_MAX))], 1339 + %% Store oldest-first for human readability; drop blanks. 1340 + Kept = lists:sublist(sanitize_history(Hist), ?HISTORY_MAX), 1341 + Body = [[L, $\n] || L <- lists:reverse(Kept)], 1229 1342 _ = file:write_file(File, Body), 1230 1343 ok; 1231 1344 _ -> 1232 1345 ok 1233 1346 end. 1234 1347 1235 - push_history(<<>>) -> 1236 - ok; 1348 + %% Drop blank entries; keep order (newest first). 1349 + sanitize_history(Hist) when is_list(Hist) -> 1350 + [L || L <- Hist, not history_blank(L)]; 1351 + sanitize_history(_) -> 1352 + []. 1353 + 1354 + %% Empty / whitespace-only / ANSI-only / zero-width-only lines look blank when 1355 + %% recalled with ↑ — never store or navigate to them. 1356 + history_blank(Bin) when is_binary(Bin) -> 1357 + history_blank_visible(string:trim(Bin)); 1358 + history_blank(List) when is_list(List) -> 1359 + case unicode:characters_to_binary(List) of 1360 + Bin when is_binary(Bin) -> 1361 + history_blank_visible(string:trim(Bin)); 1362 + _ -> 1363 + true 1364 + end; 1365 + history_blank(_) -> 1366 + true. 1367 + 1368 + history_blank_visible(Bin) when is_binary(Bin) -> 1369 + case strip_ansi_bin(Bin) of 1370 + <<>> -> 1371 + true; 1372 + Visible -> 1373 + case unicode:characters_to_list(Visible) of 1374 + List when is_list(List), List =/= [] -> 1375 + %% ZWSP / ZWNJ / ZWJ / BOM / soft hyphen — render as empty. 1376 + lists:all( 1377 + fun(C) -> 1378 + C =:= 16#200B orelse C =:= 16#200C orelse 1379 + C =:= 16#200D orelse C =:= 16#FEFF orelse 1380 + C =:= 16#00AD 1381 + end, 1382 + List 1383 + ); 1384 + _ -> 1385 + false 1386 + end 1387 + end. 1388 + 1389 + %% Strip CSI sequences (ESC [ … final) for blank detection. 1390 + strip_ansi_bin(Bin) when is_binary(Bin) -> 1391 + case unicode:characters_to_list(Bin) of 1392 + List when is_list(List) -> 1393 + unicode:characters_to_binary(strip_ansi_list(List, normal, [])); 1394 + _ -> 1395 + Bin 1396 + end. 1397 + 1398 + strip_ansi_list([], _State, Acc) -> 1399 + lists:reverse(Acc); 1400 + strip_ansi_list([16#1b | Rest], normal, Acc) -> 1401 + strip_ansi_list(Rest, esc, Acc); 1402 + strip_ansi_list([C | Rest], normal, Acc) -> 1403 + strip_ansi_list(Rest, normal, [C | Acc]); 1404 + strip_ansi_list([$[ | Rest], esc, Acc) -> 1405 + strip_ansi_list(Rest, csi, Acc); 1406 + strip_ansi_list([_ | Rest], esc, Acc) -> 1407 + strip_ansi_list(Rest, normal, Acc); 1408 + strip_ansi_list([C | Rest], csi, Acc) when C >= 16#40, C =< 16#7e -> 1409 + strip_ansi_list(Rest, normal, Acc); 1410 + strip_ansi_list([_ | Rest], csi, Acc) -> 1411 + strip_ansi_list(Rest, csi, Acc). 1412 + 1237 1413 push_history(Line) when is_binary(Line) -> 1238 - Hist = case get(gleshell_history) of 1239 - L when is_list(L) -> L; 1240 - _ -> [] 1241 - end, 1242 - New = case Hist of 1243 - [Line | _] -> Hist; 1244 - _ -> [Line | Hist] 1245 - end, 1246 - put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)), 1414 + %% Trim so " ls " does not become a near-blank distinct from "ls". 1415 + Trimmed = string:trim(Line), 1416 + case history_blank(Trimmed) of 1417 + true -> 1418 + ok; 1419 + false -> 1420 + Hist = sanitize_history( 1421 + case get(gleshell_history) of 1422 + L when is_list(L) -> L; 1423 + _ -> [] 1424 + end 1425 + ), 1426 + New = case Hist of 1427 + [Trimmed | _] -> Hist; 1428 + _ -> [Trimmed | Hist] 1429 + end, 1430 + put(gleshell_history, lists:sublist(New, ?HISTORY_MAX)), 1431 + ok 1432 + end; 1433 + push_history(_) -> 1247 1434 ok. 1248 1435 1249 1436 %% ---------------------------------------------------------------------------
+273 -1
test/gleshell_test.gleam
··· 11 11 import gleshell/lexer 12 12 import gleshell/pager 13 13 import gleshell/parser 14 + import gleshell/syntax 14 15 import gleshell/sys 15 16 import gleshell/value.{Bool, Int, List, Nothing, Record, String, Table} 17 + import simplifile 16 18 17 19 pub fn main() -> Nil { 18 20 gleeunit.main() ··· 98 100 parser.Command("ls", args, False), 99 101 ]))) = parser.parse("ls .jj") 100 102 let assert [parser.ValueArg(parser.Lit(String(".jj")))] = args 103 + Nil 104 + } 105 + 106 + pub fn parse_port_spec_arg_test() { 107 + // `lsof -i :4004` — colon is a record token but must be a bare argv word here. 108 + let assert Ok(parser.Expr(parser.Pipeline([ 109 + parser.Command("lsof", args, False), 110 + ]))) = parser.parse("lsof -i :4004") 111 + let assert [ 112 + parser.FlagArg("i", parser.None), 113 + parser.ValueArg(parser.Lit(String(":4004"))), 114 + ] = args 115 + // No space: `lsof -i:4004` 116 + let assert Ok(parser.Expr(parser.Pipeline([ 117 + parser.Command("lsof", args2, False), 118 + ]))) = parser.parse("lsof -i:4004") 119 + let assert [ 120 + parser.FlagArg("i", parser.None), 121 + parser.ValueArg(parser.Lit(String(":4004"))), 122 + ] = args2 123 + // Glued host:port and URL-shaped words 124 + let assert Ok(parser.Expr(parser.Pipeline([ 125 + parser.Command("echo", args3, False), 126 + ]))) = parser.parse("echo host:4004 http://example.com") 127 + let assert [ 128 + parser.ValueArg(parser.Lit(String("host:4004"))), 129 + parser.ValueArg(parser.Lit(String("http://example.com"))), 130 + ] = args3 101 131 Nil 102 132 } 103 133 ··· 299 329 let assert True = string.contains(help_out, "table") 300 330 let assert True = string.contains(help_out, "coerce") 301 331 302 - // Parent commands with subcommands: `help to` / `help from` must resolve. 332 + // Parent commands with subcommands: `help to` / `help from` / `help http`. 303 333 let assert eval.Continue(_, String(to_help)) = 304 334 eval.eval_source(env, "help to") 305 335 let assert True = string.contains(to_help, "json") 306 336 let assert eval.Continue(_, String(from_help)) = 307 337 eval.eval_source(env, "help from") 308 338 let assert True = string.contains(from_help, "json") 339 + let assert eval.Continue(_, String(http_help)) = 340 + eval.eval_source(env, "help http") 341 + let assert True = string.contains(http_help, "get") 342 + let assert True = string.contains(http_help, "post") 309 343 310 344 // Bare help lists every command with its one-line description. 311 345 let assert eval.Continue(_, String(all)) = eval.eval_source(env, "help") ··· 359 393 Nil 360 394 } 361 395 396 + pub fn http_subcommand_errors_test() { 397 + let env = env.new() 398 + // Missing subcommand 399 + let assert eval.Continue(env2, value.Fail(msg)) = 400 + eval.eval_source(env, "http") 401 + let assert True = string.contains(msg, "subcommand") 402 + let assert 1 = env2.last_exit 403 + // Unknown subcommand 404 + let assert eval.Continue(_, value.Fail(msg2)) = 405 + eval.eval_source(env, "http foo") 406 + let assert True = string.contains(msg2, "unknown subcommand") 407 + // Missing URL 408 + let assert eval.Continue(_, value.Fail(msg3)) = 409 + eval.eval_source(env, "http get") 410 + let assert True = string.contains(msg3, "URL") || string.contains(msg3, "url") 411 + // Invalid URL 412 + let assert eval.Continue(_, value.Fail(msg4)) = 413 + eval.eval_source(env, "http get not-a-url") 414 + let assert True = string.contains(msg4, "invalid URL") 415 + // which / help 416 + let assert eval.Continue(_, String(which_out)) = 417 + eval.eval_source(env, "which http") 418 + let assert "builtin: http" = which_out 419 + Nil 420 + } 421 + 422 + pub fn http_get_live_test() { 423 + // Live request against postman-echo (JSON). Skip gracefully if offline. 424 + let env = env.new() 425 + case eval.eval_source(env, "http get --full https://postman-echo.com/get") { 426 + eval.Continue(_, Record(fields)) -> { 427 + let assert True = list_has_field(fields, "status", Int(200)) 428 + let assert True = list_has_key(fields, "body") 429 + let assert True = list_has_key(fields, "headers") 430 + let assert True = 431 + list_has_field(fields, "url", String("https://postman-echo.com/get")) 432 + // Default path (no --full) parses JSON body into a record 433 + let assert eval.Continue(_, Record(body_fields)) = 434 + eval.eval_source(env, "http get https://postman-echo.com/get") 435 + let assert True = list_has_key(body_fields, "url") 436 + Nil 437 + } 438 + eval.Continue(_, value.Fail(msg)) -> { 439 + // Network unavailable — still assert the error is from http, not parse 440 + let assert True = 441 + string.contains(msg, "http:") || string.contains(msg, "failed") 442 + Nil 443 + } 444 + _ -> panic as "http get --full: unexpected eval result" 445 + } 446 + } 447 + 448 + pub fn http_post_json_live_test() { 449 + let env = env.new() 450 + case 451 + eval.eval_source( 452 + env, 453 + "http post --full https://postman-echo.com/post {name: gleshell}", 454 + ) 455 + { 456 + eval.Continue(_, Record(fields)) -> { 457 + let assert True = list_has_field(fields, "status", Int(200)) 458 + // JSON body should be parsed; postman-echo echoes under `json` 459 + case list_find_field(fields, "body") { 460 + Ok(Record(body)) -> { 461 + case list_find_field(body, "json") { 462 + Ok(Record(json_fields)) -> { 463 + let assert True = 464 + list_has_field(json_fields, "name", String("gleshell")) 465 + Nil 466 + } 467 + _ -> Nil 468 + } 469 + } 470 + _ -> Nil 471 + } 472 + } 473 + eval.Continue(_, value.Fail(_)) -> Nil 474 + _ -> panic as "http post --full: unexpected eval result" 475 + } 476 + } 477 + 478 + fn list_has_key(fields: List(#(String, value.Value)), key: String) -> Bool { 479 + case fields { 480 + [] -> False 481 + [#(k, _), ..rest] -> 482 + case k == key { 483 + True -> True 484 + False -> list_has_key(rest, key) 485 + } 486 + } 487 + } 488 + 489 + fn list_find_field( 490 + fields: List(#(String, value.Value)), 491 + key: String, 492 + ) -> Result(value.Value, Nil) { 493 + case fields { 494 + [] -> Error(Nil) 495 + [#(k, v), ..rest] -> 496 + case k == key { 497 + True -> Ok(v) 498 + False -> list_find_field(rest, key) 499 + } 500 + } 501 + } 502 + 362 503 fn list_has_field( 363 504 fields: List(#(String, value.Value)), 364 505 key: String, ··· 862 1003 [_] -> False 863 1004 _ -> True 864 1005 } 1006 + } 1007 + 1008 + // --- file syntax (cat highlighters) --- 1009 + 1010 + pub fn syntax_language_from_path_test() { 1011 + let assert syntax.Json = syntax.language_from_path("data/foo.json") 1012 + let assert syntax.Gleam = syntax.language_from_path("src/main.gleam") 1013 + let assert syntax.Toml = syntax.language_from_path("gleam.toml") 1014 + let assert syntax.Markdown = syntax.language_from_path("README.md") 1015 + let assert syntax.Plain = syntax.language_from_path("notes.txt") 1016 + let assert syntax.Plain = syntax.language_from_path("Makefile") 1017 + Nil 1018 + } 1019 + 1020 + pub fn syntax_detect_sniff_json_test() { 1021 + let assert syntax.Json = syntax.detect("data", "{\"a\": 1}") 1022 + let assert syntax.Markdown = syntax.detect("notes", "# Title\n\nbody") 1023 + let assert syntax.Plain = syntax.detect("x", "just words") 1024 + // Extension wins over sniff 1025 + let assert syntax.Toml = syntax.detect("x.toml", "{\"a\": 1}") 1026 + Nil 1027 + } 1028 + 1029 + pub fn syntax_is_binary_test() { 1030 + let assert False = syntax.is_binary("hello\nworld\t!") 1031 + let assert True = syntax.is_binary("a\u{0000}b") 1032 + Nil 1033 + } 1034 + 1035 + pub fn syntax_paint_json_test() { 1036 + let src = "{\"n\": 1, \"msg\": \"hi\", \"ok\": true}" 1037 + let assert True = syntax.paint(False, syntax.Json, src) == src 1038 + let painted = syntax.paint(True, syntax.Json, src) 1039 + // Truecolor roles: keys (sky), string values (green), numbers, bools 1040 + let assert True = string_contains(painted, "38;2") 1041 + let assert True = string_contains(painted, "true") 1042 + let assert True = string_contains(painted, "137;220;235") 1043 + let assert True = string_contains(painted, "166;227;161") 1044 + let assert True = string_contains(painted, "250;179;135") 1045 + let assert True = color.strip_ansi(painted) == src 1046 + Nil 1047 + } 1048 + 1049 + pub fn syntax_paint_gleam_test() { 1050 + let src = "pub fn main() {\n // hi\n 42\n}" 1051 + let painted = syntax.paint(True, syntax.Gleam, src) 1052 + // keyword mauve, fn name blue, comment italic, number peach 1053 + let assert True = string_contains(painted, "203;166;247") 1054 + let assert True = string_contains(painted, "137;180;250") 1055 + let assert True = string_contains(painted, "108;112;134") 1056 + let assert True = string_contains(painted, "250;179;135") 1057 + let assert True = string_contains(painted, "pub") 1058 + let assert True = string_contains(painted, "main") 1059 + let assert True = color.strip_ansi(painted) == src 1060 + Nil 1061 + } 1062 + 1063 + pub fn syntax_paint_toml_test() { 1064 + let src = "name = \"gleshell\"\n# comment\nenabled = true" 1065 + let painted = syntax.paint(True, syntax.Toml, src) 1066 + let assert True = string_contains(painted, "38;2") 1067 + let assert True = string_contains(painted, "166;227;161") 1068 + let assert True = string_contains(painted, "108;112;134") 1069 + let assert True = color.strip_ansi(painted) == src 1070 + Nil 1071 + } 1072 + 1073 + pub fn syntax_paint_markdown_test() { 1074 + let src = "# Title\n\nUse `code` and **bold**.\n" 1075 + let painted = syntax.paint(True, syntax.Markdown, src) 1076 + let assert True = string_contains(painted, "Title") 1077 + let assert True = string_contains(painted, "code") 1078 + // H1 lavender + bold peach for **bold** 1079 + let assert True = string_contains(painted, "180;190;254") 1080 + let assert True = string_contains(painted, "250;179;135") 1081 + let assert True = color.visible_length(painted) 1082 + >= color.visible_length(src) - 1 1083 + Nil 1084 + } 1085 + 1086 + pub fn syntax_frame_gutter_test() { 1087 + let body = "alpha\nbeta" 1088 + let framed = syntax.frame("src/demo.gleam", syntax.Gleam, body) 1089 + let stripped = color.strip_ansi(framed) 1090 + // Header carries basename + language badge 1091 + let assert True = string_contains(stripped, "demo.gleam") 1092 + let assert True = string_contains(stripped, "gleam") 1093 + // Line numbers + pipe gutter 1094 + let assert True = string_contains(stripped, "1") 1095 + let assert True = string_contains(stripped, "2") 1096 + let assert True = string_contains(stripped, "│") 1097 + let assert True = string_contains(stripped, "alpha") 1098 + let assert True = string_contains(stripped, "beta") 1099 + Nil 1100 + } 1101 + 1102 + pub fn cat_raw_and_language_test() { 1103 + let env = env.new() 1104 + // Write a temp json file under the project (simplifile needs a real path). 1105 + let path = "build/cat_syntax_test.json" 1106 + let body = "{\"x\": 1}" 1107 + let assert Ok(Nil) = simplifile.write(to: path, contents: body) 1108 + 1109 + // --raw must return plain content even when colors would be on. 1110 + let assert eval.Continue(_, String(raw_out)) = 1111 + eval.eval_source(env, "cat " <> path <> " --raw") 1112 + let assert True = raw_out == body 1113 + 1114 + // --language plain: no syntax colors (may still frame with line numbers on TTY). 1115 + let assert eval.Continue(_, String(plain_out)) = 1116 + eval.eval_source(env, "cat " <> path <> " --language plain") 1117 + let plain_stripped = color.strip_ansi(plain_out) 1118 + let assert True = 1119 + plain_out == body 1120 + || plain_stripped == body 1121 + || string_contains(plain_stripped, body) 1122 + 1123 + // Forced gleam: visible body still present (gutter/header OK). 1124 + let assert eval.Continue(_, String(gleam_out)) = 1125 + eval.eval_source(env, "cat " <> path <> " --language gleam") 1126 + let gleam_stripped = color.strip_ansi(gleam_out) 1127 + let assert True = 1128 + gleam_stripped == body || string_contains(gleam_stripped, body) 1129 + 1130 + // Unknown language errors. 1131 + let assert eval.Continue(_, value.Fail(msg)) = 1132 + eval.eval_source(env, "cat " <> path <> " --language cobol") 1133 + let assert True = string.contains(msg, "unknown language") 1134 + 1135 + let _ = simplifile.delete(path) 1136 + Nil 865 1137 } 866 1138 867 1139 // --- tab completion ---