args.bend

cswap-bend-cli@0.1.0.0 · 447 lines · 76 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/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
8type 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).
27type Opt is Data:
28  Opt{name: String, value: String}
29
30type KV is Data:
31  KV{k: String, v: String}
32
33def 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
42def 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
49def 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
56def 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.
60def verb_of(+t: String) -> String:
61  verb.pick(t)
62
63def valued() -> List<&2, String>:
64  ["--slot", "--email", "--alias", "--hold", "--strategy", "--model", "--account"]
65
66def switches() -> List<&2, String>:
67  ["--json", "--token-status", "--debug", "--force", "--full", "--install-service", "--uninstall-service", "--service-status"]
68
69def 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
76def is_flag(+t: String) -> Bool:
77  String.starts_with(t, "-") && Nat.is_gt(String.length(t), 1n)
78
79def 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.
89def split_eq(+t: String) -> String & Maybe<&2, String>:
90  split_eq.go(String.split(t, '='))
91
92def 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
99def 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
106def 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
113def 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
120def 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
127def 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
134def prog() -> String:
135  "cswap"
136
137def usage_line() -> String:
138  "usage: cswap <command> [args] [options]"
139
140def flag.msg(on: Bool, msg: String, rest: String) -> String:
141  match on:
142    case True{}:
143      msg
144    case False{}:
145      rest
146
147def first.pick(empty: Bool, rest: String, h: String) -> String:
148  match empty:
149    case True{}:
150      rest
151    case False{}:
152      h
153
154def 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
161def err_if(on: Bool, msg: String) -> String:
162  flag.msg(on, msg, "")
163
164def 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
167def 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
174def 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 <num|email>'"),
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 <num|email>'"),
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
193type Slot is Data:
194  SlotOk{n: Maybe<&2, U32>}
195  SlotBad{msg: String}
196
197def 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
204def 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
211type Hold is Data:
212  HoldOk{secs: Maybe<&2, U32>}
213  HoldBad{msg: String}
214
215def hold.bad() -> Hold:
216  HoldBad{"--hold must be a non-negative number of seconds"}
217
218def 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
225def 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
232def 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
239def 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
246def bad_text(msg: String) -> String:
247  usage_line() ++ "\n" ++ prog() ++ ": error: " ++ msg
248
249def bad(msg: String) -> Parsed:
250  PBad{bad_text(msg)}
251
252def sel.p(c: Bool, a: Parsed, b: Parsed) -> Parsed:
253  match c:
254    case True{}:
255      a
256    case False{}:
257      b
258
259def with_err(+e: String, p: Parsed) -> Parsed:
260  sel.p(String.is_empty(e), p, bad(e))
261
262def 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
269def 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
276def 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
285def 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
292def max_pos(one: Bool) -> Nat:
293  match one:
294    case True{}:
295      1n
296    case False{}:
297      0n
298
299def 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.
303type Alt is Data:
304  Alt{key: String, val: Parsed}
305
306def choose.pick(hit: Bool, v: Parsed, rest: Parsed) -> Parsed:
307  match hit:
308    case True{}:
309      v
310    case False{}:
311      rest
312
313def 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
320def 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
332def 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
335def build.verb(+vb: String, +os: List<&2, Opt>) -> Parsed:
336  build.go(vb, os, positionals(os))
337
338def build(+vb: String, +os: List<&2, Opt>) -> Parsed:
339  with_err(first_err(common_errs(vb, os)), build.verb(vb, os))
340
341def not_flag(+t: String) -> Bool:
342  Bool.not(String.eq(t, "--json") || String.eq(t, "--debug"))
343
344def 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
351def 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
358type Scan is Data:
359  Scan{opts: List<&2, Opt>, pending: String, err: String}
360
361def sel.s(c: Bool, a: String, b: String) -> String:
362  match c:
363    case True{}:
364      a
365    case False{}:
366      b
367
368def first_msg(+err: String, +name: String) -> String:
369  sel.s(String.is_empty(err), "unrecognized arguments: " ++ name, err)
370
371def scan.fail(err: String, +name: String) -> String:
372  first_msg(err, name)
373
374def 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
385def 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
389def 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
396def 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
403def 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
408def 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
415def scan.dangling(+pending: String, err: String) -> String:
416  sel.s(String.is_empty(pending), err, "argument " ++ pending ++ ": expected one argument")
417
418def 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
423def scan(ts: List<&2, String>) -> Scan:
424  scan.end(scan.go(ts, Scan{[], "", ""}))
425
426def scan.done(+err: String, +vb: String, +opts: List<&2, Opt>) -> Parsed:
427  sel.p(String.is_empty(err), build(vb, opts), bad(err))
428
429def parse.scan(+vb: String, sc: Scan) -> Parsed:
430  match sc:
431    case Scan{opts, pending, err}:
432      scan.done(err, vb, opts)
433
434def 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
437def 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.
442def 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)