1 import Base 2 import ./usage/text.bend as UText 3 4 # The command line, as cli.py's argparse reads it: a leading verb (or its 5 # legacy --flag spelling), then flags and positionals in any order. A parse is 6 # a Parsed: a command to run, or the usage error argparse would print (exit 2). 7 8 type Parsed is Data: 9 PHelp{} 10 PVersion{} 11 PList{json: Bool, token_status: Bool} 12 PStatus{json: Bool} 13 PAdd{slot: Maybe<&2, U32>, alias: Maybe<&2, String>} 14 PAddToken{token: Maybe<&2, String>, email: Maybe<&2, String>, slot: Maybe<&2, U32>} 15 PRemove{ident: String} 16 PImportUsage{path: String, hold: Maybe<&2, U32>} 17 PConfig{args: List<&2, String>, json: Bool} 18 PProfile{args: List<&2, String>, json: Bool} 19 PSwitch{ident: Maybe<&2, String>, force: Bool, strategy: Maybe<&2, String>, model: Maybe<&2, String>, json: Bool} 20 PTui{args: List<&2, String>} 21 PAuto{args: List<&2, String>} 22 PDeferred{name: String} 23 PBad{msg: String} 24 25 # One token of the command line: a flag with its value ("" for a switch), or a 26 # positional (empty name). 27 type Opt is Data: 28 Opt{name: String, value: String} 29 30 type KV is Data: 31 KV{k: String, v: String} 32 33 def verbs() -> List<&2, KV>: 34 [KV{"help", "help"}, KV{"--help", "help"}, KV{"-h", "help"}, KV{"--version", "version"}, KV{"list", "list"}, KV{"ls", "list"}, KV{"--list", "list"}, 35 KV{"status", "status"}, KV{"--status", "status"}, KV{"add", "add"}, KV{"--add-account", "add"}, KV{"add-token", "add-token"}, KV{"--add-token", "add-token"}, 36 KV{"remove", "remove"}, KV{"rm", "remove"}, KV{"--remove-account", "remove"}, KV{"import-usage", "import-usage"}, KV{"--import-usage", "import-usage"}, 37 KV{"config", "config"}, KV{"profile", "profile"}, KV{"switch", "switch"}, KV{"--switch", "switch"}, KV{"--switch-to", "switch"}, KV{"auto", "auto"}, 38 KV{"run", "run"}, KV{"map", "map"}, KV{"unmap", "unmap"}, KV{"unclaimed", "unclaimed"}, KV{"alias", "alias"}, KV{"swap", "swap"}, KV{"move", "move"}, 39 KV{"disable", "disable"}, KV{"enable", "enable"}, KV{"export", "export"}, KV{"import", "import"}, KV{"purge", "purge"}, KV{"upgrade", "upgrade"}, 40 KV{"update", "upgrade"}, KV{"tui", "tui"}, KV{"watch", "watch"}, KV{"menubar", "menubar"}] 41 42 def verb.step(hit: Bool, v: String, rest: Maybe<&2, String>) -> Maybe<&2, String>: 43 match hit: 44 case True{}: 45 Some{v} 46 case False{}: 47 rest 48 49 def verb.find(+t: String, vs: List<&2, KV>) -> Maybe<&2, String>: 50 match vs: 51 case Nil{}: 52 None{} 53 case Con{KV{k, v}, rest}: 54 verb.step(String.eq(k, t), v, verb.find(t, rest)) 55 56 def verb.pick(+t: String) -> String: 57 Maybe.default(&2, String, verb.find(t, verbs()), "") 58 59 # The canonical verb a first token names, "" when it is none. 60 def verb_of(+t: String) -> String: 61 verb.pick(t) 62 63 def valued() -> List<&2, String>: 64 ["--slot", "--email", "--alias", "--hold", "--strategy", "--model", "--account"] 65 66 def switches() -> List<&2, String>: 67 ["--json", "--token-status", "--debug", "--force", "--full", "--install-service", "--uninstall-service", "--service-status"] 68 69 def has_str(+x: String, xs: List<&2, String>) -> Bool: 70 match xs: 71 case Nil{}: 72 False{} 73 case Con{h, t}: 74 String.eq(h, x) || has_str(x, t) 75 76 def is_flag(+t: String) -> Bool: 77 String.starts_with(t, "-") && Nat.is_gt(String.length(t), 1n) 78 79 def split_eq.go(ps: List<&2, String>) -> String & Maybe<&2, String>: 80 match ps: 81 case Con{k, Nil{}}: 82 (k, None{}) 83 case Con{k, Con{v, rest}}: 84 (k, Some{String.join(v <> rest, "=")}) 85 case Nil{}: 86 ("", None{}) 87 88 # `--slot=3` as ("--slot", "3"); a bare flag has no value part. 89 def split_eq(+t: String) -> String & Maybe<&2, String>: 90 split_eq.go(String.split(t, '=')) 91 92 def opt.has(+name: String, os: List<&2, Opt>) -> Bool: 93 match os: 94 case Nil{}: 95 False{} 96 case Con{Opt{n, v}, t}: 97 String.eq(n, name) || opt.has(name, t) 98 99 def opt.pick(hit: Bool, v: String, rest: Maybe<&2, String>) -> Maybe<&2, String>: 100 match hit: 101 case True{}: 102 Some{v} 103 case False{}: 104 rest 105 106 def opt.val(+name: String, os: List<&2, Opt>) -> Maybe<&2, String>: 107 match os: 108 case Nil{}: 109 None{} 110 case Con{Opt{n, v}, t}: 111 opt.pick(String.eq(n, name), v, opt.val(name, t)) 112 113 def pos.pick(hit: Bool, v: String, rest: List<&2, String>) -> List<&2, String>: 114 match hit: 115 case True{}: 116 v <> rest 117 case False{}: 118 rest 119 120 def positionals(os: List<&2, Opt>) -> List<&2, String>: 121 match os: 122 case Nil{}: 123 [] 124 case Con{Opt{n, v}, t}: 125 pos.pick(String.is_empty(n), v, positionals(t)) 126 127 def first_of(xs: List<&2, String>) -> Maybe<&2, String>: 128 match xs: 129 case Con{h, t}: 130 Some{h} 131 case Nil{}: 132 None{} 133 134 def prog() -> String: 135 "cswap" 136 137 def usage_line() -> String: 138 "usage: cswap [args] [options]" 139 140 def flag.msg(on: Bool, msg: String, rest: String) -> String: 141 match on: 142 case True{}: 143 msg 144 case False{}: 145 rest 146 147 def first.pick(empty: Bool, rest: String, h: String) -> String: 148 match empty: 149 case True{}: 150 rest 151 case False{}: 152 h 153 154 def first_err(msgs: List<&2, String>) -> String: 155 match msgs: 156 case Nil{}: 157 "" 158 case Con{+h, t}: 159 first.pick(String.is_empty(h), first_err(t), h) 160 161 def err_if(on: Bool, msg: String) -> String: 162 flag.msg(on, msg, "") 163 164 def pos_err(+vb: String, +ps: List<&2, String>, +max: Nat) -> String: 165 err_if(Nat.is_gt(List.length(&2, String, ps), max), "unrecognized arguments: " ++ String.join(List.drop(&2, String, ps, max), " ")) 166 167 def strategy_err(m: Maybe<&2, String>) -> String: 168 match m: 169 case None{}: 170 "" 171 case Some{+v}: 172 err_if(Bool.not(has_str(v, ["best", "next-available"])), "argument --strategy: invalid choice: '" ++ v ++ "' (choose from 'best', 'next-available')") 173 174 def common_errs(+vb: String, +os: List<&2, Opt>) -> List<&2, String>: 175 [err_if(opt.has("--token-status", os) && Bool.not(String.eq(vb, "list")), "--token-status can only be used with 'list'"), 176 err_if(opt.has("--json", os) && Bool.not(has_str(vb, ["list", "status", "switch"])), "--json can only be used with 'list', 'status', or 'switch'"), 177 err_if(opt.has("--json", os) && opt.has("--token-status", os), "--token-status cannot be combined with --json"), 178 err_if(opt.has("--strategy", os) && Bool.not(String.eq(vb, "switch")), "--strategy can only be used with bare 'switch'"), 179 err_if(opt.has("--model", os) && Bool.not(opt.has("--strategy", os)), "--model can only be used with 'switch --strategy best' or 'switch --strategy next-available'"), 180 err_if(opt.has("--slot", os) && Bool.not(has_str(vb, ["add", "add-token"])), "--slot can only be used with 'add' or 'add-token'"), 181 err_if(opt.has("--email", os) && Bool.not(String.eq(vb, "add-token")), "--email can only be used with 'add-token'"), 182 err_if(opt.has("--account", os) && Bool.not(String.eq(vb, "export")), "--account can only be used with 'export'"), 183 err_if(opt.has("--alias", os) && Bool.not(String.eq(vb, "add")), "--alias can only be used with 'add'"), 184 err_if(opt.has("--force", os) && Bool.not(has_str(vb, ["import", "switch"])), "--force can only be used with 'import' or 'switch '"), 185 err_if(opt.has("--full", os) && Bool.not(String.eq(vb, "export")), "--full can only be used with 'export'"), 186 err_if(opt.has("--hold", os) && Bool.not(String.eq(vb, "import-usage")), "--hold can only be used with 'import-usage'"), 187 err_if(opt.has("--strategy", os) && String.eq(vb, "switch") && Bool.not(List.is_empty(&2, String, positionals(os))), "--strategy can only be used with bare 'switch'"), 188 err_if(opt.has("--force", os) && String.eq(vb, "switch") && List.is_empty(&2, String, positionals(os)), "--force can only be used with 'import' or 'switch '"), 189 strategy_err(opt.val("--strategy", os)), 190 err_if((opt.has("--install-service", os) || opt.has("--uninstall-service", os) || opt.has("--service-status", os)) && Bool.not(String.eq(vb, "menubar")), 191 "--install-service, --uninstall-service and --service-status can only be used with 'menubar'")] 192 193 type Slot is Data: 194 SlotOk{n: Maybe<&2, U32>} 195 SlotBad{msg: String} 196 197 def slot.read(r: Maybe<&2, U32>, +s: String) -> Slot: 198 match r: 199 case Some{n}: 200 SlotOk{Some{n}} 201 case None{}: 202 SlotBad{"argument --slot: invalid int value: '" ++ s ++ "'"} 203 204 def slot.of(m: Maybe<&2, String>) -> Slot: 205 match m: 206 case None{}: 207 SlotOk{None{}} 208 case Some{+s}: 209 slot.read(U32.read(s), s) 210 211 type Hold is Data: 212 HoldOk{secs: Maybe<&2, U32>} 213 HoldBad{msg: String} 214 215 def hold.bad() -> Hold: 216 HoldBad{"--hold must be a non-negative number of seconds"} 217 218 def hold.int(r: Maybe<&2, U32>) -> Hold: 219 match r: 220 case Some{n}: 221 HoldOk{Some{n}} 222 case None{}: 223 hold.bad() 224 225 def hold.parts(ps: List<&2, String>) -> Hold: 226 match ps: 227 case Con{i, t}: 228 hold.int(U32.read(i)) 229 case Nil{}: 230 hold.bad() 231 232 def hold.norm(m: Maybe<&2, String>) -> Hold: 233 match m: 234 case Some{t}: 235 hold.parts(String.split(t, '.')) 236 case None{}: 237 hold.bad() 238 239 def hold.of(m: Maybe<&2, String>) -> Hold: 240 match m: 241 case None{}: 242 HoldOk{None{}} 243 case Some{s}: 244 hold.norm(UText.float(s)) 245 246 def bad_text(msg: String) -> String: 247 usage_line() ++ "\n" ++ prog() ++ ": error: " ++ msg 248 249 def bad(msg: String) -> Parsed: 250 PBad{bad_text(msg)} 251 252 def sel.p(c: Bool, a: Parsed, b: Parsed) -> Parsed: 253 match c: 254 case True{}: 255 a 256 case False{}: 257 b 258 259 def with_err(+e: String, p: Parsed) -> Parsed: 260 sel.p(String.is_empty(e), p, bad(e)) 261 262 def build.add(sl: Slot, alias: Maybe<&2, String>) -> Parsed: 263 match sl: 264 case SlotOk{n}: 265 PAdd{n, alias} 266 case SlotBad{msg}: 267 bad(msg) 268 269 def build.token(sl: Slot, token: Maybe<&2, String>, email: Maybe<&2, String>) -> Parsed: 270 match sl: 271 case SlotOk{n}: 272 PAddToken{token, email, n} 273 case SlotBad{msg}: 274 bad(msg) 275 276 def build.import(h: Hold, path: Maybe<&2, String>) -> Parsed: 277 match h path: 278 case HoldBad{msg} _: 279 bad(msg) 280 case HoldOk{secs} Some{p}: 281 PImportUsage{p, secs} 282 case HoldOk{secs} None{}: 283 bad("argument --import-usage: expected one argument") 284 285 def build.remove(m: Maybe<&2, String>) -> Parsed: 286 match m: 287 case Some{i}: 288 PRemove{i} 289 case None{}: 290 bad("argument --remove-account: expected one argument") 291 292 def max_pos(one: Bool) -> Nat: 293 match one: 294 case True{}: 295 1n 296 case False{}: 297 0n 298 299 def arity(+vb: String, +ps: List<&2, String>) -> String: 300 pos_err(vb, ps, max_pos(has_str(vb, ["add-token", "remove", "import-usage", "switch"]))) 301 302 # A verb and the command it builds. 303 type Alt is Data: 304 Alt{key: String, val: Parsed} 305 306 def choose.pick(hit: Bool, v: Parsed, rest: Parsed) -> Parsed: 307 match hit: 308 case True{}: 309 v 310 case False{}: 311 rest 312 313 def choose(+vb: String, alts: List<&2, Alt>, dflt: Parsed) -> Parsed: 314 match alts: 315 case Nil{}: 316 dflt 317 case Con{Alt{k, v}, t}: 318 choose.pick(String.eq(k, vb), v, choose(vb, t, dflt)) 319 320 def build.cmd(+vb: String, +os: List<&2, Opt>, +ps: List<&2, String>) -> Parsed: 321 choose(vb, [ 322 Alt{"help", PHelp{}}, Alt{"version", PVersion{}}, 323 Alt{"list", PList{opt.has("--json", os), opt.has("--token-status", os)}}, 324 Alt{"status", PStatus{opt.has("--json", os)}}, 325 Alt{"add", build.add(slot.of(opt.val("--slot", os)), opt.val("--alias", os))}, 326 Alt{"add-token", build.token(slot.of(opt.val("--slot", os)), first_of(ps), opt.val("--email", os))}, 327 Alt{"remove", build.remove(first_of(ps))}, 328 Alt{"switch", PSwitch{first_of(ps), opt.has("--force", os), opt.val("--strategy", os), opt.val("--model", os), opt.has("--json", os)}}, 329 Alt{"import-usage", build.import(hold.of(opt.val("--hold", os)), first_of(ps))}], 330 PDeferred{vb}) 331 332 def build.go(+vb: String, +os: List<&2, Opt>, +ps: List<&2, String>) -> Parsed: 333 with_err(arity(vb, ps), build.cmd(vb, os, ps)) 334 335 def build.verb(+vb: String, +os: List<&2, Opt>) -> Parsed: 336 build.go(vb, os, positionals(os)) 337 338 def build(+vb: String, +os: List<&2, Opt>) -> Parsed: 339 with_err(first_err(common_errs(vb, os)), build.verb(vb, os)) 340 341 def not_flag(+t: String) -> Bool: 342 Bool.not(String.eq(t, "--json") || String.eq(t, "--debug")) 343 344 def config.keep(keep: Bool, h: String, rest: List<&2, String>) -> List<&2, String>: 345 match keep: 346 case True{}: 347 h <> rest 348 case False{}: 349 rest 350 351 def config_args(ts: List<&2, String>) -> List<&2, String>: 352 match ts: 353 case Nil{}: 354 [] 355 case Con{+h, t}: 356 config.keep(not_flag(h), h, config_args(t)) 357 358 type Scan is Data: 359 Scan{opts: List<&2, Opt>, pending: String, err: String} 360 361 def sel.s(c: Bool, a: String, b: String) -> String: 362 match c: 363 case True{}: 364 a 365 case False{}: 366 b 367 368 def first_msg(+err: String, +name: String) -> String: 369 sel.s(String.is_empty(err), "unrecognized arguments: " ++ name, err) 370 371 def scan.fail(err: String, +name: String) -> String: 372 first_msg(err, name) 373 374 def scan.flag2(valued: Bool, switch: Bool, +name: String, inline: Maybe<&2, String>, opts: List<&2, Opt>, err: String) -> Scan: 375 match valued switch inline: 376 case True{} _ Some{v}: 377 Scan{Opt{name, v} <> opts, "", err} 378 case True{} _ None{}: 379 Scan{opts, name, err} 380 case False{} True{} _: 381 Scan{Opt{name, ""} <> opts, "", err} 382 case False{} False{} _: 383 Scan{opts, "", scan.fail(err, name)} 384 385 def scan.flag(kv: String & Maybe<&2, String>, opts: List<&2, Opt>, err: String) -> Scan: 386 (+k, v) = kv 387 scan.flag2(has_str(k, valued()), has_str(k, switches()), k, v, opts, err) 388 389 def scan.free(flag: Bool, +t: String, opts: List<&2, Opt>, err: String) -> Scan: 390 match flag: 391 case True{}: 392 scan.flag(split_eq(t), opts, err) 393 case False{}: 394 Scan{Opt{"", t} <> opts, "", err} 395 396 def scan.step2(pending: Bool, +h: String, +name: String, opts: List<&2, Opt>, err: String) -> Scan: 397 match pending: 398 case True{}: 399 Scan{Opt{name, h} <> opts, "", err} 400 case False{}: 401 scan.free(is_flag(h), h, opts, err) 402 403 def scan.step(+h: String, sc: Scan) -> Scan: 404 match sc: 405 case Scan{opts, +pending, err}: 406 scan.step2(Bool.not(String.is_empty(pending)), h, pending, opts, err) 407 408 def scan.go(ts: List<&2, String>, sc: Scan) -> Scan: 409 match ts: 410 case Nil{}: 411 sc 412 case Con{h, t}: 413 scan.go(t, scan.step(h, sc)) 414 415 def scan.dangling(+pending: String, err: String) -> String: 416 sel.s(String.is_empty(pending), err, "argument " ++ pending ++ ": expected one argument") 417 418 def scan.end(sc: Scan) -> Scan: 419 match sc: 420 case Scan{opts, pending, err}: 421 Scan{List.reverse(&2, Opt, opts), "", scan.dangling(pending, err)} 422 423 def scan(ts: List<&2, String>) -> Scan: 424 scan.end(scan.go(ts, Scan{[], "", ""})) 425 426 def scan.done(+err: String, +vb: String, +opts: List<&2, Opt>) -> Parsed: 427 sel.p(String.is_empty(err), build(vb, opts), bad(err)) 428 429 def parse.scan(+vb: String, sc: Scan) -> Parsed: 430 match sc: 431 case Scan{opts, pending, err}: 432 scan.done(err, vb, opts) 433 434 def parse.verb(+vb: String, +rest: List<&2, String>) -> Parsed: 435 sel.p(String.eq(vb, "tui") || String.eq(vb, "watch"), PTui{rest}, sel.p(String.eq(vb, "auto"), PAuto{rest}, sel.p(String.eq(vb, "config"), PConfig{config_args(rest), has_str("--json", rest)}, sel.p(String.eq(vb, "profile"), PProfile{config_args(rest), has_str("--json", rest)}, sel.p(has_str(vb, ["help", "version", "list", "status", "add", "add-token", "remove", "import-usage", "switch"]), parse.scan(vb, scan(rest)), PDeferred{vb}))))) 436 437 def parse.first(+vb: String, +h: String, +rest: List<&2, String>) -> Parsed: 438 sel.p(String.is_empty(vb), bad("unrecognized arguments: " ++ h), parse.verb(vb, rest)) 439 440 # The command a command line asks for, or the usage error (PBad) argparse 441 # would print. 442 def parse(ts: List<&2, String>) -> Parsed: 443 match ts: 444 case Nil{}: 445 bad("no command given - try 'cswap help'") 446 case Con{+h, rest}: 447 parse.first(verb_of(h), h, rest)