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

Configure Feed

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

Initial commit: gleshell, a structured-data shell in Gleam

Nushell-inspired REPL with typed pipelines, builtins (ls, where,
select, etc.), Nix flake/dev shell, and CI.

author
nandi
date (Jul 24, 2026, 11:44 PM -0700) commit a7b7c419
+3406
+23
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: 6 + - master 7 + - main 8 + pull_request: 9 + 10 + jobs: 11 + test: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v6 15 + - uses: erlef/setup-beam@v1 16 + with: 17 + otp-version: "29" 18 + gleam-version: "1.17.0" 19 + rebar3-version: "3" 20 + # elixir-version: "1" 21 + - run: gleam deps download 22 + - run: gleam test 23 + - run: gleam format --check src test
+11
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump 5 + 6 + # Nix / devenv 7 + /result 8 + /result-* 9 + /.devenv 10 + /.direnv 11 + /devenv.local.nix
+105
README.md
··· 1 + # gleshell 2 + 3 + A **structured-data shell** written in [Gleam](https://gleam.run), inspired by [Nushell](https://www.nushell.sh). 4 + 5 + Instead of piping opaque text between programs, gleshell pipelines pass typed values: strings, numbers, lists, records, and tables. Built-in commands like `ls`, `where`, and `select` work on that structure. 6 + 7 + ```text 8 + gleshell:gleshell> ls | where type == file | select name size | first 5 9 + ╭──────┬──────╮ 10 + │ name │ size │ 11 + ├──────┼──────┤ 12 + │ … │ … │ 13 + ╰──────┴──────╯ 14 + ``` 15 + 16 + ## Quick start 17 + 18 + Requires [Gleam](https://gleam.run/getting-started/) and Erlang (or use Nix): 19 + 20 + ```bash 21 + # with Nix flake (dev shell: gleam, erlang, rebar3) 22 + nix develop 23 + gleam run # interactive REPL 24 + gleam run -- -c 'ls | first 3' 25 + gleam test 26 + 27 + # or one-shot from the repo root 28 + nix run . -- -c 'ls | first 3' 29 + 30 + # or if gleam is already on PATH 31 + gleam run 32 + ``` 33 + 34 + ## Examples 35 + 36 + ```nu 37 + # list files as a table, filter, project columns 38 + ls | where type == file | select name size 39 + 40 + # ranges and list ops 41 + range 10 | reverse | first 3 42 + range 5 | length 43 + 44 + # records and JSON 45 + echo {name: "gleshell", cool: true} 46 + echo "{\"a\": 1}" | from-json | get a 47 + open data.json | get users | first 48 + 49 + # variables 50 + let n = range 3 | length 51 + echo $n 52 + 53 + # external programs (stdout captured as a string) 54 + ^uname -a 55 + which ls 56 + ``` 57 + 58 + ## Language sketch 59 + 60 + | Feature | Syntax | 61 + |--------|--------| 62 + | Pipeline | `cmd \| cmd \| cmd` | 63 + | Strings | `"hello"` or bare words `hello` | 64 + | Numbers | `42`, `3.14` | 65 + | Bools | `true` / `false` | 66 + | Nothing | `null` / `nothing` | 67 + | Lists | `[1 2 3]` | 68 + | Records | `{name: alice, age: 30}` | 69 + | Variables | `let x = …` then `$x` (pipeline input is `$in`) | 70 + | Flags | `--flag` / `--flag value` | 71 + | Force external | `^command args…` | 72 + | Comments | `# …` | 73 + 74 + ## Built-ins 75 + 76 + Filesystem: `ls`, `cd`, `pwd`, `cat`, `open`, `save` 77 + 78 + Table/list: `where`/`filter`, `select`, `get`, `first`, `last`, `take`, `skip`, `sort-by`, `reverse`, `length`, `columns`, `table`, `flatten`, `uniq`, `wrap`, `unwrap`, `keys`, `values` 79 + 80 + Data: `echo`, `range`, `lines`, `to-json`, `from-json`, `type`, `describe`, `env`, `sys`, `which`, `help`, `exit` 81 + 82 + Unknown command names fall through to external executables on `PATH`. 83 + 84 + ## Layout 85 + 86 + ```text 87 + src/ 88 + gleshell.gleam # entry + REPL 89 + gleshell_ffi.erl # readline, cwd, process spawn 90 + gleshell/ 91 + value.gleam # structured Value type 92 + lexer.gleam / parser.gleam 93 + eval.gleam # pipeline evaluator 94 + builtins.gleam # Nu-inspired commands 95 + display.gleam # table pretty-printer 96 + env.gleam / sys.gleam 97 + ``` 98 + 99 + ## Status 100 + 101 + Early but usable: core pipeline model, tables, filters, JSON, and external commands work. Not a full Nushell clone (no closures/plugins yet). Contributions and experiments welcome. 102 + 103 + ## License 104 + 105 + Apache-2.0 (same as the Gleam project template unless you change it).
+13
devenv.nix
··· 1 + { pkgs, ... }: 2 + 3 + { 4 + packages = with pkgs; [ 5 + gleam 6 + beamPackages.erlang 7 + rebar3 8 + ]; 9 + 10 + enterShell = '' 11 + echo "gleshell devenv — gleam run / gleam test / gleam run -- -c 'ls | first 3'" 12 + ''; 13 + }
+310
flake.lock
··· 1 + { 2 + "nodes": { 3 + "cachix": { 4 + "inputs": { 5 + "devenv": [ 6 + "devenv" 7 + ], 8 + "flake-compat": [ 9 + "devenv", 10 + "flake-compat" 11 + ], 12 + "git-hooks": [ 13 + "devenv", 14 + "git-hooks" 15 + ], 16 + "nixpkgs": [ 17 + "devenv", 18 + "nixpkgs" 19 + ] 20 + }, 21 + "locked": { 22 + "lastModified": 1777487137, 23 + "narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=", 24 + "owner": "cachix", 25 + "repo": "cachix", 26 + "rev": "a66a440c321d35f7193472c317f42a55ccd1cb93", 27 + "type": "github" 28 + }, 29 + "original": { 30 + "owner": "cachix", 31 + "ref": "latest", 32 + "repo": "cachix", 33 + "type": "github" 34 + } 35 + }, 36 + "crate2nix": { 37 + "flake": false, 38 + "locked": { 39 + "lastModified": 1772186516, 40 + "narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=", 41 + "owner": "rossng", 42 + "repo": "crate2nix", 43 + "rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e", 44 + "type": "github" 45 + }, 46 + "original": { 47 + "owner": "rossng", 48 + "repo": "crate2nix", 49 + "rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e", 50 + "type": "github" 51 + } 52 + }, 53 + "devenv": { 54 + "inputs": { 55 + "cachix": "cachix", 56 + "crate2nix": "crate2nix", 57 + "flake-compat": "flake-compat", 58 + "flake-parts": "flake-parts", 59 + "ghostty": "ghostty", 60 + "git-hooks": "git-hooks", 61 + "nix": "nix", 62 + "nixd": "nixd", 63 + "nixpkgs": [ 64 + "nixpkgs" 65 + ], 66 + "rust-overlay": "rust-overlay" 67 + }, 68 + "locked": { 69 + "lastModified": 1784939196, 70 + "narHash": "sha256-wY6Lll/lmIh2icBNoviibNwdrbhUQwdS7J92VIvdAxw=", 71 + "owner": "cachix", 72 + "repo": "devenv", 73 + "rev": "60b80be9e10ea07ff79c44a39741a75afd74f6a9", 74 + "type": "github" 75 + }, 76 + "original": { 77 + "owner": "cachix", 78 + "repo": "devenv", 79 + "type": "github" 80 + } 81 + }, 82 + "flake-compat": { 83 + "flake": false, 84 + "locked": { 85 + "lastModified": 1767039857, 86 + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", 87 + "owner": "edolstra", 88 + "repo": "flake-compat", 89 + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", 90 + "type": "github" 91 + }, 92 + "original": { 93 + "owner": "edolstra", 94 + "repo": "flake-compat", 95 + "type": "github" 96 + } 97 + }, 98 + "flake-parts": { 99 + "inputs": { 100 + "nixpkgs-lib": [ 101 + "devenv", 102 + "nixpkgs" 103 + ] 104 + }, 105 + "locked": { 106 + "lastModified": 1778716662, 107 + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", 108 + "owner": "hercules-ci", 109 + "repo": "flake-parts", 110 + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", 111 + "type": "github" 112 + }, 113 + "original": { 114 + "owner": "hercules-ci", 115 + "repo": "flake-parts", 116 + "type": "github" 117 + } 118 + }, 119 + "ghostty": { 120 + "flake": false, 121 + "locked": { 122 + "lastModified": 1784602798, 123 + "narHash": "sha256-298x90knBUWX5GHGXh2SKsAKvStjU2ri9UgOGoF79/8=", 124 + "owner": "ghostty-org", 125 + "repo": "ghostty", 126 + "rev": "88b4cd047fa627cdca6781bc7e7dc8b75a2cecb9", 127 + "type": "github" 128 + }, 129 + "original": { 130 + "owner": "ghostty-org", 131 + "repo": "ghostty", 132 + "type": "github" 133 + } 134 + }, 135 + "git-hooks": { 136 + "inputs": { 137 + "flake-compat": [ 138 + "devenv", 139 + "flake-compat" 140 + ], 141 + "nixpkgs": [ 142 + "devenv", 143 + "nixpkgs" 144 + ] 145 + }, 146 + "locked": { 147 + "lastModified": 1782908218, 148 + "narHash": "sha256-wLMOrPgVyeF3XmP+qfYcLqnVdTxikdcSvbIY7rA9jTA=", 149 + "owner": "cachix", 150 + "repo": "git-hooks.nix", 151 + "rev": "9f7e99119ece7705299595299f3b031f39356de1", 152 + "type": "github" 153 + }, 154 + "original": { 155 + "owner": "cachix", 156 + "repo": "git-hooks.nix", 157 + "type": "github" 158 + } 159 + }, 160 + "nix": { 161 + "inputs": { 162 + "flake-compat": [ 163 + "devenv", 164 + "flake-compat" 165 + ], 166 + "flake-parts": [ 167 + "devenv", 168 + "flake-parts" 169 + ], 170 + "git-hooks-nix": [ 171 + "devenv", 172 + "git-hooks" 173 + ], 174 + "nixpkgs": [ 175 + "devenv", 176 + "nixpkgs" 177 + ], 178 + "nixpkgs-23-11": [ 179 + "devenv" 180 + ], 181 + "nixpkgs-regression": [ 182 + "devenv" 183 + ] 184 + }, 185 + "locked": { 186 + "lastModified": 1782407171, 187 + "narHash": "sha256-xem+4ncdQCTFJsQ4PrVuyVmi3j4w/Yqg298hBUzVejA=", 188 + "owner": "cachix", 189 + "repo": "nix", 190 + "rev": "782ac1b155679b065ec945ae50d0fa1d495883b7", 191 + "type": "github" 192 + }, 193 + "original": { 194 + "owner": "cachix", 195 + "ref": "devenv-2.34", 196 + "repo": "nix", 197 + "type": "github" 198 + } 199 + }, 200 + "nixd": { 201 + "inputs": { 202 + "flake-parts": [ 203 + "devenv", 204 + "flake-parts" 205 + ], 206 + "nixpkgs": [ 207 + "devenv", 208 + "nixpkgs" 209 + ], 210 + "treefmt-nix": "treefmt-nix" 211 + }, 212 + "locked": { 213 + "lastModified": 1783935112, 214 + "narHash": "sha256-IAQ14nteIKXAz4cd75UcZrsHGEVJ7QNrkUwX9rmeZ/Y=", 215 + "owner": "nix-community", 216 + "repo": "nixd", 217 + "rev": "a64cd33e53b316b6b092ea0a966640cd2309bf3d", 218 + "type": "github" 219 + }, 220 + "original": { 221 + "owner": "nix-community", 222 + "repo": "nixd", 223 + "type": "github" 224 + } 225 + }, 226 + "nixpkgs": { 227 + "locked": { 228 + "lastModified": 1784796856, 229 + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", 230 + "owner": "NixOS", 231 + "repo": "nixpkgs", 232 + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", 233 + "type": "github" 234 + }, 235 + "original": { 236 + "owner": "NixOS", 237 + "ref": "nixos-unstable", 238 + "repo": "nixpkgs", 239 + "type": "github" 240 + } 241 + }, 242 + "root": { 243 + "inputs": { 244 + "devenv": "devenv", 245 + "nixpkgs": "nixpkgs", 246 + "systems": "systems" 247 + } 248 + }, 249 + "rust-overlay": { 250 + "inputs": { 251 + "nixpkgs": [ 252 + "devenv", 253 + "nixpkgs" 254 + ] 255 + }, 256 + "locked": { 257 + "lastModified": 1782875958, 258 + "narHash": "sha256-5eqDcnBjb1424HRQdnhuhNOBZguq1Z2tqSa2OMF/m2c=", 259 + "owner": "oxalica", 260 + "repo": "rust-overlay", 261 + "rev": "13139aefa973f3d96c60c0fbab801de058ae25ca", 262 + "type": "github" 263 + }, 264 + "original": { 265 + "owner": "oxalica", 266 + "repo": "rust-overlay", 267 + "type": "github" 268 + } 269 + }, 270 + "systems": { 271 + "locked": { 272 + "lastModified": 1681028828, 273 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 274 + "owner": "nix-systems", 275 + "repo": "default", 276 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 277 + "type": "github" 278 + }, 279 + "original": { 280 + "owner": "nix-systems", 281 + "repo": "default", 282 + "type": "github" 283 + } 284 + }, 285 + "treefmt-nix": { 286 + "inputs": { 287 + "nixpkgs": [ 288 + "devenv", 289 + "nixd", 290 + "nixpkgs" 291 + ] 292 + }, 293 + "locked": { 294 + "lastModified": 1780220602, 295 + "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", 296 + "owner": "numtide", 297 + "repo": "treefmt-nix", 298 + "rev": "db947814a175b7ca6ded66e21383d938df01c227", 299 + "type": "github" 300 + }, 301 + "original": { 302 + "owner": "numtide", 303 + "repo": "treefmt-nix", 304 + "type": "github" 305 + } 306 + } 307 + }, 308 + "root": "root", 309 + "version": 7 310 + }
+91
flake.nix
··· 1 + { 2 + description = "gleshell — structured-data shell in Gleam, inspired by Nushell"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + systems.url = "github:nix-systems/default"; 7 + devenv.url = "github:cachix/devenv"; 8 + devenv.inputs.nixpkgs.follows = "nixpkgs"; 9 + }; 10 + 11 + nixConfig = { 12 + extra-substituters = [ "https://devenv.cachix.org" ]; 13 + extra-trusted-public-keys = [ 14 + "devenv.cachix.org-1:ppRjvZf4JrQqfA3n7eHkSxX4z1M0RKZ77N8D66zCFAI=" 15 + ]; 16 + }; 17 + 18 + outputs = 19 + { 20 + self, 21 + nixpkgs, 22 + devenv, 23 + systems, 24 + ... 25 + }@inputs: 26 + let 27 + forEachSystem = nixpkgs.lib.genAttrs (import systems); 28 + in 29 + { 30 + packages = forEachSystem ( 31 + system: 32 + let 33 + pkgs = nixpkgs.legacyPackages.${system}; 34 + runtimeInputs = [ 35 + pkgs.gleam 36 + pkgs.beamPackages.erlang 37 + pkgs.rebar3 38 + ]; 39 + # Thin wrapper: needs the gleshell source tree (cwd or GLESHELL_ROOT). 40 + gleshell = pkgs.writeShellApplication { 41 + name = "gleshell"; 42 + inherit runtimeInputs; 43 + text = '' 44 + root="''${GLESHELL_ROOT:-}" 45 + if [ -z "$root" ]; then 46 + if [ -f gleam.toml ] && grep -q 'name = "gleshell"' gleam.toml 2>/dev/null; then 47 + root="$PWD" 48 + else 49 + echo "gleshell: run from the gleshell repo root, or set GLESHELL_ROOT" >&2 50 + exit 1 51 + fi 52 + fi 53 + cd "$root" 54 + exec gleam run -- "$@" 55 + ''; 56 + }; 57 + in 58 + { 59 + default = gleshell; 60 + inherit gleshell; 61 + } 62 + ); 63 + 64 + apps = forEachSystem (system: { 65 + default = { 66 + type = "app"; 67 + program = "${self.packages.${system}.gleshell}/bin/gleshell"; 68 + }; 69 + }); 70 + 71 + devShells = forEachSystem ( 72 + system: 73 + let 74 + pkgs = nixpkgs.legacyPackages.${system}; 75 + in 76 + { 77 + default = devenv.lib.mkShell { 78 + inherit inputs pkgs; 79 + modules = [ 80 + { 81 + devenv.root = builtins.toString ./.; 82 + } 83 + ./devenv.nix 84 + ]; 85 + }; 86 + } 87 + ); 88 + 89 + formatter = forEachSystem (system: nixpkgs.legacyPackages.${system}.nixfmt-rfc-style); 90 + }; 91 + }
+14
gleam.toml
··· 1 + name = "gleshell" 2 + version = "0.1.0" 3 + description = "A structured-data shell in Gleam, inspired by Nushell" 4 + licences = ["Apache-2.0"] 5 + 6 + [dependencies] 7 + gleam_stdlib = ">= 1.0.3 and < 2.0.0" 8 + argv = ">= 1.1.0 and < 2.0.0" 9 + simplifile = ">= 2.6.0 and < 3.0.0" 10 + gleam_json = ">= 3.1.0 and < 4.0.0" 11 + filepath = ">= 1.1.2 and < 2.0.0" 12 + 13 + [dev_dependencies] 14 + gleeunit = ">= 1.0.0 and < 2.0.0"
+24
manifest.toml
··· 1 + # Do not manually edit this file, it is managed by Gleam. 2 + # 3 + # This file locks the dependency versions used, to make your build 4 + # deterministic and to prevent unexpected versions from being included 5 + # in your application. 6 + # 7 + # You should check this file into your source control repository. 8 + 9 + packages = [ 10 + { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, 11 + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 12 + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 13 + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, 14 + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, 15 + { name = "simplifile", version = "2.6.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A33C345F0A4FFB91DCCD4220114534A58C387964A5F17B3E472CEBD1ADA9FFB4" }, 16 + ] 17 + 18 + [requirements] 19 + argv = { version = ">= 1.1.0 and < 2.0.0" } 20 + filepath = { version = ">= 1.1.2 and < 2.0.0" } 21 + gleam_json = { version = ">= 3.1.0 and < 4.0.0" } 22 + gleam_stdlib = { version = ">= 1.0.3 and < 2.0.0" } 23 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 24 + simplifile = { version = ">= 2.6.0 and < 3.0.0" }
+149
src/gleshell.gleam
··· 1 + //// gleshell — a structured-data shell in Gleam, inspired by Nushell. 2 + 3 + import argv 4 + import gleam/io 5 + import gleam/string 6 + import gleshell/display 7 + import gleshell/env 8 + import gleshell/eval 9 + import gleshell/sys 10 + import gleshell/value.{Nothing} 11 + 12 + pub fn main() -> Nil { 13 + case argv.load().arguments { 14 + ["-h"] | ["--help"] | ["help"] -> { 15 + print_usage() 16 + Nil 17 + } 18 + ["-c", code] -> run_once(code) 19 + ["-c"] -> { 20 + io.println_error("gleshell: -c requires a command string") 21 + halt(2) 22 + } 23 + [] -> repl(env.new()) 24 + args -> run_once(string.join(args, " ")) 25 + } 26 + } 27 + 28 + fn print_usage() -> Nil { 29 + io.println(string.join( 30 + [ 31 + "gleshell — Gleam shell inspired by Nushell", 32 + "", 33 + "Usage:", 34 + " gleshell Interactive REPL", 35 + " gleshell -c <code> Evaluate a one-liner", 36 + " gleshell <code>… Evaluate remaining args as code", 37 + "", 38 + "Examples:", 39 + " gleshell -c 'ls | where type == file | first 5'", 40 + " gleshell -c 'range 10 | reverse | first 3'", 41 + " gleshell -c 'echo {name: \"gleshell\", cool: true}'", 42 + "", 43 + "In the REPL, type `help` for built-in commands.", 44 + ], 45 + "\n", 46 + )) 47 + } 48 + 49 + fn run_once(code: String) -> Nil { 50 + let env = env.new() 51 + case eval.eval_source(env, code) { 52 + eval.Quit(code) -> halt(code) 53 + eval.Continue(_env, value) -> { 54 + print_value(value) 55 + case value { 56 + value.Fail(_) -> halt(1) 57 + _ -> Nil 58 + } 59 + } 60 + } 61 + } 62 + 63 + fn repl(env: env.Env) -> Nil { 64 + io.println( 65 + "gleshell 0.1 — structured data shell (type `help`, `exit` to quit)", 66 + ) 67 + repl_loop(env) 68 + } 69 + 70 + fn repl_loop(env: env.Env) -> Nil { 71 + let prompt = prompt_for(env) 72 + case sys.get_line(prompt) { 73 + Error("eof") -> { 74 + io.println("") 75 + Nil 76 + } 77 + Error(e) -> { 78 + io.println_error("read error: " <> e) 79 + Nil 80 + } 81 + Ok(line) -> { 82 + let line = string.trim(line) 83 + case line { 84 + "" -> repl_loop(env) 85 + _ -> 86 + case eval.eval_source(env, line) { 87 + eval.Quit(code) -> { 88 + case code { 89 + 0 -> Nil 90 + _ -> halt(code) 91 + } 92 + } 93 + eval.Continue(env2, value) -> { 94 + print_value(value) 95 + repl_loop(env2) 96 + } 97 + } 98 + } 99 + } 100 + } 101 + } 102 + 103 + fn prompt_for(env: env.Env) -> String { 104 + let base = basename(env.cwd) 105 + "gleshell:" <> base <> "> " 106 + } 107 + 108 + fn basename(path: String) -> String { 109 + case string.split(path, "/") { 110 + [] -> path 111 + parts -> 112 + case list_last(parts) { 113 + "" -> 114 + // path ended with / 115 + case parts { 116 + [only] -> only 117 + _ -> { 118 + // take second last non-empty if possible 119 + path 120 + } 121 + } 122 + name -> name 123 + } 124 + } 125 + } 126 + 127 + fn list_last(items: List(String)) -> String { 128 + case items { 129 + [] -> "" 130 + [x] -> x 131 + [_, ..rest] -> list_last(rest) 132 + } 133 + } 134 + 135 + fn print_value(value: value.Value) -> Nil { 136 + case value { 137 + Nothing -> Nil 138 + _ -> { 139 + let text = display.render(value) 140 + case text { 141 + "" -> Nil 142 + t -> io.println(t) 143 + } 144 + } 145 + } 146 + } 147 + 148 + @external(erlang, "erlang", "halt") 149 + fn halt(status: Int) -> Nil
+1138
src/gleshell/builtins.gleam
··· 1 + //// Built-in commands (Nushell-inspired structured data tools). 2 + 3 + import filepath 4 + import gleam/dict 5 + import gleam/dynamic/decode 6 + import gleam/float 7 + import gleam/int 8 + import gleam/json 9 + import gleam/list 10 + import gleam/option 11 + import gleam/order 12 + import gleam/string 13 + import gleshell/env.{type Env} 14 + import gleshell/sys 15 + import gleshell/value.{ 16 + type Value, Bool, Fail, Float, Int, List, Nothing, Record, String, Table, 17 + } 18 + import simplifile 19 + 20 + pub type BuiltinResult { 21 + BuiltinResult(env: Env, value: Value) 22 + Exit(code: Int) 23 + } 24 + 25 + pub type Builtin = 26 + fn(Env, Value, List(Value), dict.Dict(String, Value)) -> BuiltinResult 27 + 28 + pub fn registry() -> dict.Dict(String, Builtin) { 29 + dict.from_list([ 30 + #("help", cmd_help), 31 + #("echo", cmd_echo), 32 + #("print", cmd_echo), 33 + #("ls", cmd_ls), 34 + #("pwd", cmd_pwd), 35 + #("cd", cmd_cd), 36 + #("cat", cmd_cat), 37 + #("open", cmd_open), 38 + #("save", cmd_save), 39 + #("where", cmd_where), 40 + #("filter", cmd_where), 41 + #("select", cmd_select), 42 + #("get", cmd_get), 43 + #("first", cmd_first), 44 + #("last", cmd_last), 45 + #("take", cmd_take), 46 + #("skip", cmd_skip), 47 + #("length", cmd_length), 48 + #("count", cmd_length), 49 + #("reverse", cmd_reverse), 50 + #("sort-by", cmd_sort_by), 51 + #("sort_by", cmd_sort_by), 52 + #("uniq", cmd_uniq), 53 + #("wrap", cmd_wrap), 54 + #("unwrap", cmd_unwrap), 55 + #("to-json", cmd_to_json), 56 + #("to_json", cmd_to_json), 57 + #("from-json", cmd_from_json), 58 + #("from_json", cmd_from_json), 59 + #("lines", cmd_lines), 60 + #("typeof", cmd_type), 61 + #("type", cmd_type), 62 + #("describe", cmd_describe), 63 + #("env", cmd_env), 64 + #("which", cmd_which), 65 + #("exit", cmd_exit), 66 + #("quit", cmd_exit), 67 + #("ignore", cmd_ignore), 68 + #("identity", cmd_identity), 69 + #("range", cmd_range), 70 + #("append", cmd_append), 71 + #("prepend", cmd_prepend), 72 + #("is-empty", cmd_is_empty), 73 + #("is_empty", cmd_is_empty), 74 + #("table", cmd_table), 75 + #("columns", cmd_columns), 76 + #("flatten", cmd_flatten), 77 + #("values", cmd_values), 78 + #("keys", cmd_keys), 79 + #("sys", cmd_sys), 80 + ]) 81 + } 82 + 83 + pub fn names() -> List(String) { 84 + registry() 85 + |> dict.keys 86 + |> list.sort(string.compare) 87 + } 88 + 89 + fn ok(env: Env, value: Value) -> BuiltinResult { 90 + BuiltinResult(env, value) 91 + } 92 + 93 + fn err(env: Env, msg: String) -> BuiltinResult { 94 + BuiltinResult(env.set_exit(env, 1), Fail(msg)) 95 + } 96 + 97 + // --- help --- 98 + 99 + fn cmd_help( 100 + env: Env, 101 + _input: Value, 102 + args: List(Value), 103 + _flags: dict.Dict(String, Value), 104 + ) -> BuiltinResult { 105 + case args { 106 + [String(name)] -> 107 + case dict.get(help_text(), name) { 108 + Ok(text) -> ok(env, String(text)) 109 + Error(Nil) -> err(env, "unknown command: " <> name) 110 + } 111 + _ -> { 112 + let lines = 113 + list.append( 114 + [ 115 + "gleshell — a Gleam shell inspired by Nushell", 116 + "", 117 + "Pipelines pass structured data (not just text):", 118 + " ls | where type == file | select name size", 119 + " open data.json | get users | first 3", 120 + " range 5 | reverse", 121 + "", 122 + "Commands:", 123 + ], 124 + list.append(list.map(names(), fn(n) { " " <> n }), [ 125 + "", 126 + "Use `help <command>` for details. `^cmd` forces an external binary.", 127 + "Variables: `let x = ...` then `$x`. Pipeline input is `$in`.", 128 + ]), 129 + ) 130 + ok(env, String(string.join(lines, "\n"))) 131 + } 132 + } 133 + } 134 + 135 + fn help_text() -> dict.Dict(String, String) { 136 + dict.from_list([ 137 + #("ls", "ls [path] — list directory entries as a table"), 138 + #("cd", "cd [path] — change directory (~ supported)"), 139 + #("pwd", "pwd — print working directory"), 140 + #("cat", "cat <path> — read file as string"), 141 + #("open", "open <path> — open file; parses .json into structured data"), 142 + #("save", "save <path> — save pipeline input to a file"), 143 + #( 144 + "where", 145 + "where <field> <op> <value> — filter rows (ops: == != > < >= <=)", 146 + ), 147 + #("select", "select <col>… — keep only named columns"), 148 + #("get", "get <field|index> — get a field or list index"), 149 + #("first", "first [n] — first row/item (default 1)"), 150 + #("last", "last [n] — last row/item"), 151 + #("take", "take <n> — take first n rows"), 152 + #("skip", "skip <n> — skip first n rows"), 153 + #("echo", "echo <values>… — emit values (list if multiple)"), 154 + #("to-json", "to-json — convert input to JSON string"), 155 + #("from-json", "from-json — parse JSON string input"), 156 + #("range", "range <end> | range <start> <end> — integer range list"), 157 + #("sys", "sys — host info record"), 158 + ]) 159 + } 160 + 161 + // --- echo --- 162 + 163 + fn cmd_echo( 164 + env: Env, 165 + input: Value, 166 + args: List(Value), 167 + _flags: dict.Dict(String, Value), 168 + ) -> BuiltinResult { 169 + case args { 170 + [] -> ok(env, input) 171 + [single] -> ok(env, single) 172 + many -> ok(env, List(many)) 173 + } 174 + } 175 + 176 + // --- fs --- 177 + 178 + fn cmd_pwd( 179 + env: Env, 180 + _input: Value, 181 + _args: List(Value), 182 + _flags: dict.Dict(String, Value), 183 + ) -> BuiltinResult { 184 + ok(env, String(env.cwd)) 185 + } 186 + 187 + fn cmd_cd( 188 + env: Env, 189 + _input: Value, 190 + args: List(Value), 191 + _flags: dict.Dict(String, Value), 192 + ) -> BuiltinResult { 193 + let target = case args { 194 + [] | [String("~")] -> 195 + case sys.home_dir() { 196 + Ok(h) -> h 197 + Error(_) -> "/" 198 + } 199 + [String(path)] -> resolve_path(env, path) 200 + _ -> "" 201 + } 202 + case target { 203 + "" -> err(env, "cd: expected path") 204 + path -> 205 + case env.set_cwd(env, path) { 206 + Ok(env2) -> ok(env2, Nothing) 207 + Error(e) -> err(env, "cd: " <> e) 208 + } 209 + } 210 + } 211 + 212 + fn resolve_path(env: Env, path: String) -> String { 213 + case path { 214 + "" -> env.cwd 215 + "~" -> 216 + case sys.home_dir() { 217 + Ok(h) -> h 218 + Error(_) -> path 219 + } 220 + _ -> 221 + case string.starts_with(path, "/") { 222 + True -> path 223 + False -> 224 + case string.starts_with(path, "~/") { 225 + True -> 226 + case sys.home_dir() { 227 + Ok(h) -> filepath.join(h, string.drop_start(path, 2)) 228 + Error(_) -> path 229 + } 230 + False -> filepath.join(env.cwd, path) 231 + } 232 + } 233 + } 234 + } 235 + 236 + fn cmd_ls( 237 + env: Env, 238 + _input: Value, 239 + args: List(Value), 240 + _flags: dict.Dict(String, Value), 241 + ) -> BuiltinResult { 242 + let path = case args { 243 + [String(p)] -> resolve_path(env, p) 244 + _ -> env.cwd 245 + } 246 + case simplifile.read_directory(path) { 247 + Error(e) -> err(env, "ls: " <> simplifile.describe_error(e)) 248 + Ok(names) -> { 249 + let names = list.sort(names, string.compare) 250 + let records = 251 + list.map(names, fn(name) { 252 + let full = filepath.join(path, name) 253 + case simplifile.file_info(full) { 254 + Error(_) -> 255 + Record([ 256 + #("name", String(name)), 257 + #("type", String("unknown")), 258 + #("size", Int(0)), 259 + ]) 260 + Ok(info) -> { 261 + let ftype = case simplifile.file_info_type(info) { 262 + simplifile.File -> "file" 263 + simplifile.Directory -> "dir" 264 + simplifile.Symlink -> "symlink" 265 + simplifile.Other -> "other" 266 + } 267 + Record([ 268 + #("name", String(name)), 269 + #("type", String(ftype)), 270 + #("size", Int(info.size)), 271 + ]) 272 + } 273 + } 274 + }) 275 + ok(env, value.table_from_records(records)) 276 + } 277 + } 278 + } 279 + 280 + fn cmd_cat( 281 + env: Env, 282 + _input: Value, 283 + args: List(Value), 284 + _flags: dict.Dict(String, Value), 285 + ) -> BuiltinResult { 286 + case args { 287 + [String(path)] -> { 288 + let path = resolve_path(env, path) 289 + case simplifile.read(path) { 290 + Ok(content) -> ok(env, String(content)) 291 + Error(e) -> err(env, "cat: " <> simplifile.describe_error(e)) 292 + } 293 + } 294 + _ -> err(env, "cat: expected path") 295 + } 296 + } 297 + 298 + fn cmd_open( 299 + env: Env, 300 + _input: Value, 301 + args: List(Value), 302 + _flags: dict.Dict(String, Value), 303 + ) -> BuiltinResult { 304 + case args { 305 + [String(path)] -> { 306 + let path = resolve_path(env, path) 307 + case simplifile.read(path) { 308 + Error(e) -> err(env, "open: " <> simplifile.describe_error(e)) 309 + Ok(content) -> 310 + case string.ends_with(string.lowercase(path), ".json") { 311 + True -> 312 + case parse_json_value(content) { 313 + Ok(v) -> ok(env, v) 314 + Error(msg) -> err(env, "open: " <> msg) 315 + } 316 + False -> ok(env, String(content)) 317 + } 318 + } 319 + } 320 + _ -> err(env, "open: expected path") 321 + } 322 + } 323 + 324 + fn cmd_save( 325 + env: Env, 326 + input: Value, 327 + args: List(Value), 328 + _flags: dict.Dict(String, Value), 329 + ) -> BuiltinResult { 330 + case args { 331 + [String(path)] -> { 332 + let path = resolve_path(env, path) 333 + let body = case input { 334 + String(s) -> s 335 + other -> value.as_string(other) 336 + } 337 + case simplifile.write(to: path, contents: body) { 338 + Ok(Nil) -> ok(env, Nothing) 339 + Error(e) -> err(env, "save: " <> simplifile.describe_error(e)) 340 + } 341 + } 342 + _ -> err(env, "save: expected path") 343 + } 344 + } 345 + 346 + // --- table ops --- 347 + 348 + fn cmd_where( 349 + env: Env, 350 + input: Value, 351 + args: List(Value), 352 + _flags: dict.Dict(String, Value), 353 + ) -> BuiltinResult { 354 + case args { 355 + [String(field), String(op), rhs] -> { 356 + case value.table_to_records(input) { 357 + Error(e) -> err(env, "where: " <> e) 358 + Ok(rows) -> { 359 + let kept = 360 + list.filter(rows, fn(row) { row_matches(row, field, op, rhs) }) 361 + ok(env, value.table_from_records(kept)) 362 + } 363 + } 364 + } 365 + _ -> 366 + err(env, "where: expected `where <field> <op> <value>` e.g. type == file") 367 + } 368 + } 369 + 370 + fn row_matches(row: Value, field: String, op: String, rhs: Value) -> Bool { 371 + case value.get_field(row, field) { 372 + Error(_) -> False 373 + Ok(lhs) -> 374 + case op { 375 + "==" | "eq" -> value.equals(lhs, rhs) 376 + "!=" | "ne" -> !value.equals(lhs, rhs) 377 + ">" | "gt" -> 378 + case value.compare(lhs, rhs) { 379 + Ok(value.Gt) -> True 380 + _ -> False 381 + } 382 + "<" | "lt" -> 383 + case value.compare(lhs, rhs) { 384 + Ok(value.Lt) -> True 385 + _ -> False 386 + } 387 + ">=" | "ge" -> 388 + case value.compare(lhs, rhs) { 389 + Ok(value.Gt) | Ok(value.Eq) -> True 390 + _ -> False 391 + } 392 + "<=" | "le" -> 393 + case value.compare(lhs, rhs) { 394 + Ok(value.Lt) | Ok(value.Eq) -> True 395 + _ -> False 396 + } 397 + _ -> False 398 + } 399 + } 400 + } 401 + 402 + fn cmd_select( 403 + env: Env, 404 + input: Value, 405 + args: List(Value), 406 + _flags: dict.Dict(String, Value), 407 + ) -> BuiltinResult { 408 + let cols = 409 + list.filter_map(args, fn(a) { 410 + case a { 411 + String(s) -> Ok(s) 412 + _ -> Error(Nil) 413 + } 414 + }) 415 + case cols { 416 + [] -> err(env, "select: expected column names") 417 + cols -> 418 + case value.table_to_records(input) { 419 + Error(e) -> err(env, "select: " <> e) 420 + Ok(rows) -> { 421 + let selected = 422 + list.map(rows, fn(row) { 423 + case row { 424 + Record(fields) -> 425 + Record( 426 + list.map(cols, fn(c) { 427 + case list.key_find(fields, c) { 428 + Ok(v) -> #(c, v) 429 + Error(Nil) -> #(c, Nothing) 430 + } 431 + }), 432 + ) 433 + other -> other 434 + } 435 + }) 436 + ok(env, value.table_from_records(selected)) 437 + } 438 + } 439 + } 440 + } 441 + 442 + fn cmd_get( 443 + env: Env, 444 + input: Value, 445 + args: List(Value), 446 + _flags: dict.Dict(String, Value), 447 + ) -> BuiltinResult { 448 + case args { 449 + [Int(i)] -> { 450 + let rows = value.as_rows(input) 451 + case list_at(rows, i) { 452 + Ok(v) -> ok(env, v) 453 + Error(Nil) -> err(env, "get: index out of bounds") 454 + } 455 + } 456 + [String(key)] -> 457 + case input { 458 + Record(_) -> 459 + case value.get_field(input, key) { 460 + Ok(v) -> ok(env, v) 461 + Error(e) -> err(env, "get: " <> e) 462 + } 463 + Table(cols, rows) -> 464 + case list_index_of(cols, key) { 465 + Ok(idx) -> { 466 + let col_vals = 467 + list.map(rows, fn(row) { 468 + case list_at(row, idx) { 469 + Ok(v) -> v 470 + Error(Nil) -> Nothing 471 + } 472 + }) 473 + ok(env, List(col_vals)) 474 + } 475 + Error(Nil) -> err(env, "get: no column '" <> key <> "'") 476 + } 477 + List(items) -> { 478 + let got = 479 + list.filter_map(items, fn(item) { 480 + case value.get_field(item, key) { 481 + Ok(v) -> Ok(v) 482 + Error(_) -> Error(Nil) 483 + } 484 + }) 485 + ok(env, List(got)) 486 + } 487 + _ -> err(env, "get: unsupported input type " <> value.type_name(input)) 488 + } 489 + _ -> err(env, "get: expected field name or index") 490 + } 491 + } 492 + 493 + fn cmd_first( 494 + env: Env, 495 + input: Value, 496 + args: List(Value), 497 + _flags: dict.Dict(String, Value), 498 + ) -> BuiltinResult { 499 + let n = case args { 500 + [Int(i)] if i > 0 -> i 501 + _ -> 1 502 + } 503 + take_n(env, input, n, False) 504 + } 505 + 506 + fn cmd_last( 507 + env: Env, 508 + input: Value, 509 + args: List(Value), 510 + _flags: dict.Dict(String, Value), 511 + ) -> BuiltinResult { 512 + let n = case args { 513 + [Int(i)] if i > 0 -> i 514 + _ -> 1 515 + } 516 + take_n(env, input, n, True) 517 + } 518 + 519 + fn cmd_take( 520 + env: Env, 521 + input: Value, 522 + args: List(Value), 523 + _flags: dict.Dict(String, Value), 524 + ) -> BuiltinResult { 525 + case args { 526 + [Int(n)] -> take_n(env, input, n, False) 527 + _ -> err(env, "take: expected count") 528 + } 529 + } 530 + 531 + fn take_n(env: Env, input: Value, n: Int, from_end: Bool) -> BuiltinResult { 532 + case input { 533 + Table(cols, rows) -> { 534 + let rows = case from_end { 535 + True -> rows |> list.reverse |> list.take(n) |> list.reverse 536 + False -> list.take(rows, n) 537 + } 538 + case n == 1 { 539 + True -> 540 + case rows { 541 + [row] -> ok(env, Record(list.zip(cols, row))) 542 + _ -> ok(env, Table(cols, rows)) 543 + } 544 + False -> ok(env, Table(cols, rows)) 545 + } 546 + } 547 + List(items) -> { 548 + let items = case from_end { 549 + True -> items |> list.reverse |> list.take(n) |> list.reverse 550 + False -> list.take(items, n) 551 + } 552 + case n == 1 { 553 + True -> 554 + case items { 555 + [x] -> ok(env, x) 556 + _ -> ok(env, List(items)) 557 + } 558 + False -> ok(env, List(items)) 559 + } 560 + } 561 + String(s) -> { 562 + let graphemes = string.to_graphemes(s) 563 + let taken = case from_end { 564 + True -> graphemes |> list.reverse |> list.take(n) |> list.reverse 565 + False -> list.take(graphemes, n) 566 + } 567 + ok(env, String(string.concat(taken))) 568 + } 569 + other -> ok(env, other) 570 + } 571 + } 572 + 573 + fn cmd_skip( 574 + env: Env, 575 + input: Value, 576 + args: List(Value), 577 + _flags: dict.Dict(String, Value), 578 + ) -> BuiltinResult { 579 + case args { 580 + [Int(n)] -> 581 + case input { 582 + Table(cols, rows) -> ok(env, Table(cols, list.drop(rows, n))) 583 + List(items) -> ok(env, List(list.drop(items, n))) 584 + other -> ok(env, other) 585 + } 586 + _ -> err(env, "skip: expected count") 587 + } 588 + } 589 + 590 + fn cmd_length( 591 + env: Env, 592 + input: Value, 593 + _args: List(Value), 594 + _flags: dict.Dict(String, Value), 595 + ) -> BuiltinResult { 596 + ok(env, Int(value.length_of(input))) 597 + } 598 + 599 + fn cmd_reverse( 600 + env: Env, 601 + input: Value, 602 + _args: List(Value), 603 + _flags: dict.Dict(String, Value), 604 + ) -> BuiltinResult { 605 + case input { 606 + List(items) -> ok(env, List(list.reverse(items))) 607 + Table(cols, rows) -> ok(env, Table(cols, list.reverse(rows))) 608 + String(s) -> 609 + ok( 610 + env, 611 + String( 612 + s 613 + |> string.to_graphemes 614 + |> list.reverse 615 + |> string.concat, 616 + ), 617 + ) 618 + other -> ok(env, other) 619 + } 620 + } 621 + 622 + fn cmd_sort_by( 623 + env: Env, 624 + input: Value, 625 + args: List(Value), 626 + _flags: dict.Dict(String, Value), 627 + ) -> BuiltinResult { 628 + case args { 629 + [String(field)] -> 630 + case value.table_to_records(input) { 631 + Error(e) -> err(env, "sort-by: " <> e) 632 + Ok(rows) -> { 633 + let sorted = 634 + list.sort(rows, fn(a, b) { 635 + let va = case value.get_field(a, field) { 636 + Ok(v) -> v 637 + Error(_) -> Nothing 638 + } 639 + let vb = case value.get_field(b, field) { 640 + Ok(v) -> v 641 + Error(_) -> Nothing 642 + } 643 + case value.compare(va, vb) { 644 + Ok(value.Lt) -> order.Lt 645 + Ok(value.Gt) -> order.Gt 646 + Ok(value.Eq) -> order.Eq 647 + Error(_) -> 648 + string.compare(value.as_string(va), value.as_string(vb)) 649 + } 650 + }) 651 + ok(env, value.table_from_records(sorted)) 652 + } 653 + } 654 + _ -> err(env, "sort-by: expected field name") 655 + } 656 + } 657 + 658 + fn cmd_uniq( 659 + env: Env, 660 + input: Value, 661 + _args: List(Value), 662 + _flags: dict.Dict(String, Value), 663 + ) -> BuiltinResult { 664 + case input { 665 + List(items) -> { 666 + let uniqed = 667 + list.fold(items, [], fn(acc, item) { 668 + case list.any(acc, fn(x) { value.equals(x, item) }) { 669 + True -> acc 670 + False -> list.append(acc, [item]) 671 + } 672 + }) 673 + ok(env, List(uniqed)) 674 + } 675 + other -> ok(env, other) 676 + } 677 + } 678 + 679 + fn cmd_wrap( 680 + env: Env, 681 + input: Value, 682 + args: List(Value), 683 + _flags: dict.Dict(String, Value), 684 + ) -> BuiltinResult { 685 + case args { 686 + [String(name)] -> ok(env, Record([#(name, input)])) 687 + _ -> err(env, "wrap: expected column name") 688 + } 689 + } 690 + 691 + fn cmd_unwrap( 692 + env: Env, 693 + input: Value, 694 + args: List(Value), 695 + _flags: dict.Dict(String, Value), 696 + ) -> BuiltinResult { 697 + case args { 698 + [String(name)] -> 699 + case value.get_field(input, name) { 700 + Ok(v) -> ok(env, v) 701 + Error(e) -> err(env, "unwrap: " <> e) 702 + } 703 + [] -> 704 + case input { 705 + Record([#(_, v), ..]) -> ok(env, v) 706 + _ -> err(env, "unwrap: expected single-field record or field name") 707 + } 708 + _ -> err(env, "unwrap: expected field name") 709 + } 710 + } 711 + 712 + // --- json --- 713 + 714 + fn cmd_to_json( 715 + env: Env, 716 + input: Value, 717 + _args: List(Value), 718 + _flags: dict.Dict(String, Value), 719 + ) -> BuiltinResult { 720 + ok(env, String(value_to_json_string(input))) 721 + } 722 + 723 + fn cmd_from_json( 724 + env: Env, 725 + input: Value, 726 + args: List(Value), 727 + _flags: dict.Dict(String, Value), 728 + ) -> BuiltinResult { 729 + let source = case args { 730 + [String(s)] -> s 731 + [] -> 732 + case input { 733 + String(s) -> s 734 + _ -> value.as_string(input) 735 + } 736 + _ -> "" 737 + } 738 + case source { 739 + "" -> err(env, "from-json: empty input") 740 + s -> 741 + case parse_json_value(s) { 742 + Ok(v) -> ok(env, v) 743 + Error(msg) -> err(env, "from-json: " <> msg) 744 + } 745 + } 746 + } 747 + 748 + fn value_to_json_string(v: Value) -> String { 749 + case v { 750 + Nothing -> "null" 751 + Bool(True) -> "true" 752 + Bool(False) -> "false" 753 + Int(n) -> int.to_string(n) 754 + Float(f) -> float.to_string(f) 755 + String(s) -> json_escape(s) 756 + List(items) -> 757 + "[" <> string.join(list.map(items, value_to_json_string), ",") <> "]" 758 + Record(fields) -> 759 + "{" 760 + <> string.join( 761 + list.map(fields, fn(pair) { 762 + let #(k, val) = pair 763 + json_escape(k) <> ":" <> value_to_json_string(val) 764 + }), 765 + ",", 766 + ) 767 + <> "}" 768 + Table(cols, rows) -> { 769 + let records = list.map(rows, fn(row) { Record(list.zip(cols, row)) }) 770 + value_to_json_string(List(records)) 771 + } 772 + Fail(msg) -> json_escape("error: " <> msg) 773 + } 774 + } 775 + 776 + fn json_escape(s: String) -> String { 777 + let escaped = 778 + s 779 + |> string.replace("\\", "\\\\") 780 + |> string.replace("\"", "\\\"") 781 + |> string.replace("\n", "\\n") 782 + |> string.replace("\r", "\\r") 783 + |> string.replace("\t", "\\t") 784 + "\"" <> escaped <> "\"" 785 + } 786 + 787 + fn parse_json_value(source: String) -> Result(Value, String) { 788 + case string.trim(source) { 789 + "null" -> Ok(Nothing) 790 + s -> 791 + case json.parse(from: s, using: value_decoder()) { 792 + Ok(v) -> Ok(v) 793 + Error(e) -> Error(string.inspect(e)) 794 + } 795 + } 796 + } 797 + 798 + fn value_decoder() -> decode.Decoder(Value) { 799 + decode.recursive(fn() { 800 + decode.one_of(decode.map(decode.string, String), or: [ 801 + decode.map(decode.bool, Bool), 802 + decode.map(decode.int, Int), 803 + decode.map(decode.float, Float), 804 + decode.map(decode.list(value_decoder()), List), 805 + decode.map(decode.dict(decode.string, value_decoder()), fn(d) { 806 + let pairs = 807 + dict.to_list(d) 808 + |> list.sort(fn(a, b) { 809 + let #(ka, _) = a 810 + let #(kb, _) = b 811 + string.compare(ka, kb) 812 + }) 813 + Record(pairs) 814 + }), 815 + // JSON null → optional empty 816 + decode.map(decode.optional(decode.int), fn(opt) { 817 + case opt { 818 + option.None -> Nothing 819 + option.Some(n) -> Int(n) 820 + } 821 + }), 822 + ]) 823 + }) 824 + } 825 + 826 + // --- misc --- 827 + 828 + fn cmd_lines( 829 + env: Env, 830 + input: Value, 831 + _args: List(Value), 832 + _flags: dict.Dict(String, Value), 833 + ) -> BuiltinResult { 834 + case input { 835 + String(s) -> { 836 + let lines = list.map(string.split(s, "\n"), String) 837 + ok(env, List(lines)) 838 + } 839 + _ -> err(env, "lines: expected string input") 840 + } 841 + } 842 + 843 + fn cmd_type( 844 + env: Env, 845 + input: Value, 846 + _args: List(Value), 847 + _flags: dict.Dict(String, Value), 848 + ) -> BuiltinResult { 849 + ok(env, String(value.type_name(input))) 850 + } 851 + 852 + fn cmd_describe( 853 + env: Env, 854 + input: Value, 855 + _args: List(Value), 856 + _flags: dict.Dict(String, Value), 857 + ) -> BuiltinResult { 858 + ok( 859 + env, 860 + Record([ 861 + #("type", String(value.type_name(input))), 862 + #("length", Int(value.length_of(input))), 863 + #("value", String(value.as_string(input))), 864 + ]), 865 + ) 866 + } 867 + 868 + fn cmd_env( 869 + env: Env, 870 + _input: Value, 871 + args: List(Value), 872 + _flags: dict.Dict(String, Value), 873 + ) -> BuiltinResult { 874 + case args { 875 + [String(name)] -> ok(env, env.get_var(env, name)) 876 + [] -> { 877 + let pairs = 878 + env.vars 879 + |> dict.to_list 880 + |> list.map(fn(pair) { 881 + let #(k, v) = pair 882 + Record([ 883 + #("name", String(k)), 884 + #("value", String(value.as_string(v))), 885 + ]) 886 + }) 887 + ok(env, value.table_from_records(pairs)) 888 + } 889 + _ -> err(env, "env: unexpected args") 890 + } 891 + } 892 + 893 + fn cmd_which( 894 + env: Env, 895 + _input: Value, 896 + args: List(Value), 897 + _flags: dict.Dict(String, Value), 898 + ) -> BuiltinResult { 899 + case args { 900 + [String(name)] -> 901 + case dict.has_key(registry(), name) { 902 + True -> ok(env, String("builtin: " <> name)) 903 + False -> 904 + case sys.which(name) { 905 + Ok(path) -> ok(env, String(path)) 906 + Error(Nil) -> err(env, "which: " <> name <> " not found") 907 + } 908 + } 909 + _ -> err(env, "which: expected name") 910 + } 911 + } 912 + 913 + fn cmd_exit( 914 + _env: Env, 915 + _input: Value, 916 + args: List(Value), 917 + _flags: dict.Dict(String, Value), 918 + ) -> BuiltinResult { 919 + let code = case args { 920 + [Int(n)] -> n 921 + _ -> 0 922 + } 923 + Exit(code) 924 + } 925 + 926 + fn cmd_ignore( 927 + env: Env, 928 + _input: Value, 929 + _args: List(Value), 930 + _flags: dict.Dict(String, Value), 931 + ) -> BuiltinResult { 932 + ok(env, Nothing) 933 + } 934 + 935 + fn cmd_identity( 936 + env: Env, 937 + input: Value, 938 + _args: List(Value), 939 + _flags: dict.Dict(String, Value), 940 + ) -> BuiltinResult { 941 + ok(env, input) 942 + } 943 + 944 + fn cmd_range( 945 + env: Env, 946 + _input: Value, 947 + args: List(Value), 948 + _flags: dict.Dict(String, Value), 949 + ) -> BuiltinResult { 950 + case args { 951 + [Int(end)] -> ok(env, List(range_list(0, end))) 952 + [Int(start), Int(end)] -> ok(env, List(range_list(start, end))) 953 + _ -> err(env, "range: expected `range <end>` or `range <start> <end>`") 954 + } 955 + } 956 + 957 + fn range_list(start: Int, end: Int) -> List(Value) { 958 + case start >= end { 959 + True -> [] 960 + False -> [Int(start), ..range_list(start + 1, end)] 961 + } 962 + } 963 + 964 + fn cmd_append( 965 + env: Env, 966 + input: Value, 967 + args: List(Value), 968 + _flags: dict.Dict(String, Value), 969 + ) -> BuiltinResult { 970 + case input { 971 + List(items) -> ok(env, List(list.append(items, args))) 972 + _ -> err(env, "append: expected list input") 973 + } 974 + } 975 + 976 + fn cmd_prepend( 977 + env: Env, 978 + input: Value, 979 + args: List(Value), 980 + _flags: dict.Dict(String, Value), 981 + ) -> BuiltinResult { 982 + case input { 983 + List(items) -> ok(env, List(list.append(args, items))) 984 + _ -> err(env, "prepend: expected list input") 985 + } 986 + } 987 + 988 + fn cmd_is_empty( 989 + env: Env, 990 + input: Value, 991 + _args: List(Value), 992 + _flags: dict.Dict(String, Value), 993 + ) -> BuiltinResult { 994 + ok(env, Bool(value.length_of(input) == 0)) 995 + } 996 + 997 + fn cmd_table( 998 + env: Env, 999 + input: Value, 1000 + _args: List(Value), 1001 + _flags: dict.Dict(String, Value), 1002 + ) -> BuiltinResult { 1003 + case value.table_to_records(input) { 1004 + Ok(rows) -> ok(env, value.table_from_records(rows)) 1005 + Error(e) -> err(env, "table: " <> e) 1006 + } 1007 + } 1008 + 1009 + fn cmd_columns( 1010 + env: Env, 1011 + input: Value, 1012 + _args: List(Value), 1013 + _flags: dict.Dict(String, Value), 1014 + ) -> BuiltinResult { 1015 + case input { 1016 + Table(cols, _) -> ok(env, List(list.map(cols, String))) 1017 + Record(fields) -> 1018 + ok( 1019 + env, 1020 + List( 1021 + list.map(fields, fn(p) { 1022 + let #(k, _) = p 1023 + String(k) 1024 + }), 1025 + ), 1026 + ) 1027 + _ -> err(env, "columns: expected table or record") 1028 + } 1029 + } 1030 + 1031 + fn cmd_flatten( 1032 + env: Env, 1033 + input: Value, 1034 + _args: List(Value), 1035 + _flags: dict.Dict(String, Value), 1036 + ) -> BuiltinResult { 1037 + case input { 1038 + List(items) -> { 1039 + let flat = 1040 + list.flat_map(items, fn(item) { 1041 + case item { 1042 + List(inner) -> inner 1043 + other -> [other] 1044 + } 1045 + }) 1046 + ok(env, List(flat)) 1047 + } 1048 + other -> ok(env, other) 1049 + } 1050 + } 1051 + 1052 + fn cmd_values( 1053 + env: Env, 1054 + input: Value, 1055 + _args: List(Value), 1056 + _flags: dict.Dict(String, Value), 1057 + ) -> BuiltinResult { 1058 + case input { 1059 + Record(fields) -> 1060 + ok( 1061 + env, 1062 + List( 1063 + list.map(fields, fn(p) { 1064 + let #(_, v) = p 1065 + v 1066 + }), 1067 + ), 1068 + ) 1069 + _ -> err(env, "values: expected record") 1070 + } 1071 + } 1072 + 1073 + fn cmd_keys( 1074 + env: Env, 1075 + input: Value, 1076 + _args: List(Value), 1077 + _flags: dict.Dict(String, Value), 1078 + ) -> BuiltinResult { 1079 + case input { 1080 + Record(fields) -> 1081 + ok( 1082 + env, 1083 + List( 1084 + list.map(fields, fn(p) { 1085 + let #(k, _) = p 1086 + String(k) 1087 + }), 1088 + ), 1089 + ) 1090 + _ -> err(env, "keys: expected record") 1091 + } 1092 + } 1093 + 1094 + fn cmd_sys( 1095 + env: Env, 1096 + _input: Value, 1097 + _args: List(Value), 1098 + _flags: dict.Dict(String, Value), 1099 + ) -> BuiltinResult { 1100 + let home = case sys.home_dir() { 1101 + Ok(h) -> h 1102 + Error(_) -> "" 1103 + } 1104 + ok( 1105 + env, 1106 + Record([ 1107 + #("cwd", String(env.cwd)), 1108 + #("home", String(home)), 1109 + #("shell", String("gleshell")), 1110 + #("last_exit", Int(env.last_exit)), 1111 + ]), 1112 + ) 1113 + } 1114 + 1115 + // --- helpers --- 1116 + 1117 + fn list_at(items: List(a), index: Int) -> Result(a, Nil) { 1118 + case items, index { 1119 + [x, ..], 0 -> Ok(x) 1120 + [_, ..rest], n if n > 0 -> list_at(rest, n - 1) 1121 + _, _ -> Error(Nil) 1122 + } 1123 + } 1124 + 1125 + fn list_index_of(items: List(a), target: a) -> Result(Int, Nil) { 1126 + list_index_of_loop(items, target, 0) 1127 + } 1128 + 1129 + fn list_index_of_loop(items: List(a), target: a, i: Int) -> Result(Int, Nil) { 1130 + case items { 1131 + [] -> Error(Nil) 1132 + [x, ..rest] -> 1133 + case x == target { 1134 + True -> Ok(i) 1135 + False -> list_index_of_loop(rest, target, i + 1) 1136 + } 1137 + } 1138 + }
+161
src/gleshell/display.gleam
··· 1 + //// Pretty-print structured values (Nushell-style tables). 2 + 3 + import gleam/int 4 + import gleam/list 5 + import gleam/string 6 + import gleshell/value.{type Value, Fail, List, Nothing, Record, Table} 7 + 8 + pub fn render(value: Value) -> String { 9 + case value { 10 + Nothing -> "" 11 + Fail(msg) -> "Error: " <> msg 12 + Table(cols, rows) -> render_table(cols, rows) 13 + List(items) -> { 14 + case list.all(items, is_record) { 15 + True -> 16 + case value.table_from_records(items) { 17 + Table(c, r) -> render_table(c, r) 18 + other -> render_value_block(other) 19 + } 20 + False -> render_list(items) 21 + } 22 + } 23 + Record(fields) -> render_record(fields) 24 + other -> value.cell_string(other) 25 + } 26 + } 27 + 28 + fn is_record(v: Value) -> Bool { 29 + case v { 30 + Record(_) -> True 31 + _ -> False 32 + } 33 + } 34 + 35 + fn render_value_block(v: Value) -> String { 36 + value.as_string(v) 37 + } 38 + 39 + fn render_list(items: List(Value)) -> String { 40 + case items { 41 + [] -> "[]" 42 + _ -> { 43 + let body = 44 + items 45 + |> list.index_map(fn(item, i) { 46 + " " <> int.to_string(i) <> " │ " <> value.cell_string(item) 47 + }) 48 + |> string.join("\n") 49 + "╭──── list ───\n" <> body <> "\n╰────────────" 50 + } 51 + } 52 + } 53 + 54 + fn render_record(fields: List(#(String, Value))) -> String { 55 + case fields { 56 + [] -> "{}" 57 + _ -> { 58 + let key_w = 59 + fields 60 + |> list.map(fn(p) { 61 + let #(k, _) = p 62 + string.length(k) 63 + }) 64 + |> list.fold(0, int.max) 65 + 66 + let lines = 67 + list.map(fields, fn(pair) { 68 + let #(k, v) = pair 69 + " " <> pad_right(k, key_w) <> " │ " <> value.cell_string(v) 70 + }) 71 + "╭──── record ───\n" <> string.join(lines, "\n") <> "\n╰──────────────" 72 + } 73 + } 74 + } 75 + 76 + pub fn render_table(columns: List(String), rows: List(List(Value))) -> String { 77 + case columns { 78 + [] -> "(empty table)" 79 + _ -> { 80 + let cells: List(List(String)) = 81 + list.map(rows, fn(row) { list.map(row, value.cell_string) }) 82 + 83 + let widths = 84 + list.index_map(columns, fn(col, i) { 85 + let header_w = string.length(col) 86 + let data_w = 87 + cells 88 + |> list.map(fn(row) { 89 + case list_at(row, i) { 90 + Ok(c) -> string.length(c) 91 + Error(Nil) -> 0 92 + } 93 + }) 94 + |> list.fold(header_w, int.max) 95 + int.max(data_w, 1) 96 + }) 97 + 98 + let top = box_line(widths, "╭", "┬", "╮", "─") 99 + let sep = box_line(widths, "├", "┼", "┤", "─") 100 + let bot = box_line(widths, "╰", "┴", "╯", "─") 101 + let header = data_line(columns, widths) 102 + let body = 103 + cells 104 + |> list.map(fn(row) { data_line(row, widths) }) 105 + |> string.join("\n") 106 + 107 + case body { 108 + "" -> top <> "\n" <> header <> "\n" <> bot 109 + _ -> top <> "\n" <> header <> "\n" <> sep <> "\n" <> body <> "\n" <> bot 110 + } 111 + } 112 + } 113 + } 114 + 115 + fn box_line( 116 + widths: List(Int), 117 + left: String, 118 + mid: String, 119 + right: String, 120 + fill: String, 121 + ) -> String { 122 + let segments = list.map(widths, fn(w) { string.repeat(fill, w + 2) }) 123 + left <> string.join(segments, mid) <> right 124 + } 125 + 126 + fn data_line(cells: List(String), widths: List(Int)) -> String { 127 + let padded = 128 + list.map2(cells, widths, fn(cell, w) { " " <> pad_right(cell, w) <> " " }) 129 + // if fewer cells than widths, pad 130 + let padded = case list.length(padded) < list.length(widths) { 131 + True -> { 132 + let extra = 133 + list.drop(widths, list.length(padded)) 134 + |> list.map(fn(w) { " " <> pad_right("", w) <> " " }) 135 + list.append(padded, extra) 136 + } 137 + False -> padded 138 + } 139 + "│" <> string.join(padded, "│") <> "│" 140 + } 141 + 142 + fn pad_right(s: String, width: Int) -> String { 143 + let len = string.length(s) 144 + case len >= width { 145 + True -> string.slice(s, 0, width) 146 + False -> s <> string.repeat(" ", width - len) 147 + } 148 + } 149 + 150 + fn list_at(items: List(a), index: Int) -> Result(a, Nil) { 151 + case items, index { 152 + [x, ..], 0 -> Ok(x) 153 + [_, ..rest], n if n > 0 -> list_at(rest, n - 1) 154 + _, _ -> Error(Nil) 155 + } 156 + } 157 + 158 + /// Color-friendly error line for the REPL. 159 + pub fn render_error(msg: String) -> String { 160 + "✗ " <> msg 161 + }
+62
src/gleshell/env.gleam
··· 1 + //// Shell environment: cwd, variables, last exit status. 2 + 3 + import gleam/dict.{type Dict} 4 + import gleshell/sys 5 + import gleshell/value.{type Value, Nothing, String} 6 + 7 + pub type Env { 8 + Env(cwd: String, vars: Dict(String, Value), last_exit: Int) 9 + } 10 + 11 + pub fn new() -> Env { 12 + let cwd = case sys.get_cwd() { 13 + Ok(c) -> c 14 + Error(_) -> "." 15 + } 16 + Env(cwd: cwd, vars: dict.new(), last_exit: 0) 17 + } 18 + 19 + pub fn get_var(env: Env, name: String) -> Value { 20 + case name { 21 + "PWD" | "pwd" -> String(env.cwd) 22 + "in" -> 23 + case dict.get(env.vars, "in") { 24 + Ok(v) -> v 25 + Error(Nil) -> Nothing 26 + } 27 + _ -> 28 + case dict.get(env.vars, name) { 29 + Ok(v) -> v 30 + Error(Nil) -> 31 + case sys.getenv(name) { 32 + Ok(s) -> String(s) 33 + Error(Nil) -> Nothing 34 + } 35 + } 36 + } 37 + } 38 + 39 + pub fn set_var(env: Env, name: String, value: Value) -> Env { 40 + Env(..env, vars: dict.insert(env.vars, name, value)) 41 + } 42 + 43 + pub fn set_input(env: Env, input: Value) -> Env { 44 + set_var(env, "in", input) 45 + } 46 + 47 + pub fn set_cwd(env: Env, path: String) -> Result(Env, String) { 48 + case sys.set_cwd(path) { 49 + Ok(Nil) -> { 50 + let cwd = case sys.get_cwd() { 51 + Ok(c) -> c 52 + Error(_) -> path 53 + } 54 + Ok(Env(..env, cwd: cwd)) 55 + } 56 + Error(e) -> Error(e) 57 + } 58 + } 59 + 60 + pub fn set_exit(env: Env, code: Int) -> Env { 61 + Env(..env, last_exit: code) 62 + }
+163
src/gleshell/eval.gleam
··· 1 + //// Evaluate pipelines against the environment. 2 + 3 + import gleam/dict 4 + import gleam/int 5 + import gleam/list 6 + import gleam/string 7 + import gleshell/builtins 8 + import gleshell/env.{type Env} 9 + import gleshell/parser.{ 10 + type Arg, type Command, type Expr, type Pipeline, type Statement, FlagArg, Let, 11 + ListExpr, Lit, RecordExpr, ValueArg, Var, 12 + } 13 + import gleshell/sys 14 + import gleshell/value.{type Value, Fail, List, Nothing, Record, String} 15 + 16 + pub type EvalResult { 17 + Continue(env: Env, value: Value) 18 + Quit(code: Int) 19 + } 20 + 21 + pub fn eval_source(env: Env, source: String) -> EvalResult { 22 + let source = string.trim(source) 23 + case source { 24 + "" -> Continue(env, Nothing) 25 + _ -> 26 + case parser.parse(source) { 27 + Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) 28 + Ok(stmt) -> eval_statement(env, stmt) 29 + } 30 + } 31 + } 32 + 33 + fn eval_statement(env: Env, stmt: Statement) -> EvalResult { 34 + case stmt { 35 + Let(name, pipeline) -> 36 + case eval_pipeline(env, pipeline, Nothing) { 37 + Quit(code) -> Quit(code) 38 + Continue(env2, value) -> { 39 + case value { 40 + Fail(_) -> Continue(env2, value) 41 + _ -> Continue(env.set_var(env2, name, value), value) 42 + } 43 + } 44 + } 45 + parser.Expr(pipeline) -> eval_pipeline(env, pipeline, Nothing) 46 + } 47 + } 48 + 49 + fn eval_pipeline(env: Env, pipeline: Pipeline, input: Value) -> EvalResult { 50 + case pipeline { 51 + parser.Pipeline(commands) -> 52 + list.fold(commands, Continue(env, input), fn(acc, cmd) { 53 + case acc { 54 + Quit(code) -> Quit(code) 55 + Continue(env2, value) -> 56 + case value { 57 + Fail(_) -> Continue(env2, value) 58 + _ -> eval_command(env2, cmd, value) 59 + } 60 + } 61 + }) 62 + } 63 + } 64 + 65 + fn eval_command(env: Env, cmd: Command, input: Value) -> EvalResult { 66 + case cmd { 67 + parser.Command(name, args, external) -> { 68 + let env = env.set_input(env, input) 69 + case eval_args(env, args) { 70 + Error(msg) -> Continue(env.set_exit(env, 1), Fail(msg)) 71 + Ok(#(pos, flags)) -> { 72 + case external { 73 + True -> run_external(env, name, pos) 74 + False -> 75 + case dict.get(builtins.registry(), name) { 76 + Ok(builtin) -> 77 + case builtin(env, input, pos, flags) { 78 + builtins.Exit(code) -> Quit(code) 79 + builtins.BuiltinResult(env2, value) -> { 80 + let env2 = case value { 81 + Fail(_) -> env.set_exit(env2, 1) 82 + _ -> env.set_exit(env2, 0) 83 + } 84 + Continue(env2, value) 85 + } 86 + } 87 + Error(Nil) -> run_external(env, name, pos) 88 + } 89 + } 90 + } 91 + } 92 + } 93 + } 94 + } 95 + 96 + fn eval_args( 97 + env: Env, 98 + args: List(Arg), 99 + ) -> Result(#(List(Value), dict.Dict(String, Value)), String) { 100 + list.try_fold(args, #([], dict.new()), fn(acc, arg) { 101 + let #(pos, flags) = acc 102 + case arg { 103 + ValueArg(expr) -> 104 + case eval_expr(env, expr) { 105 + Ok(v) -> Ok(#(list.append(pos, [v]), flags)) 106 + Error(e) -> Error(e) 107 + } 108 + FlagArg(name, parser.Some(expr)) -> 109 + case eval_expr(env, expr) { 110 + Ok(v) -> Ok(#(pos, dict.insert(flags, name, v))) 111 + Error(e) -> Error(e) 112 + } 113 + FlagArg(name, parser.None) -> 114 + Ok(#(pos, dict.insert(flags, name, value.Bool(True)))) 115 + } 116 + }) 117 + } 118 + 119 + fn eval_expr(env: Env, expr: Expr) -> Result(Value, String) { 120 + case expr { 121 + Lit(v) -> Ok(v) 122 + Var(name) -> Ok(env.get_var(env, name)) 123 + ListExpr(items) -> { 124 + case list.try_map(items, fn(e) { eval_expr(env, e) }) { 125 + Ok(vals) -> Ok(List(vals)) 126 + Error(e) -> Error(e) 127 + } 128 + } 129 + RecordExpr(fields) -> { 130 + case 131 + list.try_map(fields, fn(pair) { 132 + let #(k, e) = pair 133 + case eval_expr(env, e) { 134 + Ok(v) -> Ok(#(k, v)) 135 + Error(err) -> Error(err) 136 + } 137 + }) 138 + { 139 + Ok(pairs) -> Ok(Record(pairs)) 140 + Error(e) -> Error(e) 141 + } 142 + } 143 + } 144 + } 145 + 146 + fn run_external(env: Env, name: String, args: List(Value)) -> EvalResult { 147 + let str_args = list.map(args, value.as_string) 148 + case sys.run_cmd(name, str_args) { 149 + Error(msg) -> Continue(env.set_exit(env, 127), Fail(msg)) 150 + Ok(#(status, output)) -> { 151 + let output = string.trim_end(output) 152 + let env = env.set_exit(env, status) 153 + case status { 154 + 0 -> Continue(env, String(output)) 155 + _ -> 156 + Continue(env, case output { 157 + "" -> Fail(name <> " exited with status " <> int.to_string(status)) 158 + out -> String(out) 159 + }) 160 + } 161 + } 162 + } 163 + }
+284
src/gleshell/lexer.gleam
··· 1 + //// Tokenize shell input. 2 + 3 + import gleam/float 4 + import gleam/int 5 + import gleam/list 6 + import gleam/string 7 + 8 + pub type Token { 9 + Ident(String) 10 + StringLit(String) 11 + IntLit(Int) 12 + FloatLit(Float) 13 + BoolLit(Bool) 14 + Pipe 15 + LBracket 16 + RBracket 17 + LBrace 18 + RBrace 19 + LParen 20 + RParen 21 + Colon 22 + Comma 23 + Dollar 24 + Eq 25 + Ne 26 + Gt 27 + Lt 28 + Ge 29 + Le 30 + Assign 31 + Flag(String) 32 + External 33 + NothingLit 34 + Eof 35 + } 36 + 37 + pub type LexError { 38 + LexError(message: String, position: Int) 39 + } 40 + 41 + pub fn tokenize(source: String) -> Result(List(Token), LexError) { 42 + do_tokenize(string.to_graphemes(source), 0, []) 43 + } 44 + 45 + fn do_tokenize( 46 + chars: List(String), 47 + pos: Int, 48 + acc: List(Token), 49 + ) -> Result(List(Token), LexError) { 50 + case chars { 51 + [] -> Ok(list.reverse([Eof, ..acc])) 52 + [" ", ..rest] | ["\t", ..rest] | ["\r", ..rest] -> 53 + do_tokenize(rest, pos + 1, acc) 54 + ["\n", ..rest] -> do_tokenize(rest, pos + 1, acc) 55 + ["#", ..rest] -> skip_comment(rest, pos + 1, acc) 56 + ["|", ..rest] -> do_tokenize(rest, pos + 1, [Pipe, ..acc]) 57 + ["[", ..rest] -> do_tokenize(rest, pos + 1, [LBracket, ..acc]) 58 + ["]", ..rest] -> do_tokenize(rest, pos + 1, [RBracket, ..acc]) 59 + ["{", ..rest] -> do_tokenize(rest, pos + 1, [LBrace, ..acc]) 60 + ["}", ..rest] -> do_tokenize(rest, pos + 1, [RBrace, ..acc]) 61 + ["(", ..rest] -> do_tokenize(rest, pos + 1, [LParen, ..acc]) 62 + [")", ..rest] -> do_tokenize(rest, pos + 1, [RParen, ..acc]) 63 + [":", ..rest] -> do_tokenize(rest, pos + 1, [Colon, ..acc]) 64 + [",", ..rest] -> do_tokenize(rest, pos + 1, [Comma, ..acc]) 65 + ["$", ..rest] -> do_tokenize(rest, pos + 1, [Dollar, ..acc]) 66 + ["^", ..rest] -> do_tokenize(rest, pos + 1, [External, ..acc]) 67 + ["!", "=", ..rest] -> do_tokenize(rest, pos + 2, [Ne, ..acc]) 68 + [">", "=", ..rest] -> do_tokenize(rest, pos + 2, [Ge, ..acc]) 69 + ["<", "=", ..rest] -> do_tokenize(rest, pos + 2, [Le, ..acc]) 70 + ["=", "=", ..rest] -> do_tokenize(rest, pos + 2, [Eq, ..acc]) 71 + ["=", ..rest] -> do_tokenize(rest, pos + 1, [Assign, ..acc]) 72 + [">", ..rest] -> do_tokenize(rest, pos + 1, [Gt, ..acc]) 73 + ["<", ..rest] -> do_tokenize(rest, pos + 1, [Lt, ..acc]) 74 + ["\"", ..rest] -> { 75 + case read_string(rest, pos + 1, "") { 76 + Ok(#(s, after, new_pos)) -> 77 + do_tokenize(after, new_pos, [StringLit(s), ..acc]) 78 + Error(e) -> Error(e) 79 + } 80 + } 81 + ["'", ..rest] -> { 82 + case read_single_string(rest, pos + 1, "") { 83 + Ok(#(s, after, new_pos)) -> 84 + do_tokenize(after, new_pos, [StringLit(s), ..acc]) 85 + Error(e) -> Error(e) 86 + } 87 + } 88 + ["-", "-", ..rest] -> { 89 + let #(name, after, new_pos) = read_ident_body(rest, pos + 2, "") 90 + case name { 91 + "" -> Error(LexError("expected flag name after --", pos)) 92 + n -> do_tokenize(after, new_pos, [Flag(n), ..acc]) 93 + } 94 + } 95 + ["-", d, ..rest] -> { 96 + case is_digit(d) { 97 + True -> { 98 + let #(num_str, after, new_pos) = 99 + read_number(["-", d, ..rest], pos, "") 100 + case parse_number(num_str) { 101 + Ok(tok) -> do_tokenize(after, new_pos, [tok, ..acc]) 102 + Error(msg) -> Error(LexError(msg, pos)) 103 + } 104 + } 105 + False -> { 106 + // short flag -x 107 + let #(name, after, new_pos) = 108 + read_ident_body([d, ..rest], pos + 1, "") 109 + case name { 110 + "" -> Error(LexError("expected flag name after -", pos)) 111 + n -> do_tokenize(after, new_pos, [Flag(n), ..acc]) 112 + } 113 + } 114 + } 115 + } 116 + [c, ..] -> { 117 + case is_digit(c) { 118 + True -> { 119 + let #(num_str, after, new_pos) = read_number(chars, pos, "") 120 + case parse_number(num_str) { 121 + Ok(tok) -> do_tokenize(after, new_pos, [tok, ..acc]) 122 + Error(msg) -> Error(LexError(msg, pos)) 123 + } 124 + } 125 + False -> 126 + case is_ident_start(c) { 127 + True -> { 128 + let #(name, after, new_pos) = read_ident_body(chars, pos, "") 129 + let tok = keyword_or_ident(name) 130 + do_tokenize(after, new_pos, [tok, ..acc]) 131 + } 132 + False -> Error(LexError("unexpected character '" <> c <> "'", pos)) 133 + } 134 + } 135 + } 136 + } 137 + } 138 + 139 + fn skip_comment( 140 + chars: List(String), 141 + pos: Int, 142 + acc: List(Token), 143 + ) -> Result(List(Token), LexError) { 144 + case chars { 145 + [] -> do_tokenize([], pos, acc) 146 + ["\n", ..rest] -> do_tokenize(rest, pos + 1, acc) 147 + [_, ..rest] -> skip_comment(rest, pos + 1, acc) 148 + } 149 + } 150 + 151 + fn read_string( 152 + chars: List(String), 153 + pos: Int, 154 + acc: String, 155 + ) -> Result(#(String, List(String), Int), LexError) { 156 + case chars { 157 + [] -> Error(LexError("unterminated string", pos)) 158 + ["\"", ..rest] -> Ok(#(acc, rest, pos + 1)) 159 + ["\\", "n", ..rest] -> read_string(rest, pos + 2, acc <> "\n") 160 + ["\\", "t", ..rest] -> read_string(rest, pos + 2, acc <> "\t") 161 + ["\\", "\"", ..rest] -> read_string(rest, pos + 2, acc <> "\"") 162 + ["\\", "\\", ..rest] -> read_string(rest, pos + 2, acc <> "\\") 163 + ["\\", c, ..rest] -> read_string(rest, pos + 2, acc <> c) 164 + [c, ..rest] -> read_string(rest, pos + 1, acc <> c) 165 + } 166 + } 167 + 168 + fn read_single_string( 169 + chars: List(String), 170 + pos: Int, 171 + acc: String, 172 + ) -> Result(#(String, List(String), Int), LexError) { 173 + case chars { 174 + [] -> Error(LexError("unterminated string", pos)) 175 + ["'", ..rest] -> Ok(#(acc, rest, pos + 1)) 176 + ["\\", "'", ..rest] -> read_single_string(rest, pos + 2, acc <> "'") 177 + ["\\", "\\", ..rest] -> read_single_string(rest, pos + 2, acc <> "\\") 178 + [c, ..rest] -> read_single_string(rest, pos + 1, acc <> c) 179 + } 180 + } 181 + 182 + fn read_ident_body( 183 + chars: List(String), 184 + pos: Int, 185 + acc: String, 186 + ) -> #(String, List(String), Int) { 187 + case chars { 188 + [c, ..rest] -> 189 + case is_ident_continue(c) { 190 + True -> read_ident_body(rest, pos + 1, acc <> c) 191 + False -> #(acc, chars, pos) 192 + } 193 + [] -> #(acc, [], pos) 194 + } 195 + } 196 + 197 + fn read_number( 198 + chars: List(String), 199 + pos: Int, 200 + acc: String, 201 + ) -> #(String, List(String), Int) { 202 + case chars { 203 + ["-", ..rest] if acc == "" -> read_number(rest, pos + 1, "-") 204 + [c, ..rest] -> 205 + case is_digit(c) || c == "." { 206 + True -> read_number(rest, pos + 1, acc <> c) 207 + False -> #(acc, chars, pos) 208 + } 209 + [] -> #(acc, [], pos) 210 + } 211 + } 212 + 213 + fn parse_number(s: String) -> Result(Token, String) { 214 + case string.contains(s, ".") { 215 + True -> 216 + case float.parse(s) { 217 + Ok(f) -> Ok(FloatLit(f)) 218 + Error(Nil) -> Error("invalid float '" <> s <> "'") 219 + } 220 + False -> 221 + case int.parse(s) { 222 + Ok(n) -> Ok(IntLit(n)) 223 + Error(Nil) -> Error("invalid integer '" <> s <> "'") 224 + } 225 + } 226 + } 227 + 228 + fn keyword_or_ident(name: String) -> Token { 229 + case name { 230 + "true" | "True" -> BoolLit(True) 231 + "false" | "False" -> BoolLit(False) 232 + "null" | "nothing" | "Nothing" -> NothingLit 233 + _ -> Ident(name) 234 + } 235 + } 236 + 237 + fn is_digit(c: String) -> Bool { 238 + case c { 239 + "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True 240 + _ -> False 241 + } 242 + } 243 + 244 + fn is_ident_start(c: String) -> Bool { 245 + case c { 246 + "_" -> True 247 + _ -> { 248 + let lower = string.lowercase(c) 249 + case lower { 250 + "a" 251 + | "b" 252 + | "c" 253 + | "d" 254 + | "e" 255 + | "f" 256 + | "g" 257 + | "h" 258 + | "i" 259 + | "j" 260 + | "k" 261 + | "l" 262 + | "m" 263 + | "n" 264 + | "o" 265 + | "p" 266 + | "q" 267 + | "r" 268 + | "s" 269 + | "t" 270 + | "u" 271 + | "v" 272 + | "w" 273 + | "x" 274 + | "y" 275 + | "z" -> True 276 + _ -> False 277 + } 278 + } 279 + } 280 + } 281 + 282 + fn is_ident_continue(c: String) -> Bool { 283 + is_ident_start(c) || is_digit(c) || c == "-" || c == "." || c == "/" 284 + }
+277
src/gleshell/parser.gleam
··· 1 + //// Parse tokens into a Nushell-like AST (pipelines of commands). 2 + 3 + import gleam/int 4 + import gleam/list 5 + import gleam/string 6 + import gleshell/lexer.{ 7 + type Token, Assign, BoolLit, Colon, Comma, Dollar, Eof, Eq, External, Flag, 8 + FloatLit, Ge, Gt, Ident, IntLit, LBrace, LBracket, Le, Lt, Ne, NothingLit, 9 + Pipe, RBrace, RBracket, StringLit, 10 + } 11 + import gleshell/value.{type Value} 12 + 13 + pub type ParseError { 14 + ParseError(message: String) 15 + } 16 + 17 + pub type Statement { 18 + /// `let name = pipeline` 19 + Let(name: String, pipeline: Pipeline) 20 + /// Bare pipeline expression 21 + Expr(pipeline: Pipeline) 22 + } 23 + 24 + pub type Pipeline { 25 + Pipeline(commands: List(Command)) 26 + } 27 + 28 + pub type Command { 29 + Command(name: String, args: List(Arg), external: Bool) 30 + } 31 + 32 + pub type Arg { 33 + /// Literal or evaluated value expression 34 + ValueArg(Expr) 35 + /// `--flag` or `--flag value` 36 + FlagArg(name: String, value: Option(Expr)) 37 + } 38 + 39 + pub type Option(a) { 40 + Some(a) 41 + None 42 + } 43 + 44 + pub type Expr { 45 + Lit(Value) 46 + Var(String) 47 + ListExpr(List(Expr)) 48 + RecordExpr(List(#(String, Expr))) 49 + } 50 + 51 + pub fn parse(source: String) -> Result(Statement, String) { 52 + case lexer.tokenize(source) { 53 + Error(lexer.LexError(msg, pos)) -> 54 + Error("lex error at " <> int.to_string(pos) <> ": " <> msg) 55 + Ok(tokens) -> 56 + case parse_statement(tokens) { 57 + Ok(#(stmt, rest)) -> 58 + case skip_eof(rest) { 59 + [] -> Ok(stmt) 60 + leftover -> 61 + Error( 62 + "unexpected tokens after statement: " 63 + <> tokens_preview(leftover), 64 + ) 65 + } 66 + Error(ParseError(msg)) -> Error(msg) 67 + } 68 + } 69 + } 70 + 71 + fn skip_eof(tokens: List(Token)) -> List(Token) { 72 + case tokens { 73 + [Eof] | [Eof, ..] | [] -> [] 74 + other -> other 75 + } 76 + } 77 + 78 + fn tokens_preview(tokens: List(Token)) -> String { 79 + tokens 80 + |> list.take(5) 81 + |> list.map(token_name) 82 + |> string.join(", ") 83 + } 84 + 85 + fn token_name(t: Token) -> String { 86 + case t { 87 + Ident(s) -> "ident(" <> s <> ")" 88 + StringLit(_) -> "string" 89 + IntLit(_) -> "int" 90 + FloatLit(_) -> "float" 91 + BoolLit(_) -> "bool" 92 + Pipe -> "|" 93 + Flag(s) -> "--" <> s 94 + Eof -> "eof" 95 + Assign -> "=" 96 + Eq -> "==" 97 + Ne -> "!=" 98 + Gt -> ">" 99 + Lt -> "<" 100 + Ge -> ">=" 101 + Le -> "<=" 102 + External -> "^" 103 + _ -> "token" 104 + } 105 + } 106 + 107 + fn parse_statement( 108 + tokens: List(Token), 109 + ) -> Result(#(Statement, List(Token)), ParseError) { 110 + case tokens { 111 + [Ident("let"), Ident(name), Assign, ..rest] -> { 112 + use #(pipe, rest2) <- result_try(parse_pipeline(rest)) 113 + Ok(#(Let(name, pipe), rest2)) 114 + } 115 + _ -> { 116 + use #(pipe, rest) <- result_try(parse_pipeline(tokens)) 117 + Ok(#(Expr(pipe), rest)) 118 + } 119 + } 120 + } 121 + 122 + fn result_try( 123 + r: Result(a, ParseError), 124 + f: fn(a) -> Result(b, ParseError), 125 + ) -> Result(b, ParseError) { 126 + case r { 127 + Ok(v) -> f(v) 128 + Error(e) -> Error(e) 129 + } 130 + } 131 + 132 + fn parse_pipeline( 133 + tokens: List(Token), 134 + ) -> Result(#(Pipeline, List(Token)), ParseError) { 135 + use #(cmd, rest) <- result_try(parse_command(tokens)) 136 + parse_pipeline_cont([cmd], rest) 137 + } 138 + 139 + fn parse_pipeline_cont( 140 + cmds: List(Command), 141 + tokens: List(Token), 142 + ) -> Result(#(Pipeline, List(Token)), ParseError) { 143 + case tokens { 144 + [Pipe, ..rest] -> { 145 + use #(cmd, rest2) <- result_try(parse_command(rest)) 146 + parse_pipeline_cont(list.append(cmds, [cmd]), rest2) 147 + } 148 + _ -> Ok(#(Pipeline(cmds), tokens)) 149 + } 150 + } 151 + 152 + fn parse_command( 153 + tokens: List(Token), 154 + ) -> Result(#(Command, List(Token)), ParseError) { 155 + case tokens { 156 + [External, Ident(name), ..rest] -> { 157 + use #(args, rest2) <- result_try(parse_args(rest, [])) 158 + Ok(#(Command(name, args, True), rest2)) 159 + } 160 + [Ident(name), ..rest] -> { 161 + use #(args, rest2) <- result_try(parse_args(rest, [])) 162 + Ok(#(Command(name, args, False), rest2)) 163 + } 164 + [StringLit(s), ..rest] -> { 165 + use #(args, rest2) <- result_try(parse_args(rest, [])) 166 + Ok(#(Command(s, args, False), rest2)) 167 + } 168 + [] | [Eof] -> Error(ParseError("expected command")) 169 + _ -> Error(ParseError("expected command name")) 170 + } 171 + } 172 + 173 + fn parse_args( 174 + tokens: List(Token), 175 + acc: List(Arg), 176 + ) -> Result(#(List(Arg), List(Token)), ParseError) { 177 + case tokens { 178 + [] | [Eof] | [Pipe, ..] -> Ok(#(list.reverse(acc), tokens)) 179 + [Flag(name), ..rest] -> { 180 + case is_expr_start(rest) { 181 + True -> { 182 + use #(expr, rest2) <- result_try(parse_expr(rest)) 183 + parse_args(rest2, [FlagArg(name, Some(expr)), ..acc]) 184 + } 185 + False -> parse_args(rest, [FlagArg(name, None), ..acc]) 186 + } 187 + } 188 + // Comparison operators as bare string args (for `where field == value`) 189 + [Eq, ..rest] -> parse_args(rest, [ValueArg(Lit(value.String("=="))), ..acc]) 190 + [Ne, ..rest] -> parse_args(rest, [ValueArg(Lit(value.String("!="))), ..acc]) 191 + [Gt, ..rest] -> parse_args(rest, [ValueArg(Lit(value.String(">"))), ..acc]) 192 + [Lt, ..rest] -> parse_args(rest, [ValueArg(Lit(value.String("<"))), ..acc]) 193 + [Ge, ..rest] -> parse_args(rest, [ValueArg(Lit(value.String(">="))), ..acc]) 194 + [Le, ..rest] -> parse_args(rest, [ValueArg(Lit(value.String("<="))), ..acc]) 195 + [Assign, ..rest] -> 196 + parse_args(rest, [ValueArg(Lit(value.String("=="))), ..acc]) 197 + _ -> { 198 + case is_expr_start(tokens) { 199 + True -> { 200 + use #(expr, rest) <- result_try(parse_expr(tokens)) 201 + parse_args(rest, [ValueArg(expr), ..acc]) 202 + } 203 + False -> Ok(#(list.reverse(acc), tokens)) 204 + } 205 + } 206 + } 207 + } 208 + 209 + fn is_expr_start(tokens: List(Token)) -> Bool { 210 + case tokens { 211 + [StringLit(_), ..] 212 + | [IntLit(_), ..] 213 + | [FloatLit(_), ..] 214 + | [BoolLit(_), ..] 215 + | [NothingLit, ..] 216 + | [LBracket, ..] 217 + | [LBrace, ..] 218 + | [Dollar, ..] 219 + | [Ident(_), ..] -> True 220 + _ -> False 221 + } 222 + } 223 + 224 + fn parse_expr(tokens: List(Token)) -> Result(#(Expr, List(Token)), ParseError) { 225 + case tokens { 226 + [StringLit(s), ..rest] -> Ok(#(Lit(value.String(s)), rest)) 227 + [IntLit(n), ..rest] -> Ok(#(Lit(value.Int(n)), rest)) 228 + [FloatLit(f), ..rest] -> Ok(#(Lit(value.Float(f)), rest)) 229 + [BoolLit(b), ..rest] -> Ok(#(Lit(value.Bool(b)), rest)) 230 + [NothingLit, ..rest] -> Ok(#(Lit(value.Nothing), rest)) 231 + [Dollar, Ident(name), ..rest] -> Ok(#(Var(name), rest)) 232 + [Dollar, ..] -> Error(ParseError("expected variable name after $")) 233 + [LBracket, ..rest] -> parse_list(rest) 234 + [LBrace, ..rest] -> parse_record(rest) 235 + [Ident(name), ..rest] -> Ok(#(Lit(value.String(name)), rest)) 236 + _ -> Error(ParseError("expected expression")) 237 + } 238 + } 239 + 240 + fn parse_list(tokens: List(Token)) -> Result(#(Expr, List(Token)), ParseError) { 241 + parse_list_items(tokens, []) 242 + } 243 + 244 + fn parse_list_items( 245 + tokens: List(Token), 246 + acc: List(Expr), 247 + ) -> Result(#(Expr, List(Token)), ParseError) { 248 + case tokens { 249 + [RBracket, ..rest] -> Ok(#(ListExpr(list.reverse(acc)), rest)) 250 + [Comma, ..rest] -> parse_list_items(rest, acc) 251 + _ -> { 252 + use #(expr, rest) <- result_try(parse_expr(tokens)) 253 + parse_list_items(rest, [expr, ..acc]) 254 + } 255 + } 256 + } 257 + 258 + fn parse_record( 259 + tokens: List(Token), 260 + ) -> Result(#(Expr, List(Token)), ParseError) { 261 + parse_record_fields(tokens, []) 262 + } 263 + 264 + fn parse_record_fields( 265 + tokens: List(Token), 266 + acc: List(#(String, Expr)), 267 + ) -> Result(#(Expr, List(Token)), ParseError) { 268 + case tokens { 269 + [RBrace, ..rest] -> Ok(#(RecordExpr(list.reverse(acc)), rest)) 270 + [Comma, ..rest] -> parse_record_fields(rest, acc) 271 + [Ident(key), Colon, ..rest] | [StringLit(key), Colon, ..rest] -> { 272 + use #(expr, rest2) <- result_try(parse_expr(rest)) 273 + parse_record_fields(rest2, [#(key, expr), ..acc]) 274 + } 275 + _ -> Error(ParseError("expected record field `name: value` or `}`")) 276 + } 277 + }
+28
src/gleshell/sys.gleam
··· 1 + //// OS / process FFI wrappers. 2 + 3 + @external(erlang, "gleshell_ffi", "get_line") 4 + pub fn get_line(prompt: String) -> Result(String, String) 5 + 6 + @external(erlang, "gleshell_ffi", "set_cwd") 7 + pub fn set_cwd(path: String) -> Result(Nil, String) 8 + 9 + @external(erlang, "gleshell_ffi", "get_cwd") 10 + pub fn get_cwd() -> Result(String, String) 11 + 12 + @external(erlang, "gleshell_ffi", "getenv") 13 + pub fn getenv(name: String) -> Result(String, Nil) 14 + 15 + @external(erlang, "gleshell_ffi", "setenv") 16 + pub fn setenv(name: String, value: String) -> Result(Nil, Nil) 17 + 18 + @external(erlang, "gleshell_ffi", "run_cmd") 19 + pub fn run_cmd( 20 + command: String, 21 + args: List(String), 22 + ) -> Result(#(Int, String), String) 23 + 24 + @external(erlang, "gleshell_ffi", "which") 25 + pub fn which(command: String) -> Result(String, Nil) 26 + 27 + @external(erlang, "gleshell_ffi", "home_dir") 28 + pub fn home_dir() -> Result(String, String)
+262
src/gleshell/value.gleam
··· 1 + //// Structured values inspired by Nushell — data, not just text streams. 2 + 3 + import gleam/float 4 + import gleam/int 5 + import gleam/list 6 + import gleam/order 7 + import gleam/string 8 + 9 + /// A shell value. Pipelines pass these between commands. 10 + pub type Value { 11 + Nothing 12 + Bool(Bool) 13 + Int(Int) 14 + Float(Float) 15 + String(String) 16 + List(List(Value)) 17 + /// Ordered key/value pairs (column order preserved). 18 + Record(List(#(String, Value))) 19 + /// Homogeneous table: column names + rows of values. 20 + Table(columns: List(String), rows: List(List(Value))) 21 + /// Runtime failure value (distinct from Result.Error). 22 + Fail(String) 23 + } 24 + 25 + pub fn type_name(value: Value) -> String { 26 + case value { 27 + Nothing -> "nothing" 28 + Bool(_) -> "bool" 29 + Int(_) -> "int" 30 + Float(_) -> "float" 31 + String(_) -> "string" 32 + List(_) -> "list" 33 + Record(_) -> "record" 34 + Table(_, _) -> "table" 35 + Fail(_) -> "error" 36 + } 37 + } 38 + 39 + pub fn is_truthy(value: Value) -> Bool { 40 + case value { 41 + Nothing -> False 42 + Bool(b) -> b 43 + Int(0) -> False 44 + Float(f) -> f != 0.0 45 + String("") -> False 46 + List([]) -> False 47 + Table(_, []) -> False 48 + Fail(_) -> False 49 + _ -> True 50 + } 51 + } 52 + 53 + pub fn as_string(value: Value) -> String { 54 + case value { 55 + Nothing -> "" 56 + Bool(True) -> "true" 57 + Bool(False) -> "false" 58 + Int(n) -> int.to_string(n) 59 + Float(f) -> float.to_string(f) 60 + String(s) -> s 61 + List(items) -> { 62 + let inner = 63 + items 64 + |> list.map(as_string) 65 + |> string.join(", ") 66 + "[" <> inner <> "]" 67 + } 68 + Record(fields) -> { 69 + let inner = 70 + fields 71 + |> list.map(fn(pair) { 72 + let #(k, v) = pair 73 + k <> ": " <> as_string(v) 74 + }) 75 + |> string.join(", ") 76 + "{" <> inner <> "}" 77 + } 78 + Table(cols, rows) -> 79 + "table<" 80 + <> string.join(cols, ", ") 81 + <> "; " 82 + <> int.to_string(list.length(rows)) 83 + <> " rows>" 84 + Fail(msg) -> "error: " <> msg 85 + } 86 + } 87 + 88 + /// Compact single-line representation for table cells. 89 + pub fn cell_string(value: Value) -> String { 90 + case value { 91 + Nothing -> "" 92 + Bool(True) -> "true" 93 + Bool(False) -> "false" 94 + Int(n) -> int.to_string(n) 95 + Float(f) -> float.to_string(f) 96 + String(s) -> s 97 + List(items) -> 98 + "[" 99 + <> { 100 + items 101 + |> list.map(cell_string) 102 + |> string.join(" ") 103 + } 104 + <> "]" 105 + Record(fields) -> 106 + "{" 107 + <> { 108 + fields 109 + |> list.map(fn(pair) { 110 + let #(k, v) = pair 111 + k <> ":" <> cell_string(v) 112 + }) 113 + |> string.join(" ") 114 + } 115 + <> "}" 116 + Table(cols, rows) -> 117 + "table(" 118 + <> int.to_string(list.length(cols)) 119 + <> "x" 120 + <> int.to_string(list.length(rows)) 121 + <> ")" 122 + Fail(msg) -> "error:" <> msg 123 + } 124 + } 125 + 126 + pub fn get_field(record: Value, name: String) -> Result(Value, String) { 127 + case record { 128 + Record(fields) -> 129 + case list.key_find(fields, name) { 130 + Ok(v) -> Ok(v) 131 + Error(Nil) -> Error("no field '" <> name <> "'") 132 + } 133 + Table(_, _) -> Error("use 'get' on a row record, not a table") 134 + other -> Error("expected record, got " <> type_name(other)) 135 + } 136 + } 137 + 138 + pub fn record_from_pairs(pairs: List(#(String, Value))) -> Value { 139 + Record(pairs) 140 + } 141 + 142 + /// Build a table from a list of records (union of keys, stable first-seen order). 143 + pub fn table_from_records(records: List(Value)) -> Value { 144 + case records { 145 + [] -> Table([], []) 146 + _ -> { 147 + let columns = 148 + records 149 + |> list.fold([], fn(acc, rec) { 150 + case rec { 151 + Record(fields) -> 152 + list.fold(fields, acc, fn(cols, pair) { 153 + let #(k, _) = pair 154 + case list.contains(cols, k) { 155 + True -> cols 156 + False -> list.append(cols, [k]) 157 + } 158 + }) 159 + _ -> acc 160 + } 161 + }) 162 + let rows = 163 + list.map(records, fn(rec) { 164 + case rec { 165 + Record(fields) -> 166 + list.map(columns, fn(col) { 167 + case list.key_find(fields, col) { 168 + Ok(v) -> v 169 + Error(Nil) -> Nothing 170 + } 171 + }) 172 + other -> list.map(columns, fn(_) { other }) 173 + } 174 + }) 175 + Table(columns, rows) 176 + } 177 + } 178 + } 179 + 180 + /// Convert a table into a list of records. 181 + pub fn table_to_records(table: Value) -> Result(List(Value), String) { 182 + case table { 183 + Table(cols, rows) -> 184 + Ok(list.map(rows, fn(row) { Record(list.zip(cols, row)) })) 185 + List(items) -> { 186 + case 187 + list.all(items, fn(v) { 188 + case v { 189 + Record(_) -> True 190 + _ -> False 191 + } 192 + }) 193 + { 194 + True -> Ok(items) 195 + False -> Error("list is not a list of records") 196 + } 197 + } 198 + Record(_) as r -> Ok([r]) 199 + other -> 200 + Error("expected table or list of records, got " <> type_name(other)) 201 + } 202 + } 203 + 204 + pub fn length_of(value: Value) -> Int { 205 + case value { 206 + Nothing -> 0 207 + List(items) -> list.length(items) 208 + Table(_, rows) -> list.length(rows) 209 + String(s) -> string.length(s) 210 + Record(fields) -> list.length(fields) 211 + _ -> 1 212 + } 213 + } 214 + 215 + pub fn compare(a: Value, b: Value) -> Result(Cmp, String) { 216 + case a, b { 217 + Int(x), Int(y) -> Ok(order_to_cmp(int.compare(x, y))) 218 + Float(x), Float(y) -> Ok(order_to_cmp(float.compare(x, y))) 219 + Int(x), Float(y) -> Ok(order_to_cmp(float.compare(int.to_float(x), y))) 220 + Float(x), Int(y) -> Ok(order_to_cmp(float.compare(x, int.to_float(y)))) 221 + String(x), String(y) -> Ok(order_to_cmp(string.compare(x, y))) 222 + Bool(x), Bool(y) -> 223 + Ok(case x, y { 224 + True, False -> Gt 225 + False, True -> Lt 226 + _, _ -> Eq 227 + }) 228 + _, _ -> Error("cannot compare " <> type_name(a) <> " and " <> type_name(b)) 229 + } 230 + } 231 + 232 + fn order_to_cmp(o: order.Order) -> Cmp { 233 + case o { 234 + order.Lt -> Lt 235 + order.Eq -> Eq 236 + order.Gt -> Gt 237 + } 238 + } 239 + 240 + pub type Cmp { 241 + Lt 242 + Eq 243 + Gt 244 + } 245 + 246 + pub fn equals(a: Value, b: Value) -> Bool { 247 + case compare(a, b) { 248 + Ok(Eq) -> True 249 + Ok(_) -> False 250 + Error(_) -> as_string(a) == as_string(b) 251 + } 252 + } 253 + 254 + /// Try to coerce pipeline input into rows (list of values). 255 + pub fn as_rows(value: Value) -> List(Value) { 256 + case value { 257 + Nothing -> [] 258 + List(items) -> items 259 + Table(cols, rows) -> list.map(rows, fn(row) { Record(list.zip(cols, row)) }) 260 + other -> [other] 261 + } 262 + }
+126
src/gleshell_ffi.erl
··· 1 + %% Erlang FFI for gleshell: REPL I/O, cwd, env, external processes. 2 + -module(gleshell_ffi). 3 + -export([ 4 + get_line/1, 5 + set_cwd/1, 6 + get_cwd/0, 7 + getenv/1, 8 + setenv/2, 9 + run_cmd/2, 10 + which/1, 11 + home_dir/0 12 + ]). 13 + 14 + -spec get_line(binary()) -> {ok, binary()} | {error, binary()}. 15 + get_line(Prompt) when is_binary(Prompt) -> 16 + %% OTP may return a charlist or a UTF-8 binary depending on the 17 + %% standard_io encoding / binary options — accept both. 18 + case io:get_line(unicode:characters_to_list(Prompt)) of 19 + eof -> 20 + {error, <<"eof">>}; 21 + {error, _} -> 22 + {error, <<"io_error">>}; 23 + Line when is_list(Line); is_binary(Line) -> 24 + Bin = unicode:characters_to_binary(Line), 25 + Stripped = string:trim(Bin, trailing, [$\n, $\r]), 26 + {ok, Stripped} 27 + end. 28 + 29 + -spec set_cwd(binary()) -> {ok, nil} | {error, binary()}. 30 + set_cwd(Path) when is_binary(Path) -> 31 + case file:set_cwd(unicode:characters_to_list(Path)) of 32 + ok -> 33 + {ok, nil}; 34 + {error, Reason} -> 35 + {error, reason_to_bin(Reason)} 36 + end. 37 + 38 + -spec get_cwd() -> {ok, binary()} | {error, binary()}. 39 + get_cwd() -> 40 + case file:get_cwd() of 41 + {ok, Dir} -> 42 + {ok, unicode:characters_to_binary(Dir)}; 43 + {error, Reason} -> 44 + {error, reason_to_bin(Reason)} 45 + end. 46 + 47 + -spec getenv(binary()) -> {ok, binary()} | {error, nil}. 48 + getenv(Name) when is_binary(Name) -> 49 + case os:getenv(unicode:characters_to_list(Name)) of 50 + false -> 51 + {error, nil}; 52 + Value -> 53 + {ok, unicode:characters_to_binary(Value)} 54 + end. 55 + 56 + -spec setenv(binary(), binary()) -> {ok, nil}. 57 + setenv(Name, Value) when is_binary(Name), is_binary(Value) -> 58 + os:putenv(unicode:characters_to_list(Name), unicode:characters_to_list(Value)), 59 + {ok, nil}. 60 + 61 + -spec which(binary()) -> {ok, binary()} | {error, nil}. 62 + which(Command) when is_binary(Command) -> 63 + case os:find_executable(unicode:characters_to_list(Command)) of 64 + false -> 65 + {error, nil}; 66 + Path -> 67 + {ok, unicode:characters_to_binary(Path)} 68 + end. 69 + 70 + -spec home_dir() -> {ok, binary()} | {error, binary()}. 71 + home_dir() -> 72 + case os:getenv("HOME") of 73 + false -> 74 + {error, <<"HOME not set">>}; 75 + Home -> 76 + {ok, unicode:characters_to_binary(Home)} 77 + end. 78 + 79 + %% Run an executable with args; capture stdout+stderr and exit status. 80 + %% Returns {ok, {Status :: integer(), Output :: binary()}} | {error, binary()}. 81 + -spec run_cmd(binary(), [binary()]) -> {ok, {integer(), binary()}} | {error, binary()}. 82 + run_cmd(Command, Args) when is_binary(Command), is_list(Args) -> 83 + case os:find_executable(unicode:characters_to_list(Command)) of 84 + false -> 85 + {error, <<"command not found: ", Command/binary>>}; 86 + Path -> 87 + PortArgs = [unicode:characters_to_list(A) || A <- Args], 88 + try 89 + Port = open_port( 90 + {spawn_executable, Path}, 91 + [ 92 + binary, 93 + exit_status, 94 + stderr_to_stdout, 95 + use_stdio, 96 + stream, 97 + {args, PortArgs} 98 + ] 99 + ), 100 + collect_output(Port, <<>>) 101 + catch 102 + _:Reason -> 103 + {error, reason_to_bin(Reason)} 104 + end 105 + end. 106 + 107 + collect_output(Port, Acc) -> 108 + receive 109 + {Port, {data, Data}} when is_binary(Data) -> 110 + collect_output(Port, <<Acc/binary, Data/binary>>); 111 + {Port, {data, Data}} when is_list(Data) -> 112 + Bin = unicode:characters_to_binary(Data), 113 + collect_output(Port, <<Acc/binary, Bin/binary>>); 114 + {Port, {exit_status, Status}} -> 115 + {ok, {Status, Acc}} 116 + after 120_000 -> 117 + catch port_close(Port), 118 + {error, <<"command timed out after 120s">>} 119 + end. 120 + 121 + reason_to_bin(Reason) when is_atom(Reason) -> 122 + atom_to_binary(Reason, utf8); 123 + reason_to_bin(Reason) when is_binary(Reason) -> 124 + Reason; 125 + reason_to_bin(Reason) -> 126 + iolist_to_binary(io_lib:format("~p", [Reason])).
+165
test/gleshell_test.gleam
··· 1 + import gleeunit 2 + import gleshell/env 3 + import gleshell/eval 4 + import gleshell/lexer 5 + import gleshell/parser 6 + import gleshell/value.{Bool, Int, List, Nothing, Record, String, Table} 7 + 8 + pub fn main() -> Nil { 9 + gleeunit.main() 10 + } 11 + 12 + // --- lexer --- 13 + 14 + pub fn lexer_pipeline_test() { 15 + let assert Ok(tokens) = lexer.tokenize("ls | where type == file") 16 + let assert [lexer.Ident("ls"), lexer.Pipe, lexer.Ident("where"), ..] = tokens 17 + Nil 18 + } 19 + 20 + pub fn lexer_string_and_number_test() { 21 + let assert Ok(tokens) = lexer.tokenize("echo \"hi\" 42 3.14 true") 22 + let assert [ 23 + lexer.Ident("echo"), 24 + lexer.StringLit("hi"), 25 + lexer.IntLit(42), 26 + lexer.FloatLit(3.14), 27 + lexer.BoolLit(True), 28 + lexer.Eof, 29 + ] = tokens 30 + Nil 31 + } 32 + 33 + // --- parser --- 34 + 35 + pub fn parse_pipeline_test() { 36 + let assert Ok(parser.Expr(parser.Pipeline(cmds))) = 37 + parser.parse("ls | first 3") 38 + let assert [ 39 + parser.Command("ls", [], False), 40 + parser.Command("first", args, False), 41 + ] = cmds 42 + let assert [parser.ValueArg(parser.Lit(Int(3)))] = args 43 + Nil 44 + } 45 + 46 + pub fn parse_let_test() { 47 + let assert Ok(parser.Let( 48 + "x", 49 + parser.Pipeline([parser.Command("echo", args, False)]), 50 + )) = parser.parse("let x = echo 1") 51 + let assert [parser.ValueArg(parser.Lit(Int(1)))] = args 52 + Nil 53 + } 54 + 55 + pub fn parse_where_ops_test() { 56 + let assert Ok(parser.Expr(parser.Pipeline([ 57 + parser.Command("where", args, False), 58 + ]))) = parser.parse("where type == file") 59 + let assert [ 60 + parser.ValueArg(parser.Lit(String("type"))), 61 + parser.ValueArg(parser.Lit(String("=="))), 62 + parser.ValueArg(parser.Lit(String("file"))), 63 + ] = args 64 + Nil 65 + } 66 + 67 + pub fn parse_list_and_record_test() { 68 + let assert Ok(parser.Expr(parser.Pipeline([ 69 + parser.Command("echo", args, False), 70 + ]))) = parser.parse("echo [1 2] {a: true}") 71 + let assert [ 72 + parser.ValueArg(parser.ListExpr([parser.Lit(Int(1)), parser.Lit(Int(2))])), 73 + parser.ValueArg(parser.RecordExpr([#("a", parser.Lit(Bool(True)))])), 74 + ] = args 75 + Nil 76 + } 77 + 78 + // --- value helpers --- 79 + 80 + pub fn table_from_records_test() { 81 + let rows = [ 82 + Record([#("name", String("a")), #("n", Int(1))]), 83 + Record([#("name", String("b")), #("n", Int(2))]), 84 + ] 85 + let assert Table( 86 + ["name", "n"], 87 + [[String("a"), Int(1)], [String("b"), Int(2)]], 88 + ) = value.table_from_records(rows) 89 + Nil 90 + } 91 + 92 + // --- eval --- 93 + 94 + pub fn eval_echo_and_range_test() { 95 + let env = env.new() 96 + let assert eval.Continue(_, List([Int(0), Int(1), Int(2)])) = 97 + eval.eval_source(env, "range 3") 98 + let assert eval.Continue(_, String("hello")) = 99 + eval.eval_source(env, "echo hello") 100 + Nil 101 + } 102 + 103 + pub fn eval_pipeline_reverse_first_test() { 104 + let env = env.new() 105 + let assert eval.Continue(_, Int(2)) = 106 + eval.eval_source(env, "range 3 | reverse | first") 107 + Nil 108 + } 109 + 110 + pub fn eval_let_and_var_test() { 111 + let env = env.new() 112 + let assert eval.Continue(env2, Int(7)) = 113 + eval.eval_source(env, "let n = echo 7") 114 + let assert eval.Continue(_, Int(7)) = eval.eval_source(env2, "echo $n") 115 + Nil 116 + } 117 + 118 + pub fn eval_where_select_test() { 119 + let env = env.new() 120 + // build table via records in a list, convert with table 121 + let assert eval.Continue(_, result) = 122 + eval.eval_source( 123 + env, 124 + "echo [{name: a, n: 1} {name: b, n: 2} {name: c, n: 3}] | table | where n > 1 | select name", 125 + ) 126 + let assert Table(["name"], rows) = result 127 + let assert [[String("b")], [String("c")]] = rows 128 + Nil 129 + } 130 + 131 + pub fn eval_from_json_test() { 132 + let env = env.new() 133 + let assert eval.Continue(_, Record(fields)) = 134 + eval.eval_source(env, "echo \"{\\\"x\\\": 1}\" | from-json") 135 + let assert True = list_has_field(fields, "x", Int(1)) 136 + Nil 137 + } 138 + 139 + fn list_has_field( 140 + fields: List(#(String, value.Value)), 141 + key: String, 142 + expected: value.Value, 143 + ) -> Bool { 144 + case fields { 145 + [] -> False 146 + [#(k, v), ..rest] -> 147 + case k == key { 148 + True -> value.equals(v, expected) 149 + False -> list_has_field(rest, key, expected) 150 + } 151 + } 152 + } 153 + 154 + pub fn eval_length_test() { 155 + let env = env.new() 156 + let assert eval.Continue(_, Int(4)) = 157 + eval.eval_source(env, "range 4 | length") 158 + Nil 159 + } 160 + 161 + pub fn value_nothing_falsey_test() { 162 + let assert False = value.is_truthy(Nothing) 163 + let assert True = value.is_truthy(Int(1)) 164 + Nil 165 + }