harn-stdlib 0.8.52

Embedded Harn standard library source catalog
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
// std/cli/argparse — declarative argument parser for CLI subcommand
// `.harn` scripts dispatched via the harn-cli wedge (harn#2293 epic,
// harn#2295 G2). Each ported subcommand declares a parser spec and
// calls parse(spec, argv); the returned ParseResult is either
// `{ ok: dict }` with one entry per registered arg (plus `rest` for
// anything after `--`), or `{ err: ParseError }`.
//
// Out of scope here (per ticket): nested subcommands (each subcommand
// is its own script and top-level dispatch has already picked it),
// shell completions, value-range validators beyond `value_required`.
//
// Argument kinds:
//   "positional" — required by default; set `required: false` to opt out.
//                  Set `variadic: true` to greedily collect remaining
//                  positionals into a list.
//   "flag"       — takes a value. `--long val`, `--long=val`, `-s val`,
//                  or `-sVALUE`. Set `multi: true` to collect repeats.
//   "switch"     — boolean toggle. `--long` or `-s`, no value allowed.
/**
 * Declarative spec for a single argument or flag.
 *
 * - `name`: required, becomes the key in `parse` output.
 * - `kind`: "positional" | "flag" | "switch".
 * - `short`/`long`: flag/switch only. Conventionally include the
 *   leading dashes (`"-m"`, `"--model"`).
 * - `required`: positionals default true; flags/switches default false.
 * - `multi`: flag only. Collects repeats into a list.
 * - `variadic`: positional only. Greedy.
 * - `value_name`: shown in --help; defaults to UPPERCASE(name).
 * - `help`: one-line description.
 * - `default`: value when omitted; falls back to false for switches and
 *   `[]` for multi flags.
 */
type ArgSpec = {
  name: string,
  kind: string,
  short?: string,
  long?: string,
  required?: bool,
  multi?: bool,
  variadic?: bool,
  value_name?: string,
  help?: string,
  default?: any,
}

/** Parser declaration. */
type ParserSpec = {name: string, about?: string, args: list<ArgSpec>, examples?: list<string>}

/**
 * Structured error returned in `ParseResult.err`. Callers decide whether
 * to render with `render_help` or rethrow.
 *
 * `kind` values:
 *   "missing_required"  — a required arg was not supplied
 *   "unknown_flag"      — `--foo` not registered in the spec
 *   "unknown_arg"       — extra positional past the registered count
 *   "value_required"    — a flag was passed without its value
 *   "bad_value"         — value supplied where none expected (switch)
 */
type ParseError = {kind: string, arg?: string, hint?: string}

type ParseResult = {ok?: dict, err?: ParseError}

/**
 * Validate a parser spec and return it. Currently a pass-through plus
 * structural checks; in the future this may precompute lookup tables.
 *
 * Throws on programmer error (invalid kind or missing name) since the
 * spec is static and any failure here means the script is buggy.
 *
 * @effects: []
 * @allocation: none
 * @errors: ["argparse: every arg must have a non-empty name", "argparse: arg N has invalid kind 'K'; expected positional|flag|switch"]
 * @api_stability: stable
 * @example: parser({name: "demo", args: [{name: "input", kind: "positional"}]})
 */
pub fn parser(spec: ParserSpec) -> ParserSpec {
  for arg in spec.args {
    let name = arg.name
    if name == nil || name == "" {
      throw "std/cli/argparse: every arg must have a non-empty name"
    }
    let kind = arg.kind ?? ""
    if kind != "positional" && kind != "flag" && kind != "switch" {
      throw "std/cli/argparse: arg " + name + " has invalid kind '" + kind
        + "'; expected positional|flag|switch"
    }
  }
  return spec
}

/** --- Lookup helpers --------------------------------------------------- */
fn __find_long(args, key) {
  for arg in args {
    if arg?.long == key {
      return arg
    }
  }
  return nil
}

fn __find_short(args, key) {
  for arg in args {
    if arg?.short == key {
      return arg
    }
  }
  return nil
}

fn __positionals(args) {
  var out = []
  for arg in args {
    if arg?.kind == "positional" {
      out = out.push(arg)
    }
  }
  return out
}

fn __flags_only(args) {
  var out = []
  for arg in args {
    if arg?.kind == "flag" || arg?.kind == "switch" {
      out = out.push(arg)
    }
  }
  return out
}

/** --- Parse ------------------------------------------------------------ */
fn __record_value(collected, arg, value) {
  if arg?.multi {
    let prior = collected[arg.name] ?? []
    return collected.merge({[arg.name]: prior.push(value)})
  }
  return collected.merge({[arg.name]: value})
}

fn __apply_defaults(collected, args) {
  var out = collected
  for arg in args {
    if out[arg.name] != nil {
      continue
    }
    if arg?.default != nil {
      out = out.merge({[arg.name]: arg.default})
      continue
    }
    if arg?.kind == "switch" {
      out = out.merge({[arg.name]: false})
      continue
    }
    if arg?.kind == "flag" && arg?.multi {
      out = out.merge({[arg.name]: []})
    }
  }
  return out
}

fn __first_missing_required(args, collected) -> string {
  for arg in args {
    if collected[arg.name] != nil {
      continue
    }
    // Positionals default to required; flags/switches default to
    // optional. The `?? true|false` form makes the nil case explicit
    // since the linter rewrites bare `arg?.required` to a truthy
    // check (which treats nil as false and breaks the positional
    // default).
    let default_req = arg?.kind == "positional"
    let is_required = arg?.required ?? default_req
    if is_required {
      return arg.name
    }
  }
  return ""
}

fn __long_split(body) {
  // body is the post-"--" text, e.g. "foo=bar baz".
  // Returns {key: "--KEY", value: VALUE_OR_NIL}.
  if !contains(body, "=") {
    return {key: "--" + body, value: nil}
  }
  let parts = split(body, "=")
  let key = parts[0]
  // Re-join everything after the first "=" so a value containing
  // further `=` survives intact.
  let value = join(parts.slice(1, len(parts)), "=")
  return {key: "--" + key, value: value}
}

/**
 * Parse argv against a spec. Returns `{ ok: dict }` with one key per
 * registered arg (plus `rest: list<string>` for everything after `--`)
 * on success, or `{ err: ParseError }` on failure.
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: stable
 * @example: parse(spec, argv)
 */
pub fn parse(spec: ParserSpec, argv: list<string>) -> ParseResult {
  var collected = {}
  var rest = []
  var pending: any = nil
  var seen_dash_dash = false
  var positional_idx = 0
  let positionals = __positionals(spec.args)
  for arg_str in argv {
    if seen_dash_dash {
      rest = rest.push(arg_str)
      continue
    }
    if pending != nil {
      collected = __record_value(collected, pending, arg_str)
      pending = nil
      continue
    }
    if arg_str == "--" {
      seen_dash_dash = true
      continue
    }
    // Long flag: --key, --key=value
    if starts_with(arg_str, "--") && len(arg_str) > 2 {
      let body = substring(arg_str, 2, len(arg_str))
      let {key, value: value_opt} = __long_split(body)
      let arg = __find_long(spec.args, key)
      if arg == nil {
        return {err: {kind: "unknown_flag", arg: arg_str, hint: "no flag named ${key}"}}
      }
      if arg.kind == "switch" {
        if value_opt != nil {
          return {err: {kind: "bad_value", arg: arg_str, hint: "switch ${key} does not take a value"}}
        }
        collected = collected.merge({[arg.name]: true})
        continue
      }
      if value_opt != nil {
        collected = __record_value(collected, arg, value_opt)
      } else {
        pending = arg
      }
      continue
    }
    // Short flag: -x, -xVALUE
    if starts_with(arg_str, "-") && len(arg_str) > 1 {
      let short = substring(arg_str, 0, 2)
      let arg = __find_short(spec.args, short)
      if arg == nil {
        return {err: {kind: "unknown_flag", arg: arg_str, hint: "no flag named ${short}"}}
      }
      if arg.kind == "switch" {
        if len(arg_str) > 2 {
          return {err: {kind: "bad_value", arg: arg_str, hint: "switch ${short} does not take a value"}}
        }
        collected = collected.merge({[arg.name]: true})
        continue
      }
      // Flag: either inline -xVALUE or split -x VALUE
      if len(arg_str) > 2 {
        let v = substring(arg_str, 2, len(arg_str))
        collected = __record_value(collected, arg, v)
      } else {
        pending = arg
      }
      continue
    }
    // Positional
    if positional_idx >= len(positionals) {
      return {err: {kind: "unknown_arg", arg: arg_str, hint: "unexpected positional"}}
    }
    let pos = positionals[positional_idx]
    if pos?.variadic {
      // Don't advance positional_idx — variadics swallow the rest.
      let prior = collected[pos.name] ?? []
      collected = collected.merge({[pos.name]: prior.push(arg_str)})
    } else {
      collected = collected.merge({[pos.name]: arg_str})
      positional_idx = positional_idx + 1
    }
  }
  if pending != nil {
    let label = pending?.long ?? pending?.short ?? pending.name
    return {err: {kind: "value_required", arg: label, hint: "flag requires a value"}}
  }
  collected = __apply_defaults(collected, spec.args)
  let missing = __first_missing_required(spec.args, collected)
  if missing != "" {
    return {
      err: {kind: "missing_required", arg: missing, hint: "required argument '${missing}' was not provided"},
    }
  }
  return {ok: collected.merge({rest: rest})}
}

/** --- Help rendering --------------------------------------------------- */
fn __value_name(arg) -> string {
  if arg?.value_name != nil {
    return arg.value_name
  }
  return uppercase(arg.name)
}

fn __usage_token(arg) -> string {
  if arg.kind == "positional" {
    // Positionals default to required when `required` is unset.
    let is_required = arg?.required ?? true
    if !is_required {
      return "[${arg.name}]"
    }
    if arg?.variadic {
      return "<${arg.name}>..."
    }
    return "<${arg.name}>"
  }
  let flag_marker = arg?.long ?? arg?.short ?? ("--" + arg.name)
  if arg.kind == "switch" {
    return "[${flag_marker}]"
  }
  return "[${flag_marker} <${__value_name(arg)}>]"
}

fn __option_lhs(arg) -> string {
  let short = arg?.short ?? ""
  let long = arg?.long ?? ""
  let combined = if short != "" && long != "" {
    "${short}, ${long}"
  } else if long != "" {
    "    ${long}"
  } else if short != "" {
    "${short}"
  } else {
    "    --${arg.name}"
  }
  if arg.kind == "flag" {
    return "  ${combined} <${__value_name(arg)}>"
  }
  return "  ${combined}"
}

fn __positional_lhs(arg) -> string {
  // Positionals default to required when `required` is unset.
  let is_required = arg?.required ?? true
  let token = if arg?.variadic {
    "<${arg.name}>..."
  } else if !is_required {
    "[${arg.name}]"
  } else {
    "<${arg.name}>"
  }
  return "  ${token}"
}

fn __help_arg_line(arg) -> string {
  let lhs = if arg.kind == "positional" {
    __positional_lhs(arg)
  } else {
    __option_lhs(arg)
  }
  let help = arg?.help ?? ""
  if help == "" {
    return lhs
  }
  return str_pad(lhs, 32, " ") + " " + help
}

/**
 * Render the `--help` text for a parser spec. The format is stable —
 * snapshot tests pin it — so changes here must intentionally update
 * the conformance fixtures.
 *
 * @effects: []
 * @allocation: heap
 * @errors: []
 * @api_stability: stable
 * @example: render_help(spec)
 */
pub fn render_help(spec: ParserSpec) -> string {
  var lines = []
  if spec.about != nil && spec.about != "" {
    lines = lines.push(spec.about)
    lines = lines.push("")
  }
  let flags = __flags_only(spec.args)
  let positionals = __positionals(spec.args)
  var usage_tokens = ["  " + spec.name]
  if len(flags) > 0 {
    usage_tokens = usage_tokens.push("[OPTIONS]")
  }
  for arg in positionals {
    usage_tokens = usage_tokens.push(__usage_token(arg))
  }
  lines = lines.push("USAGE:")
  lines = lines.push(join(usage_tokens, " "))
  if len(positionals) > 0 {
    lines = lines.push("")
    lines = lines.push("ARGS:")
    for arg in positionals {
      lines = lines.push(__help_arg_line(arg))
    }
  }
  if len(flags) > 0 {
    lines = lines.push("")
    lines = lines.push("OPTIONS:")
    for arg in flags {
      lines = lines.push(__help_arg_line(arg))
    }
    lines = lines.push(str_pad("  -h, --help", 32, " ") + " Print help")
  }
  if spec.examples != nil && len(spec.examples) > 0 {
    lines = lines.push("")
    lines = lines.push("EXAMPLES:")
    for ex in spec.examples {
      lines = lines.push("  ${ex}")
    }
  }
  return join(lines, "\n")
}