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

Configure Feed

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

Fix paste dropping letters; stream most externals on a live TTY.

Raw line editor: keep a push-back key queue so multi-char IO payloads are not
truncated, treat unbound ESC+letter as the letter (was "gleam" → "geam"),
enable bracketed paste, and batch-insert paste bursts with one redraw.

Lexer: allow mid-token @ for SSH/git barewords. Externals default to a live
TTY so long-running tools stream; only nested-pager tools (systemctl, git,
man, …) stay captured for the builtin pager.

author
nandi
date (Jul 26, 2026, 8:21 AM -0700) commit 0de6f8e4 parent e0146aa1 change-id vkxmuqwk
+344 -104
+5 -5
README.md
··· 182 182 (case-insensitive, finds as you type) and `n`/`N` next/previous match. `^less` 183 183 still runs the external binary. 184 184 185 - External tools that would spawn system `less` (`systemctl`, `git log`, `man`, …) 186 - are captured with nested pagers forced to `cat`, then shown through the **same 187 - builtin pager** when the text does not fit on one screen. Full-screen programs 188 - that need a real TTY (`vim`, `htop`, `sudo`, `ssh`, …) still inherit the 189 - terminal. Use `^less` / a path for the external pager binary. 185 + Most external commands inherit a live TTY so long-lived processes (`nix run`, 186 + dev servers, builds) stream output as they run. Tools that would spawn system 187 + `less` (`systemctl`, `git log`, `man`, …) are still captured with nested pagers 188 + forced to `cat`, then shown through the **same builtin pager** when the text 189 + does not fit on one screen. Use `^less` / a path for the external pager binary. 190 190 191 191 Unknown command names fall through to external executables on `PATH`. 192 192
+4 -4
src/gleshell.gleam
··· 266 266 Nothing -> Nil 267 267 _ -> 268 268 // External commands that used PTY relay already streamed output to the 269 - // terminal (vim, sudo, …). Skip re-printing when that flag is still set. 270 - // Everything else (including systemctl/git/man with nested pagers forced 271 - // to cat) is captured and shown here — through the builtin pager when 272 - // allowed and the text does not fit on one screen (less -F style). 269 + // terminal (servers, vim, sudo, …). Skip re-printing when that flag is 270 + // still set. Nested-pager tools (systemctl/git/man) are captured and 271 + // shown here — through the builtin pager when allowed and the text does 272 + // not fit on one screen (less -F style). 273 273 case sys.take_output_shown() { 274 274 True -> Nil 275 275 False -> {
+19 -35
src/gleshell/eval.gleam
··· 59 59 } 60 60 } 61 61 } 62 - // Bare expression: last stage may take a live TTY only if it needs one 63 - // (vim, sudo, …). Pager-using tools (systemctl, git, man) are captured 64 - // and shown via gleshell's builtin pager instead of system less. 62 + // Bare expression: last stage gets a live TTY by default so long-lived 63 + // processes stream. Pager-default tools (systemctl, git, man) stay captured 64 + // and are shown via gleshell's builtin pager instead of system less. 65 65 parser.Expr(pipeline) -> eval_pipeline(env, pipeline, Nothing, True) 66 66 } 67 67 } 68 68 69 - /// `allow_tty` — when True, the last pipeline stage *may* run on a live TTY 70 - /// if `wants_tty` says it needs one (editors, TUIs, auth). Otherwise output 69 + /// `allow_tty` — when True, the last pipeline stage runs on a live TTY unless 70 + /// it is a known nested-pager tool (`captures_for_pager`). Otherwise output 71 71 /// is captured (nested pagers → cat) for gleshell's pager. 72 72 fn eval_pipeline( 73 73 env: Env, ··· 249 249 ) -> EvalResult { 250 250 // Pipeline input becomes the external's stdin (Unix-style `cmd | less`). 251 251 let stdin = stdin_bytes(input) 252 - // Prefer capture so tools that would spawn system `less` (systemctl, git, 253 - // man, …) dump full output with nested pagers disabled. The REPL then pages 254 - // through gleshell's builtin pager. Only true TUI/TTY programs inherit a 255 - // live terminal (vim, htop, sudo, ssh, …). 252 + // Live TTY by default so long-lived processes (servers, builds, `nix run`) 253 + // stream output. Only tools that open system `less` by default are captured 254 + // with nested pagers forced to cat; the REPL then pages via gleshell's pager. 256 255 let result = case interactive && wants_tty(name) { 257 256 True -> sys.run_cmd_tty(name, str_args, stdin) 258 257 False -> sys.run_cmd(name, str_args, stdin) ··· 283 282 } 284 283 } 285 284 286 - /// True when the external must own a live TTY (full-screen UI, password 287 - /// prompts, REPLs). Everything else is captured so systemctl/git/man use 288 - /// gleshell's pager instead of spawning system `less`. 285 + /// True when the external should own a live TTY (stream + interactive). 286 + /// Default True: servers, builds, and ordinary tools show output as they run. 287 + /// Only nested-pager tools are captured so systemctl/git/man use gleshell's 288 + /// pager instead of spawning system `less`. 289 289 fn wants_tty(name: String) -> Bool { 290 + !captures_for_pager(name) 291 + } 292 + 293 + /// Tools that open system `less`/`more` by default. Capture with nested pagers 294 + /// forced to cat; the interactive REPL shows long output in the builtin pager. 295 + fn captures_for_pager(name: String) -> Bool { 290 296 let base = command_basename(name) 291 297 case base { 292 - // Editors 293 - "vi" | "vim" | "nvim" | "nano" | "emacs" | "emacsclient" | "helix" | "hx" 294 - | "kak" | "micro" | "ed" | "joe" -> True 295 - // Explicit system pagers (builtin `less` is separate; `^less` hits this) 296 - "less" | "more" | "most" -> True 297 - // Process / system monitors 298 - "top" | "htop" | "btop" | "glances" | "iotop" | "nethogs" | "nvtop" 299 - | "gtop" | "watch" -> True 300 - // File managers / TUIs 301 - "ranger" | "mc" | "nnn" | "lf" | "yazi" | "xplr" | "tig" | "gitui" 302 - | "lazygit" | "k9s" -> True 303 - // Interactive filters 304 - "fzf" | "peco" | "sk" -> True 305 - // Shells / REPLs 306 - "sh" | "bash" | "zsh" | "fish" | "nu" | "python" | "python3" | "ipython" 307 - | "node" | "irb" | "pry" | "lua" | "psql" | "mysql" | "redis-cli" 308 - | "sqlite3" | "iex" | "erl" -> True 309 - // Remote / auth (need a controlling TTY) 310 - "ssh" | "sftp" | "scp" | "mosh" | "telnet" | "sudo" | "doas" | "run0" 311 - | "pkexec" | "su" | "login" | "passwd" | "ssh-add" -> True 312 - // Multiplexers / debuggers / network TUI 313 - "tmux" | "screen" | "zellij" | "gdb" | "lldb" | "cgdb" | "nmtui" 314 - | "alsamixer" | "pulsemixer" | "bluetoothctl" -> True 298 + "systemctl" | "journalctl" | "man" | "info" | "git" -> True 315 299 _ -> False 316 300 } 317 301 }
+2 -1
src/gleshell/highlight.gleam
··· 464 464 fn is_ident_continue(c: String) -> Bool { 465 465 // Path-ish chars covered by is_ident_start (`.` `/` `~`); keep `-` mid-token. 466 466 // `#` mid-token for flake refs (must match lexer); bare `#` is still a comment. 467 - is_ident_start(c) || is_digit(c) || c == "-" || c == "#" 467 + // `@` mid-token for SSH/git URLs (must match lexer). 468 + is_ident_start(c) || is_digit(c) || c == "-" || c == "#" || c == "@" 468 469 }
+2 -1
src/gleshell/lexer.gleam
··· 286 286 // Path-ish chars: letters/digits already covered; keep `.` `/` `-` `~` mid-token. 287 287 // `#` mid-token for flake refs (`nixpkgs#hello`, `.#package`); bare `#` still 288 288 // starts a comment at a word boundary (handled in do_tokenize). 289 - is_ident_start(c) || is_digit(c) || c == "-" || c == "#" 289 + // `@` mid-token for SSH/git URLs (`git@host:path`, `user@host`). 290 + is_ident_start(c) || is_digit(c) || c == "-" || c == "#" || c == "@" 290 291 }
+278 -57
src/gleshell_ffi.erl
··· 428 428 %% competing get_chars clients (killing those dropped the 429 429 %% first post-command key — empty ↑ after nix/sleep/…). 430 430 start_stdin_mux(), 431 + enable_bracketed_paste(), 431 432 try 432 433 Fun() 433 434 after 435 + disable_bracketed_paste(), 434 436 stop_stdin_mux(), 435 437 save_line_history() 436 438 end, ··· 616 618 ), 617 619 put(gleshell_history, History), 618 620 put(gleshell_input_rows, 1), 621 + put(gleshell_key_q, []), 622 + put(gleshell_key_unread, []), 619 623 redraw(PromptList, [], []), 620 624 raw_loop(PromptList, [], [], History, 0, <<>>). 621 625 ··· 634 638 io:put_chars("\r\n"), 635 639 push_history(Line), 636 640 {ok, Line}; 641 + paste_start -> 642 + %% Bracketed paste: insert literally (no key bindings), one redraw. 643 + NewLeft = read_bracketed_paste(Left), 644 + redraw(Prompt, NewLeft, Right), 645 + raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 646 + paste_end -> 647 + %% Stray end marker (paste not open) — ignore. 648 + raw_loop(Prompt, Left, Right, History, HistPos, Saved); 637 649 {char, C} when is_integer(C), C >= 32, C =/= 127 -> 638 - %% Printable Unicode codepoint 639 - NewLeft = [C | Left], 650 + %% Printable Unicode codepoint. Drain any already-queued paste burst 651 + %% so we insert every letter and only redraw once (avoids drops when 652 + %% a multi-char read or ESC fragment would otherwise lose bytes). 653 + Burst = drain_printable_burst(C), 654 + NewLeft = lists:reverse(Burst) ++ Left, 640 655 redraw(Prompt, NewLeft, Right), 641 656 raw_loop(Prompt, NewLeft, Right, History, 0, <<>>); 642 657 backspace -> ··· 1737 1752 %% When the stdin mux is running (raw REPL), all key bytes come from that 1738 1753 %% process so PTY relay / interrupt watch can retarget without spawning a 1739 1754 %% second get_chars client (see start_stdin_mux/0). 1755 + %% 1756 + %% Push-back queue (`gleshell_key_q`): codepoints waiting to be consumed. 1757 + %% Critical when a single io payload decodes to multiple characters — the old 1758 + %% `<<C/utf8, _/binary>>` path kept only the first and dropped the rest, which 1759 + %% turned paste bursts like "gleam" into "geam" / "gem" / etc. 1740 1760 %% --------------------------------------------------------------------------- 1741 1761 1742 1762 read_key() -> 1743 - case read_key_byte() of 1744 - eof -> 1745 - eof; 1746 - {error, Reason} -> 1747 - {error, Reason}; 1748 - C when is_integer(C) -> 1749 - decode_key(C, <<>>) 1763 + case pop_unread_key() of 1764 + {ok, K} -> 1765 + K; 1766 + empty -> 1767 + case read_key_byte() of 1768 + eof -> 1769 + eof; 1770 + {error, Reason} -> 1771 + {error, Reason}; 1772 + C when is_integer(C) -> 1773 + decode_key(C) 1774 + end 1775 + end. 1776 + 1777 + %% Non-blocking: next already-queued codepoint or mux message, else `none`. 1778 + try_read_key_byte() -> 1779 + case pop_key_q() of 1780 + {ok, C} -> 1781 + C; 1782 + empty -> 1783 + case get(gleshell_stdin_mux) of 1784 + Mux when is_pid(Mux) -> 1785 + receive 1786 + {gleshell_stdin, eof} -> 1787 + eof; 1788 + {gleshell_stdin, {error, Reason}} -> 1789 + {error, Reason}; 1790 + {gleshell_stdin, Data} -> 1791 + case enqueue_key_data(Data) of 1792 + empty -> 1793 + try_read_key_byte(); 1794 + C when is_integer(C) -> 1795 + C 1796 + end 1797 + after 0 -> 1798 + none 1799 + end; 1800 + _ -> 1801 + none 1802 + end 1750 1803 end. 1751 1804 1752 1805 %% One logical input unit (codepoint or raw byte) for the line editor / CSI. 1753 1806 read_key_byte() -> 1754 - case get(gleshell_stdin_mux) of 1755 - Mux when is_pid(Mux) -> 1756 - receive 1757 - {gleshell_stdin, eof} -> 1758 - eof; 1759 - {gleshell_stdin, {error, Reason}} -> 1760 - {error, Reason}; 1761 - {gleshell_stdin, Data} -> 1762 - case key_data_to_codepoint(Data) of 1763 - empty -> 1764 - read_key_byte(); 1765 - C when is_integer(C) -> 1766 - C 1767 - end 1768 - end; 1769 - _ -> 1770 - read_key_byte_direct() 1807 + case pop_key_q() of 1808 + {ok, C} -> 1809 + C; 1810 + empty -> 1811 + case get(gleshell_stdin_mux) of 1812 + Mux when is_pid(Mux) -> 1813 + receive 1814 + {gleshell_stdin, eof} -> 1815 + eof; 1816 + {gleshell_stdin, {error, Reason}} -> 1817 + {error, Reason}; 1818 + {gleshell_stdin, Data} -> 1819 + case enqueue_key_data(Data) of 1820 + empty -> 1821 + read_key_byte(); 1822 + C when is_integer(C) -> 1823 + C 1824 + end 1825 + end; 1826 + _ -> 1827 + read_key_byte_direct() 1828 + end 1771 1829 end. 1772 1830 1773 1831 read_key_byte_direct() -> ··· 1777 1835 {error, Reason} -> 1778 1836 {error, Reason}; 1779 1837 Data -> 1780 - case key_data_to_codepoint(Data) of 1838 + case enqueue_key_data(Data) of 1781 1839 empty -> 1782 1840 read_key_byte_direct(); 1783 1841 C when is_integer(C) -> ··· 1785 1843 end 1786 1844 end. 1787 1845 1788 - key_data_to_codepoint(Data) -> 1846 + %% Decode IO data into codepoints: return the first, queue the rest. 1847 + enqueue_key_data(Data) -> 1789 1848 Bin = io_data_to_bin(Data), 1790 1849 case Bin of 1791 1850 <<>> -> 1792 1851 empty; 1793 - <<C/utf8, _/binary>> -> 1794 - C; 1795 - <<C, _/binary>> when is_integer(C) -> 1796 - C; 1797 1852 _ -> 1798 1853 case unicode:characters_to_list(Bin) of 1799 - [C | _] when is_integer(C) -> 1854 + [C | Rest] when is_integer(C) -> 1855 + unread_codepoints(Rest), 1800 1856 C; 1857 + [] -> 1858 + empty; 1859 + {incomplete, Done, _Rest} when is_list(Done) -> 1860 + case Done of 1861 + [C | Rest] when is_integer(C) -> 1862 + unread_codepoints(Rest), 1863 + C; 1864 + [] -> 1865 + %% Incomplete UTF-8 lead — keep raw bytes so we do 1866 + %% not invent latin1 garbage; wait for more data. 1867 + unread_codepoints(binary_to_byte_list(Bin)), 1868 + case pop_key_q() of 1869 + {ok, C} -> C; 1870 + empty -> empty 1871 + end; 1872 + _ -> 1873 + empty 1874 + end; 1875 + {error, Done, _Rest} when is_list(Done) -> 1876 + case Done of 1877 + [C | Rest] when is_integer(C) -> 1878 + unread_codepoints(Rest), 1879 + C; 1880 + _ -> 1881 + fallback_first_byte(Bin) 1882 + end; 1801 1883 _ -> 1802 - empty 1884 + fallback_first_byte(Bin) 1803 1885 end 1804 1886 end. 1805 1887 1806 - decode_key($\r, _) -> enter; 1807 - decode_key($\n, _) -> enter; 1808 - decode_key($\t, _) -> tab; 1809 - decode_key(127, _) -> backspace; 1810 - decode_key($\b, _) -> backspace; 1811 - decode_key(1, _) -> ctrl_a; 1812 - decode_key(5, _) -> ctrl_e; 1813 - decode_key(4, _) -> ctrl_d; 1814 - decode_key(3, _) -> ctrl_c; 1815 - decode_key(11, _) -> ctrl_k; 1816 - decode_key(21, _) -> ctrl_u; 1817 - decode_key(23, _) -> ctrl_w; 1818 - decode_key(12, _) -> ctrl_l; 1819 - decode_key(18, _) -> ctrl_r; 1820 - decode_key(6, _) -> ctrl_f; 1821 - decode_key(16, _) -> ctrl_p; 1822 - decode_key(14, _) -> ctrl_n; 1823 - decode_key(7, _) -> ctrl_g; 1824 - decode_key(?ESC, _) -> 1888 + fallback_first_byte(<<C, Rest/binary>>) when is_integer(C) -> 1889 + unread_codepoints(binary_to_byte_list(Rest)), 1890 + C; 1891 + fallback_first_byte(_) -> 1892 + empty. 1893 + 1894 + binary_to_byte_list(Bin) when is_binary(Bin) -> 1895 + binary_to_list(Bin). 1896 + 1897 + pop_key_q() -> 1898 + case get(gleshell_key_q) of 1899 + [C | Rest] when is_integer(C) -> 1900 + put(gleshell_key_q, Rest), 1901 + {ok, C}; 1902 + _ -> 1903 + put(gleshell_key_q, []), 1904 + empty 1905 + end. 1906 + 1907 + %% Prepend so the next pop returns these in order (Cs is chronological). 1908 + unread_codepoints([]) -> 1909 + ok; 1910 + unread_codepoints(Cs) when is_list(Cs) -> 1911 + Q = 1912 + case get(gleshell_key_q) of 1913 + Q0 when is_list(Q0) -> Q0; 1914 + _ -> [] 1915 + end, 1916 + put(gleshell_key_q, Cs ++ Q), 1917 + ok. 1918 + 1919 + unread_key(Key) -> 1920 + case get(gleshell_key_unread) of 1921 + L when is_list(L) -> 1922 + put(gleshell_key_unread, [Key | L]); 1923 + _ -> 1924 + put(gleshell_key_unread, [Key]) 1925 + end. 1926 + 1927 + pop_unread_key() -> 1928 + case get(gleshell_key_unread) of 1929 + [K | Rest] -> 1930 + put(gleshell_key_unread, Rest), 1931 + {ok, K}; 1932 + _ -> 1933 + put(gleshell_key_unread, []), 1934 + empty 1935 + end. 1936 + 1937 + %% Drain a paste/typeahead burst of printable chars already in the queue/mailbox. 1938 + %% Acc starts with the first char; returns chronological list of codepoints. 1939 + drain_printable_burst(First) when is_integer(First) -> 1940 + drain_printable_burst_loop([First]). 1941 + 1942 + drain_printable_burst_loop(AccRev) -> 1943 + case try_next_printable() of 1944 + {char, C} -> 1945 + drain_printable_burst_loop([C | AccRev]); 1946 + none -> 1947 + %% Brief yield: mux may still be forwarding the rest of a paste. 1948 + receive 1949 + after 2 -> 1950 + case try_next_printable() of 1951 + {char, C} -> 1952 + drain_printable_burst_loop([C | AccRev]); 1953 + none -> 1954 + lists:reverse(AccRev) 1955 + end 1956 + end 1957 + end. 1958 + 1959 + try_next_printable() -> 1960 + case pop_unread_key() of 1961 + {ok, {char, C}} when is_integer(C), C >= 32, C =/= 127 -> 1962 + {char, C}; 1963 + {ok, Other} -> 1964 + unread_key(Other), 1965 + none; 1966 + empty -> 1967 + case try_read_key_byte() of 1968 + none -> 1969 + none; 1970 + eof -> 1971 + unread_key(eof), 1972 + none; 1973 + {error, Reason} -> 1974 + unread_key({error, Reason}), 1975 + none; 1976 + C when is_integer(C) -> 1977 + case decode_key(C) of 1978 + {char, Ch} when is_integer(Ch), Ch >= 32, Ch =/= 127 -> 1979 + {char, Ch}; 1980 + Other -> 1981 + unread_key(Other), 1982 + none 1983 + end 1984 + end 1985 + end. 1986 + 1987 + %% Bracketed paste body until paste_end. Newlines → space (single-line editor). 1988 + read_bracketed_paste(Left) -> 1989 + case read_key() of 1990 + paste_end -> 1991 + Left; 1992 + paste_start -> 1993 + read_bracketed_paste(Left); 1994 + {char, C} when is_integer(C), C >= 32, C =/= 127 -> 1995 + read_bracketed_paste([C | Left]); 1996 + enter -> 1997 + read_bracketed_paste([$\s | Left]); 1998 + eof -> 1999 + Left; 2000 + {error, _} -> 2001 + Left; 2002 + ctrl_c -> 2003 + Left; 2004 + _Other -> 2005 + read_bracketed_paste(Left) 2006 + end. 2007 + 2008 + enable_bracketed_paste() -> 2009 + %% Ask the terminal to wrap pastes in ESC[200~ … ESC[201~. 2010 + catch io:put_chars("\e[?2004h"), 2011 + ok. 2012 + 2013 + disable_bracketed_paste() -> 2014 + catch io:put_chars("\e[?2004l"), 2015 + ok. 2016 + 2017 + decode_key($\r) -> enter; 2018 + decode_key($\n) -> enter; 2019 + decode_key($\t) -> tab; 2020 + decode_key(127) -> backspace; 2021 + decode_key($\b) -> backspace; 2022 + decode_key(1) -> ctrl_a; 2023 + decode_key(5) -> ctrl_e; 2024 + decode_key(4) -> ctrl_d; 2025 + decode_key(3) -> ctrl_c; 2026 + decode_key(11) -> ctrl_k; 2027 + decode_key(21) -> ctrl_u; 2028 + decode_key(23) -> ctrl_w; 2029 + decode_key(12) -> ctrl_l; 2030 + decode_key(18) -> ctrl_r; 2031 + decode_key(6) -> ctrl_f; 2032 + decode_key(16) -> ctrl_p; 2033 + decode_key(14) -> ctrl_n; 2034 + decode_key(7) -> ctrl_g; 2035 + decode_key(?ESC) -> 1825 2036 read_escape(); 1826 - decode_key(C, _) when is_integer(C), C >= 32 -> 2037 + decode_key(C) when is_integer(C), C >= 32 -> 1827 2038 {char, C}; 1828 - decode_key(_, _) -> 2039 + decode_key(_) -> 1829 2040 other. 1830 2041 1831 2042 read_escape() -> ··· 1849 2060 $D -> left; 1850 2061 $H -> home; 1851 2062 $F -> 'end'; 1852 - _ -> other 2063 + Other when is_integer(Other) -> 2064 + %% Do not drop the follow-up byte (paste / unbound meta). 2065 + unread_codepoints([Other]), 2066 + other; 2067 + _ -> 2068 + other 1853 2069 end; 1854 2070 %% Alt+letter arrives as ESC then the letter (meta). 1855 2071 $f -> 1856 2072 alt_f; 1857 2073 $F -> 1858 2074 alt_f; 2075 + C when is_integer(C), C >= 32 -> 2076 + %% Unbound Alt+key or ESC fragment mid-paste: keep the letter. 2077 + %% (Old path returned `esc` and *discarded* C — "gleam" → "geam".) 2078 + {char, C}; 1859 2079 _ -> 1860 - %% Unknown ESC sequence — Escape is the usual cancel key in TUIs. 1861 2080 esc 1862 2081 end. 1863 2082 ··· 1884 2103 "6" -> page_down; 1885 2104 "7" -> home; 1886 2105 "8" -> 'end'; 2106 + "200" -> paste_start; 2107 + "201" -> paste_end; 1887 2108 _ -> other 1888 2109 end; 1889 2110 $A ->
+34 -1
test/gleshell_test.gleam
··· 102 102 Nil 103 103 } 104 104 105 + pub fn lexer_ssh_user_at_host_test() { 106 + // Mid-token `@` is part of the bareword (git SSH URLs, user@host). 107 + let assert Ok(tokens) = 108 + lexer.tokenize("git clone git@tangled.org:tranquil.farm/tranquil-pds") 109 + let assert [ 110 + lexer.Ident("git"), 111 + lexer.Ident("clone"), 112 + lexer.Ident("git@tangled.org"), 113 + lexer.Colon, 114 + lexer.Ident("tranquil.farm/tranquil-pds"), 115 + lexer.Eof, 116 + ] = tokens 117 + // Bare `@` at a word boundary is still an error (not a valid bareword start). 118 + let assert Error(lexer.LexError("unexpected character '@'", 0)) = 119 + lexer.tokenize("@alone") 120 + Nil 121 + } 122 + 123 + pub fn parse_ssh_git_url_test() { 124 + // Colon glue reassembly: `git@host:path` → one argv string. 125 + let assert Ok(parser.Expr(parser.Pipeline([ 126 + parser.Command("git", args, False), 127 + ]))) = 128 + parser.parse("git clone git@tangled.org:tranquil.farm/tranquil-pds") 129 + let assert [ 130 + parser.ValueArg(parser.Lit(String("clone"))), 131 + parser.ValueArg( 132 + parser.Lit(String("git@tangled.org:tranquil.farm/tranquil-pds")), 133 + ), 134 + ] = args 135 + Nil 136 + } 137 + 105 138 pub fn parse_flake_ref_hash_test() { 106 139 let assert Ok(parser.Expr(parser.Pipeline([ 107 140 parser.Command("nix", args, False), ··· 242 275 243 276 /// Pipeline input must become the external's stdin (`cat f | less`, `echo hi | wc`). 244 277 /// Use `let` so the last stage is capture mode even on a TTY (bare expressions 245 - /// may inherit the terminal and return an empty string value). 278 + /// inherit the terminal by default and return an empty string value). 246 279 pub fn eval_pipeline_stdin_to_external_test() { 247 280 let env = env.new() 248 281 // `echo` is a builtin; `wc` is external. Count bytes of "hello" (no trailing NL).