@loadmap/cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,7 +38,7 @@ function boundOf(c, bound) {
38
38
  }
39
39
  // ../core/src/format.ts
40
40
  /** A number for a reader: 1,234 / 12.3 / 0.4. */
41
- function fmt(n) {
41
+ function fmt$1(n) {
42
42
  if (!Number.isFinite(n)) return n > 0 ? "∞" : "—";
43
43
  if (Math.abs(n) >= 1e3) return Math.round(n).toLocaleString("en-US");
44
44
  return (Math.round(n * 10) / 10).toString();
@@ -55,7 +55,7 @@ function compact(n) {
55
55
  /** A load multiple, rounded down so it never promises more than the model: 2.6× / 53×. */
56
56
  function multiple(u, searchMax = 2e3) {
57
57
  if (u == null) return "—";
58
- if (!Number.isFinite(u)) return `beyond ${fmt(searchMax)}×`;
58
+ if (!Number.isFinite(u)) return `beyond ${fmt$1(searchMax)}×`;
59
59
  return u >= 10 ? `${Math.floor(u)}×` : `${Math.floor(u * 10) / 10}×`;
60
60
  }
61
61
  /**
@@ -1041,7 +1041,7 @@ function sureness(c) {
1041
1041
  return ` (capacity ${c.provenance}${c.within ? ` ±${pct(c.within)}` : ""})`;
1042
1042
  }
1043
1043
  function verdict(load, live, first, range, th, searchMax, when) {
1044
- const at = `At ${fmt(load)}×`;
1044
+ const at = `At ${fmt$1(load)}×`;
1045
1045
  const target = `${pct(th.target)} target`;
1046
1046
  if (!first) return {
1047
1047
  tier: "ok",
@@ -1157,7 +1157,7 @@ function check$1(model, expectations = model.expectations) {
1157
1157
  });
1158
1158
  }
1159
1159
  const failed = claims.filter((x) => !x.ok);
1160
- const message = failed.length ? failed.map((x) => `expected at least ${fmt(x.min)}×, but ${x.text}`).join("; ") : claims.map((x) => x.text).join("; ");
1160
+ const message = failed.length ? failed.map((x) => `expected at least ${fmt$1(x.min)}×, but ${x.text}`).join("; ") : claims.map((x) => x.text).join("; ");
1161
1161
  return {
1162
1162
  expectation: e,
1163
1163
  ok: !failed.length,
@@ -1361,6 +1361,26 @@ function distance(a, b) {
1361
1361
  function own(o, k) {
1362
1362
  return Object.hasOwn(o, k) ? o[k] : void 0;
1363
1363
  }
1364
+ /**
1365
+ * Which argument of a statement fills which slot: by kind, in order, optional slots skipped. `bad` are the ones that
1366
+ * fit no slot, `missing` the first required slot left empty.
1367
+ */
1368
+ function matchArgs(slots, args) {
1369
+ const open = [...slots];
1370
+ const filled = /* @__PURE__ */ new Map();
1371
+ const bad = [];
1372
+ for (const a of args) {
1373
+ const kind = a.t === "id" ? "id" : a.t === "str" || a.t === "tpl" ? "text" : a.t === "num" ? "number" : null;
1374
+ const i = open.findIndex((s) => s.kind === kind);
1375
+ if (kind == null || i < 0 || open.slice(0, i).some((s) => !s.optional)) bad.push(a);
1376
+ else filled.set(open.splice(0, i + 1).at(-1), a);
1377
+ }
1378
+ return {
1379
+ filled,
1380
+ bad,
1381
+ missing: open.find((s) => !s.optional)
1382
+ };
1383
+ }
1364
1384
  const STAGE_LABEL = [{
1365
1385
  name: "id",
1366
1386
  kind: "id",
@@ -1393,11 +1413,13 @@ const COMPONENT_PROPS = {
1393
1413
  doc: "\"are past capacity\" instead of \"is\"."
1394
1414
  },
1395
1415
  demand: {
1416
+ inspector: "main",
1396
1417
  value: "number",
1397
1418
  scope: "ctx",
1398
1419
  doc: "What the component must handle, in its capacity's unit. Without one: the units per second reaching its stages."
1399
1420
  },
1400
1421
  capacity: {
1422
+ inspector: "main",
1401
1423
  value: "capacity",
1402
1424
  scope: "ctx",
1403
1425
  doc: "What it can handle: measured(value, unit, source) or estimated(value, unit, source), `value ± 30%` saying how sure it is; configured(value, unit, source) for a limit someone set."
@@ -1461,6 +1483,7 @@ const SCHEMA = {
1461
1483
  args: [],
1462
1484
  props: {
1463
1485
  title: {
1486
+ inspector: "main",
1464
1487
  value: "string",
1465
1488
  scope: "const",
1466
1489
  doc: "The heading above the diagram."
@@ -1519,6 +1542,7 @@ const SCHEMA = {
1519
1542
  children: []
1520
1543
  },
1521
1544
  load: {
1545
+ inspector: "model",
1522
1546
  doc: "The load, as a multiple of today. It drives the limits.",
1523
1547
  example: "load \"Users, × today\" { max: 100; default: 10 }",
1524
1548
  args: [{
@@ -1533,6 +1557,7 @@ const SCHEMA = {
1533
1557
  doc: "The lowest multiple. Default 1."
1534
1558
  },
1535
1559
  max: {
1560
+ inspector: "main",
1536
1561
  value: "number",
1537
1562
  scope: "const",
1538
1563
  doc: "The highest multiple on the slider.",
@@ -1544,6 +1569,7 @@ const SCHEMA = {
1544
1569
  doc: "The slider step. Default 1."
1545
1570
  },
1546
1571
  default: {
1572
+ inspector: "main",
1547
1573
  value: "number",
1548
1574
  scope: "const",
1549
1575
  doc: "Where the slider starts. Default the minimum."
@@ -1552,6 +1578,7 @@ const SCHEMA = {
1552
1578
  children: []
1553
1579
  },
1554
1580
  param: {
1581
+ inspector: "model",
1555
1582
  doc: "A control: options make a select, min and max a slider, a true or false default a switch.",
1556
1583
  example: "param pods \"Worker pods\" { options: [2, 3, 4, 6]; default: 3 }",
1557
1584
  args: [{
@@ -1564,26 +1591,31 @@ const SCHEMA = {
1564
1591
  }],
1565
1592
  props: {
1566
1593
  options: {
1594
+ inspector: "main",
1567
1595
  value: "options",
1568
1596
  scope: "const",
1569
1597
  doc: "The choices of a select: [2, 3, 4] or [\"a\", \"b\"]."
1570
1598
  },
1571
1599
  min: {
1600
+ inspector: "main",
1572
1601
  value: "number",
1573
1602
  scope: "const",
1574
1603
  doc: "The lowest value of a slider."
1575
1604
  },
1576
1605
  max: {
1606
+ inspector: "main",
1577
1607
  value: "number",
1578
1608
  scope: "const",
1579
1609
  doc: "The highest value of a slider."
1580
1610
  },
1581
1611
  step: {
1612
+ inspector: "main",
1582
1613
  value: "number",
1583
1614
  scope: "const",
1584
1615
  doc: "The step of a slider. Default 1."
1585
1616
  },
1586
1617
  default: {
1618
+ inspector: "main",
1587
1619
  value: "value",
1588
1620
  scope: "const",
1589
1621
  doc: "The starting value.",
@@ -1649,11 +1681,13 @@ const SCHEMA = {
1649
1681
  }
1650
1682
  },
1651
1683
  growth: {
1684
+ inspector: "model",
1652
1685
  doc: "How fast the load grows, compounded from asOf, where it is 1×: load multiples become dates.",
1653
1686
  example: "growth { rate: 0.06; per: month; asOf: \"2026-11-01\" }",
1654
1687
  args: [],
1655
1688
  props: {
1656
1689
  rate: {
1690
+ inspector: "main",
1657
1691
  value: "number",
1658
1692
  scope: "const",
1659
1693
  required: true,
@@ -1742,6 +1776,7 @@ const SCHEMA = {
1742
1776
  }
1743
1777
  },
1744
1778
  let: {
1779
+ inspector: "model",
1745
1780
  doc: "A named formula other formulas can use by its name.",
1746
1781
  example: "let perPosition = watchOnly ? 0.3 : 1.2",
1747
1782
  args: [{
@@ -1753,6 +1788,7 @@ const SCHEMA = {
1753
1788
  children: []
1754
1789
  },
1755
1790
  fit: {
1791
+ inspector: "model",
1756
1792
  doc: "cost = a + b × x, fitted from measured points. Call it like a function: workerCores(rate).",
1757
1793
  example: "fit workerCores \"Worker CPU per request\" { source: \"load test\"; points rate cores { 200 0.26 } }",
1758
1794
  args: [{
@@ -1793,6 +1829,7 @@ const SCHEMA = {
1793
1829
  rows: true
1794
1830
  },
1795
1831
  flow: {
1832
+ inspector: "model",
1796
1833
  doc: "Something that moves through the system, with its rate and its route.",
1797
1834
  example: "flow orders \"orders\" { unit: \"order\"; rate: 40 * load; route: gateway -> topic -> workers }",
1798
1835
  args: [{
@@ -1810,6 +1847,7 @@ const SCHEMA = {
1810
1847
  doc: "One unit of the flow, singular: \"order\"."
1811
1848
  },
1812
1849
  rate: {
1850
+ inspector: "main",
1813
1851
  value: "number",
1814
1852
  scope: "ctx",
1815
1853
  doc: "Units per second.",
@@ -1891,12 +1929,14 @@ const SCHEMA = {
1891
1929
  args: [],
1892
1930
  props: {
1893
1931
  times: {
1932
+ inspector: "main",
1894
1933
  value: "number",
1895
1934
  scope: "values",
1896
1935
  doc: "Retries after the first attempt: a whole number. Reads only the parameters.",
1897
1936
  required: true
1898
1937
  },
1899
1938
  budget: {
1939
+ inspector: "main",
1900
1940
  value: "number",
1901
1941
  scope: "values",
1902
1942
  doc: "At most this share of the flow is added as retries: 0.1 adds at most 10 %. Without one, every retry is sent."
@@ -1955,6 +1995,7 @@ const SCHEMA = {
1955
1995
  props: {
1956
1996
  sub: SUB,
1957
1997
  members: {
1998
+ inspector: "main",
1958
1999
  value: "number",
1959
2000
  scope: "ctx",
1960
2001
  doc: "How many members.",
@@ -1997,6 +2038,7 @@ const SCHEMA = {
1997
2038
  args: STAGE_LABEL,
1998
2039
  props: {
1999
2040
  hit: {
2041
+ inspector: "main",
2000
2042
  value: "number",
2001
2043
  scope: "ctx",
2002
2044
  doc: "The share answered here, 0 to 1.",
@@ -2055,11 +2097,13 @@ const SCHEMA = {
2055
2097
  args: STAGE_LABEL,
2056
2098
  props: {
2057
2099
  pods: {
2100
+ inspector: "main",
2058
2101
  value: "number",
2059
2102
  scope: "ctx",
2060
2103
  doc: "How many pods; an autoscaled pool has autoscale instead."
2061
2104
  },
2062
2105
  perPod: {
2106
+ inspector: "main",
2063
2107
  value: "number",
2064
2108
  scope: "ctx",
2065
2109
  doc: "Workers per pod.",
@@ -2081,18 +2125,21 @@ const SCHEMA = {
2081
2125
  args: [],
2082
2126
  props: {
2083
2127
  min: {
2128
+ inspector: "main",
2084
2129
  value: "number",
2085
2130
  scope: "ctx",
2086
2131
  doc: "The fewest pods, at least 1.",
2087
2132
  required: true
2088
2133
  },
2089
2134
  max: {
2135
+ inspector: "main",
2090
2136
  value: "number",
2091
2137
  scope: "ctx",
2092
2138
  doc: "The most pods.",
2093
2139
  required: true
2094
2140
  },
2095
2141
  target: {
2142
+ inspector: "main",
2096
2143
  value: "number",
2097
2144
  scope: "ctx",
2098
2145
  doc: "The utilization each running pod is held at, above 0 and at most 1: 0.7 scales out above 70 %.",
@@ -2110,6 +2157,7 @@ const SCHEMA = {
2110
2157
  edges: "branches"
2111
2158
  },
2112
2159
  thresholds: {
2160
+ inspector: "model",
2113
2161
  doc: "The utilization of the target and of \"runs out\".",
2114
2162
  example: "thresholds { target: 0.5; full: 1 }",
2115
2163
  args: [],
@@ -2226,7 +2274,7 @@ const BUILTINS = {
2226
2274
  fmt: {
2227
2275
  args: ["number"],
2228
2276
  result: "string",
2229
- fn: fmt
2277
+ fn: fmt$1
2230
2278
  },
2231
2279
  compact: {
2232
2280
  args: ["number"],
@@ -2638,8 +2686,11 @@ const PUNCT = [
2638
2686
  /**
2639
2687
  * Splits `source[from, to)` into tokens. Newlines end statements, except inside parentheses and brackets, so a long
2640
2688
  * formula can wrap there. `#` starts a comment to the end of the line.
2689
+ *
2690
+ * With `trivia` (for the formatter, never the parser) every newline is a token, blank lines and wrapped formulas
2691
+ * included, and so is every comment.
2641
2692
  */
2642
- function lex(source, report, from = 0, to = source.length) {
2693
+ function lex(source, report, from = 0, to = source.length, trivia = false) {
2643
2694
  const out = [];
2644
2695
  let depth = 0;
2645
2696
  let i = from;
@@ -2647,7 +2698,7 @@ function lex(source, report, from = 0, to = source.length) {
2647
2698
  while (i < to) {
2648
2699
  const c = source[i];
2649
2700
  if (c === "\n") {
2650
- if (depth === 0 && out.at(-1)?.kind !== "newline") push({
2701
+ if (trivia || depth === 0 && out.at(-1)?.kind !== "newline") push({
2651
2702
  kind: "newline",
2652
2703
  text: "\n",
2653
2704
  from: i,
@@ -2655,8 +2706,16 @@ function lex(source, report, from = 0, to = source.length) {
2655
2706
  });
2656
2707
  i++;
2657
2708
  } else if (c === " " || c === " " || c === "\r") i++;
2658
- else if (c === "#") while (i < to && source[i] !== "\n") i++;
2659
- else if (isIdStart(c)) {
2709
+ else if (c === "#") {
2710
+ const s = i;
2711
+ while (i < to && source[i] !== "\n") i++;
2712
+ if (trivia) push({
2713
+ kind: "comment",
2714
+ text: source.slice(s, i).trimEnd(),
2715
+ from: s,
2716
+ to: i
2717
+ });
2718
+ } else if (isIdStart(c)) {
2660
2719
  const s = i;
2661
2720
  while (i < to && isIdPart(source[i])) i++;
2662
2721
  push({
@@ -2859,7 +2918,7 @@ const PREC = {
2859
2918
  /** Thrown after a syntax error has been reported, to skip to the next statement. */
2860
2919
  var Bail$1 = class {};
2861
2920
  /** Parses a definition into statements. Never throws: errors go to the reporter, and parsing goes on at the next line. */
2862
- function parse$1(source, report = new Reporter(source)) {
2921
+ function parse$2(source, report = new Reporter(source)) {
2863
2922
  if (source.length > 2e5) {
2864
2923
  report.error({
2865
2924
  from: 0,
@@ -3343,7 +3402,7 @@ function compile(source, o = {}) {
3343
3402
  let definition;
3344
3403
  let expectations = [];
3345
3404
  try {
3346
- doc = parse$1(source, report).doc;
3405
+ doc = parse$2(source, report).doc;
3347
3406
  definition = c.run(doc);
3348
3407
  if (definition) expectations = check$1(definition.model).map((result, i) => {
3349
3408
  const at = c.expectSpans[i];
@@ -3748,16 +3807,9 @@ var Compiler = class {
3748
3807
  return b;
3749
3808
  }
3750
3809
  args(b, d) {
3751
- const slots = [...b.spec.args];
3752
- for (const a of d.args) {
3753
- const kind = a.t === "id" ? "id" : a.t === "str" || a.t === "tpl" ? "text" : a.t === "num" ? "number" : null;
3754
- const i = slots.findIndex((s) => s.kind === kind);
3755
- if (kind == null || i < 0 || slots.slice(0, i).some((s) => !s.optional)) {
3756
- this.report.error(a, `unexpected ${this.slice(a)} after "${b.keyword}"`, usage(b.keyword));
3757
- continue;
3758
- }
3759
- const slot = slots[i];
3760
- slots.splice(0, i + 1);
3810
+ const { filled, bad, missing } = matchArgs(b.spec.args, d.args);
3811
+ for (const a of bad) this.report.error(a, `unexpected ${this.slice(a)} after "${b.keyword}"`, usage(b.keyword));
3812
+ for (const [slot, a] of filled) {
3761
3813
  if (a.t === "tpl" && !slot.dynamic && !a.parts.length) {
3762
3814
  b.args[slot.name] = {
3763
3815
  t: "str",
@@ -3775,7 +3827,6 @@ var Compiler = class {
3775
3827
  };
3776
3828
  else b.args[slot.name] = a;
3777
3829
  }
3778
- const missing = slots.find((s) => !s.optional);
3779
3830
  if (missing) this.report.error(b.at, `"${b.keyword}" needs its ${missing.name}`, usage(b.keyword));
3780
3831
  if (b.spec.init && !d.init) this.report.error(b.at, `"${b.keyword}" needs "= formula"`, usage(b.keyword));
3781
3832
  if (!b.spec.init && d.init) this.report.error(d.init, `"${b.keyword}" takes no "="`);
@@ -4467,6 +4518,200 @@ function where(keyword) {
4467
4518
  const parents = Object.entries(SCHEMA).filter(([, s]) => s.children.includes(keyword)).map(([k]) => k === "" ? "the top level" : k);
4468
4519
  return parents.length ? `it goes in ${parents.join(" or ")}` : void 0;
4469
4520
  }
4521
+ // ../dsl/src/ast.ts
4522
+ /** The formulas directly inside a formula, in order; a template's parts only when asked (they are its own text). */
4523
+ function childrenOf(e, templates = false) {
4524
+ switch (e.t) {
4525
+ case "unary": return [e.arg];
4526
+ case "pm": return [e.value, e.within];
4527
+ case "cond": return [
4528
+ e.test,
4529
+ e.then,
4530
+ e.else
4531
+ ];
4532
+ case "bin": return [e.left, e.right];
4533
+ case "call": return e.args;
4534
+ case "array": return e.items;
4535
+ case "tpl": return templates ? e.parts : [];
4536
+ default: return [];
4537
+ }
4538
+ }
4539
+ /**
4540
+ * Lays a definition out the one way: two spaces a block, a statement a line, at most one blank line, one space
4541
+ * around operators. It works on the tokens, so numbers, texts, templates and parentheses stay exactly as written and
4542
+ * a formula never changes. A block written on one line stays on one line while it fits. A text with a syntax error is
4543
+ * left alone.
4544
+ */
4545
+ function format(source) {
4546
+ const { doc, report } = parse$2(source);
4547
+ if (report.failed) return {
4548
+ ok: false,
4549
+ text: source,
4550
+ diagnostics: report.list
4551
+ };
4552
+ const toks = lex(source, new Reporter(source), 0, source.length, true).filter((t) => t.kind !== "eof");
4553
+ const marks = marksOf(doc.body, toks);
4554
+ const lines = new Reporter(source);
4555
+ const match = /* @__PURE__ */ new Map();
4556
+ const open = [];
4557
+ for (let i = 0; i < toks.length; i++) if (isPunct(toks[i], "{")) open.push(i);
4558
+ else if (isPunct(toks[i], "}")) match.set(open.pop(), i);
4559
+ const empty = /* @__PURE__ */ new Set();
4560
+ const written = /* @__PURE__ */ new Set();
4561
+ for (const [i, j] of match) if (toks.slice(i + 1, j).every((t) => t.kind === "newline")) empty.add(i);
4562
+ else if (lines.position(toks[i].from).line !== lines.position(toks[j].from).line) written.add(i);
4563
+ const index = new Map(toks.map((t, i) => [t, i]));
4564
+ const split = /* @__PURE__ */ new Set();
4565
+ const oneLine = (t) => {
4566
+ const i = index.get(t);
4567
+ return isPunct(t, "{") && !empty.has(i) && !written.has(i) && !split.has(i);
4568
+ };
4569
+ for (;;) {
4570
+ const out = layout(toks, match, (i) => split.has(i) || written.has(i), empty);
4571
+ const text = out.map((l) => l.toks.length ? " ".repeat(l.depth) + join$1(l.toks, marks) : "");
4572
+ const long = out.find((l, n) => [...text[n]].length > 120 && l.toks.some(oneLine));
4573
+ if (!long) return {
4574
+ ok: true,
4575
+ text: text.length ? `${text.join("\n")}\n` : "",
4576
+ diagnostics: []
4577
+ };
4578
+ split.add(index.get(long.toks.find(oneLine)));
4579
+ }
4580
+ }
4581
+ /** The tokens in lines with their depth. `multi` tells a block laid out a statement a line. */
4582
+ function layout(toks, match, multi, empty) {
4583
+ const out = [];
4584
+ let cur = [];
4585
+ let depth = 0;
4586
+ let lineDepth = 0;
4587
+ let newlines = 0;
4588
+ let start = true;
4589
+ const blocks = [true];
4590
+ const end = () => {
4591
+ if (cur.length) out.push({
4592
+ depth: lineDepth,
4593
+ toks: cur
4594
+ });
4595
+ cur = [];
4596
+ };
4597
+ const add = (t) => {
4598
+ if (!cur.length) {
4599
+ if (newlines > 1 && !start && !isPunct(t, "}")) out.push({
4600
+ depth: 0,
4601
+ toks: []
4602
+ });
4603
+ lineDepth = depth - (isPunct(t, "}") || isPunct(t, ")") || isPunct(t, "]") ? 1 : 0);
4604
+ }
4605
+ newlines = 0;
4606
+ start = false;
4607
+ cur.push(t);
4608
+ };
4609
+ for (let i = 0; i < toks.length; i++) {
4610
+ const t = toks[i];
4611
+ if (t.kind === "newline") {
4612
+ end();
4613
+ newlines++;
4614
+ } else if (isPunct(t, ";")) {
4615
+ if (blocks.at(-1)) {
4616
+ if (toks[i + 1]?.kind !== "comment") end();
4617
+ } else {
4618
+ const next = toks[i + 1];
4619
+ const last = cur.at(-1);
4620
+ if (!(!next || next.kind === "newline" || next.kind === "comment" || isPunct(next, "}") || !last || isPunct(last, "{") || isPunct(last, ";"))) add(t);
4621
+ }
4622
+ } else if (isPunct(t, "{")) {
4623
+ if (empty.has(i)) {
4624
+ add(t);
4625
+ i = match.get(i);
4626
+ add(toks[i]);
4627
+ continue;
4628
+ }
4629
+ const m = multi(i);
4630
+ add(t);
4631
+ depth++;
4632
+ blocks.push(m);
4633
+ if (m) {
4634
+ if (toks[i + 1]?.kind === "comment") add(toks[++i]);
4635
+ end();
4636
+ start = true;
4637
+ }
4638
+ } else if (isPunct(t, "}")) {
4639
+ if (blocks.pop()) end();
4640
+ add(t);
4641
+ depth--;
4642
+ } else if (isPunct(t, "(") || isPunct(t, "[")) {
4643
+ add(t);
4644
+ depth++;
4645
+ } else if (isPunct(t, ")") || isPunct(t, "]")) {
4646
+ add(t);
4647
+ depth--;
4648
+ } else add(t);
4649
+ }
4650
+ end();
4651
+ return out;
4652
+ }
4653
+ function marksOf(body, toks) {
4654
+ const m = {
4655
+ unary: /* @__PURE__ */ new Set(),
4656
+ percent: /* @__PURE__ */ new Set(),
4657
+ ternary: /* @__PURE__ */ new Set()
4658
+ };
4659
+ const after = (at) => {
4660
+ let lo = 0;
4661
+ let hi = toks.length;
4662
+ while (lo < hi) {
4663
+ const mid = lo + hi >> 1;
4664
+ if (toks[mid].from < at) lo = mid + 1;
4665
+ else hi = mid;
4666
+ }
4667
+ return lo;
4668
+ };
4669
+ const find = (from, text) => {
4670
+ for (let i = after(from); i < toks.length; i++) if (isPunct(toks[i], text)) return toks[i].from;
4671
+ return -1;
4672
+ };
4673
+ const expr = (e) => {
4674
+ if (!e) return;
4675
+ if (e.t === "unary") {
4676
+ let at = -1;
4677
+ for (let i = after(e.from); i < toks.length && toks[i].from < e.arg.from; i++) if (isPunct(toks[i], e.op)) at = toks[i].from;
4678
+ m.unary.add(at);
4679
+ } else if (e.t === "pm" && e.percent) m.percent.add(find(e.within.to, "%"));
4680
+ else if (e.t === "cond") m.ternary.add(find(e.then.to, ":"));
4681
+ for (const k of childrenOf(e)) expr(k);
4682
+ };
4683
+ const stmt = (s) => {
4684
+ if (s.kind === "row") s.values.forEach(expr);
4685
+ else if (s.kind === "prop") expr(s.value);
4686
+ else {
4687
+ if (s.kind === "decl") {
4688
+ s.args.forEach(expr);
4689
+ expr(s.init);
4690
+ } else expr(s.value);
4691
+ s.body?.forEach(stmt);
4692
+ }
4693
+ };
4694
+ body.forEach(stmt);
4695
+ return m;
4696
+ }
4697
+ function join$1(toks, m) {
4698
+ let s = toks[0].text;
4699
+ for (let i = 1; i < toks.length; i++) s += gap(toks[i - 1], toks[i], m) + toks[i].text;
4700
+ return s;
4701
+ }
4702
+ function gap(a, b, m) {
4703
+ const A = a.kind === "punct" ? a.text : "";
4704
+ const B = b.kind === "punct" ? b.text : "";
4705
+ if (A === "{" && B === "}") return "";
4706
+ if (A === "(" || A === "[" || A === ".") return "";
4707
+ if ((A === "-" || A === "+" || A === "!") && m.unary.has(a.from)) return "";
4708
+ if (B === ")" || B === "]" || B === "," || B === ";" || B === ".") return "";
4709
+ if (B === "%" && m.percent.has(b.from)) return "";
4710
+ if (B === ":" && !m.ternary.has(b.from)) return "";
4711
+ if (B === "(" && a.kind === "ident") return "";
4712
+ return " ";
4713
+ }
4714
+ const isPunct = (t, text) => t.kind === "punct" && t.text === text;
4470
4715
  // src/check.ts
4471
4716
  /**
4472
4717
  * `loadmap check`: compiles each definition and tests its expect lines. Resolves to the exit code: 1 when a
@@ -4476,7 +4721,7 @@ async function check(argv, o = {}) {
4476
4721
  const log = o.log ?? console.log;
4477
4722
  let parsed;
4478
4723
  try {
4479
- parsed = parse(argv, o.env ?? process.env);
4724
+ parsed = parse$1(argv, o.env ?? process.env);
4480
4725
  } catch (err) {
4481
4726
  log(err.message);
4482
4727
  return 2;
@@ -4505,7 +4750,7 @@ async function check(argv, o = {}) {
4505
4750
  }
4506
4751
  return failed ? 1 : 0;
4507
4752
  }
4508
- function parse(argv, env) {
4753
+ function parse$1(argv, env) {
4509
4754
  const { values, positionals } = parseArgs({
4510
4755
  args: argv,
4511
4756
  allowPositionals: true,
@@ -4540,8 +4785,90 @@ function github(d, file, source) {
4540
4785
  }
4541
4786
  const data = (s) => s.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
4542
4787
  const prop = (s) => data(s).replaceAll(":", "%3A").replaceAll(",", "%2C");
4788
+ // src/fmt.ts
4789
+ /**
4790
+ * `loadmap fmt`: lays each definition out the one way (`format()` of @loadmap/dsl), in place. With `--check` it only
4791
+ * names the files that are not laid out so. `-` reads standard input and writes standard output. Resolves to 1 when a
4792
+ * file has a syntax error, cannot be read, or (with --check) needs formatting; 2 for a usage mistake.
4793
+ */
4794
+ async function fmt(argv, o = {}) {
4795
+ const log = o.log ?? console.log;
4796
+ let parsed;
4797
+ try {
4798
+ parsed = parse(argv);
4799
+ } catch (err) {
4800
+ log(err.message);
4801
+ return 2;
4802
+ }
4803
+ if (parsed.stdin) {
4804
+ const source = await (o.read ?? readStdin)();
4805
+ const r = format(source);
4806
+ if (!r.ok) for (const d of r.diagnostics) log(formatDiagnostic(d, source, "<stdin>"));
4807
+ else (o.write ?? ((s) => process.stdout.write(s)))(r.text);
4808
+ return r.ok ? 0 : 1;
4809
+ }
4810
+ let failed = false;
4811
+ let changed = 0;
4812
+ let skipped = 0;
4813
+ for (const file of parsed.files) {
4814
+ let source;
4815
+ try {
4816
+ source = await readFile(file, "utf8");
4817
+ } catch (err) {
4818
+ log(`${file}: cannot read it (${err.code ?? err.message})`);
4819
+ failed = true;
4820
+ skipped++;
4821
+ continue;
4822
+ }
4823
+ const r = format(source);
4824
+ if (!r.ok) {
4825
+ for (const d of r.diagnostics) log(formatDiagnostic(d, source, file));
4826
+ log(`${file}: left as it is, it has a syntax error`);
4827
+ failed = true;
4828
+ skipped++;
4829
+ } else if (r.text !== source) {
4830
+ changed++;
4831
+ if (parsed.check) {
4832
+ log(`${file}: not laid out by loadmap fmt`);
4833
+ failed = true;
4834
+ } else {
4835
+ await writeFile(file, r.text);
4836
+ log(`${file}: formatted`);
4837
+ }
4838
+ }
4839
+ }
4840
+ const n = parsed.files.length;
4841
+ const files = (k) => `${k} ${k === 1 ? "file" : "files"}`;
4842
+ const left = skipped ? `; ${skipped} left out (unreadable or a syntax error)` : "";
4843
+ let summary = `${changed} of ${files(n)} formatted`;
4844
+ if (parsed.check && changed) summary = `${changed} of ${files(n)} need loadmap fmt`;
4845
+ else if (parsed.check) summary = skipped ? `${n - skipped} of ${files(n)} laid out already` : `${files(n)} laid out already`;
4846
+ log(summary + left);
4847
+ return failed ? 1 : 0;
4848
+ }
4849
+ function parse(argv) {
4850
+ const { values, positionals } = parseArgs({
4851
+ args: argv,
4852
+ allowPositionals: true,
4853
+ options: { check: { type: "boolean" } }
4854
+ });
4855
+ if (!positionals.length) throw new Error("loadmap fmt needs one or more .loadmap files, or - for standard input");
4856
+ const stdin = positionals.includes("-");
4857
+ if (stdin && (positionals.length > 1 || values.check)) throw new Error("loadmap fmt - reads standard input alone, without files or --check");
4858
+ return {
4859
+ files: positionals,
4860
+ check: !!values.check,
4861
+ stdin
4862
+ };
4863
+ }
4864
+ async function readStdin() {
4865
+ const chunks = [];
4866
+ for await (const c of process.stdin) chunks.push(c);
4867
+ return Buffer.concat(chunks).toString("utf8");
4868
+ }
4543
4869
  // src/index.ts
4544
4870
  const USAGE = `loadmap check <file.loadmap>… [--format text|github]
4871
+ loadmap fmt <file.loadmap>… [--check] | loadmap fmt -
4545
4872
  loadmap record <url | file.loadmap> --out <file.gif|.webm|.mp4|.png|.svg> [options]
4546
4873
 
4547
4874
  check compiles each definition and tests its expect lines on the pessimistic edge (every capacity at its low
@@ -4549,6 +4876,12 @@ bound). It exits 1 when a definition has an error or an expectation fails, 0 whe
4549
4876
 
4550
4877
  --format <f> text, or github to add annotations on the lines (the default under GitHub Actions)
4551
4878
 
4879
+ fmt lays each definition out the one way, in place: two spaces a block, a statement a line, one space around
4880
+ operators; numbers, texts and formulas stay as written. A file with a syntax error is left alone (exit 1).
4881
+
4882
+ --check change nothing; exit 1 when a file is not laid out so (for CI)
4883
+ - read standard input, write standard output
4884
+
4552
4885
  record opens the page in headless Chromium, finds the loadmap it exposes (expose() from @loadmap/export) and
4553
4886
  records it on a fixed clock, so the same options give the same file. A .loadmap file is drawn by the viewer that
4554
4887
  comes with the command.
@@ -4572,6 +4905,7 @@ comes with the command.
4572
4905
  async function main(argv, log = console.log) {
4573
4906
  const [command, ...rest] = argv;
4574
4907
  if (command === "check") return check(rest, { log });
4908
+ if (command === "fmt") return fmt(rest, { log });
4575
4909
  if (command !== "record") {
4576
4910
  log(USAGE);
4577
4911
  return command === "--help" || command === "-h" ? 0 : 2;
@@ -4775,4 +5109,4 @@ async function recordPage(a, format) {
4775
5109
  await browser.close();
4776
5110
  }
4777
5111
  }
4778
- export { check as a, record as i, main as n, github as o, parseRecord as r, USAGE as t };
5112
+ export { fmt as a, record as i, main as n, check as o, parseRecord as r, github as s, USAGE as t };