nomen-lang 0.10.0 → 0.11.0

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.
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Compile-time-checked rendering helpers behind interpolation format specs
3
+ * (`\{expr:spec}` — SPEC, String Interpolation). The checker validates each
4
+ * spec against the argument's type and lowers the argument into one of these
5
+ * calls, so the interpolation helper keeps receiving an ordinary owned
6
+ * string. Every function is also usable directly.
7
+ *
8
+ * Widths are signed: positive right-aligns, negative left-aligns, 0 means
9
+ * "no padding". `zero` pads with '0' between the sign and the digits (the
10
+ * printf `0` flag); alignment and zero-padding never combine (`<`/`>` and
11
+ * `0` are alternative fills in the spec grammar).
12
+ **/
13
+ pub struct Format {
14
+ // Render a float with a fixed number of decimal places. `spec` is the
15
+ // full printf directive the checker built from the format spec (e.g.
16
+ // "%.2f", "%08.2f", "%-10.3f") — the body formats nothing itself, it
17
+ // only hands the directive to snprintf (twice: size, then render, so
18
+ // any width is safe).
19
+ pub func float_fixed = (float value, string spec, mv out string) {
20
+ ```
21
+ #arch: c
22
+ int need = snprintf(NULL, 0, spec.ptr, value);
23
+ char* str = (char*)malloc((size_t)need + 1);
24
+ snprintf(str, (size_t)need + 1, spec.ptr, value);
25
+ str[need] = 0;
26
+ return (nomen_string){ str, need };
27
+ ```
28
+ ```
29
+ #arch: aarch64
30
+ // x0 = value (double bits — GP param), x1/x2 = the spec's (ptr, len)
31
+ // pair. The spec IS the directive. The variadic double rides the
32
+ // stack (Darwin AAPCS64 varargs); [sp] is re-armed before each call.
33
+ stp x19, x20, [sp, #-16]!
34
+ stp x21, x22, [sp, #-16]!
35
+ mov x19, x0 // value bits
36
+ mov x20, x1 // spec ptr
37
+ sub sp, sp, #16
38
+ fmov d0, x19
39
+ str d0, [sp] // variadic double slot
40
+ mov x0, #0 // NULL — sizing pass
41
+ mov x1, #0
42
+ mov x2, x20
43
+ bl _snprintf
44
+ mov x21, x0 // need
45
+ add x0, x21, #1
46
+ bl _malloc
47
+ mov x22, x0 // str
48
+ fmov d0, x19
49
+ str d0, [sp] // re-arm the slot
50
+ mov x0, x22
51
+ add x1, x21, #1
52
+ mov x2, x20
53
+ bl _snprintf // writes need bytes + NUL at str[need]
54
+ mov x0, x22
55
+ mov x1, x21
56
+ add sp, sp, #16
57
+ ldp x21, x22, [sp], #16
58
+ ldp x19, x20, [sp], #16
59
+ ```
60
+ }
61
+
62
+ // Grouped fixed-point rendering — the `\{value:.2n}` / `\{value:n}` spec
63
+ // (C#'s "N" standard format): render with %.Nf (2 decimals when the spec
64
+ // omits a precision, the C# default), then comma the integer part every
65
+ // three digits. Width and alignment compose at the call site through
66
+ // `Format.pad`; zero-padding and grouping never combine (checked).
67
+ pub func float_grouped = (float value, int places: places >= 0, mv out string) {
68
+ var sb = StringBuilder()
69
+ sb.append_char('%')
70
+ sb.append_char('.')
71
+ sb.append_int(places)
72
+ sb.append_char('f')
73
+ var string rendered = Format.float_fixed(value, sb.to_string())
74
+ return Format.group_int_part(rendered)
75
+ }
76
+
77
+ // Insert ',' every three digits of the integer part of an already
78
+ // rendered number ("1234.50" → "1,234.50", "-987654" → "-987,654").
79
+ // A sign and any fractional part pass through untouched.
80
+ internal func group_int_part = (string text, mv out string) { var int len = text.length
81
+ var int dot = len
82
+ var int i = 0
83
+ while i < len; i += 1 {
84
+ if text.at(i) == '.' {
85
+ dot = i
86
+ }
87
+ }
88
+ var int digits = 0
89
+ i = 0
90
+ while i < dot; i += 1 {
91
+ var char c = text.at(i)
92
+ if c != '-' && c != '+' {
93
+ digits += 1
94
+ }
95
+ }
96
+ var sb = StringBuilder()
97
+ var int seen = 0
98
+ i = 0
99
+ while i < len; i += 1 {
100
+ var char c = text.at(i)
101
+ sb.append_char(c)
102
+ if i < dot && c != '-' && c != '+' {
103
+ seen += 1
104
+ var int remaining = digits - seen
105
+ if remaining > 0 && remaining % 3 == 0 {
106
+ sb.append_char(',')
107
+ }
108
+ }
109
+ }
110
+ return sb.to_string()
111
+ }
112
+
113
+ // Render a signed integer in base 2/8/10/16. A negative value renders
114
+ // its magnitude with a leading '-' in every base (not two's complement).
115
+ // `group` inserts thousands separators every three digits — intended for
116
+ // base 10 (the `\{value:n}` spec); a grouped other base is the caller's
117
+ // choice and commas every three digits regardless.
118
+ pub func int_base = (int64 value, int base: base >= 2 && base <= 16, bool upper, int width, bool zero, bool group, mv out string) {
119
+ const string digits = "0123456789abcdef"
120
+ const string upper_digits = "0123456789ABCDEF"
121
+ var int w = width
122
+ var bool left = false
123
+ if w < 0 {
124
+ left = true
125
+ w = 0 - w
126
+ }
127
+ var bool neg = value < 0
128
+ var uint64 mag = value as uint64
129
+ if neg {
130
+ // Two's-complement negate on the UNSIGNED magnitude (wraps
131
+ // correctly for INT64_MIN — no signed-overflow UB).
132
+ var uint64 zero = 0
133
+ mag = zero - mag
134
+ }
135
+ var uint64 b = base as uint64
136
+ // Digits arrive least-significant first; the emit walks the scratch
137
+ // backwards. 72 slots covers binary's 64 digits plus the sign.
138
+ var Array<char> chars = Array<char>.with('0', 72)
139
+ var int count = 0
140
+ if mag == 0 {
141
+ chars.set(0, '0')
142
+ count = 1
143
+ } else {
144
+ while mag > 0 {
145
+ var uint64 d = mag % b
146
+ mag = mag / b
147
+ var int di = d as int
148
+ if di < 10 {
149
+ chars.set(count, digits.at(di))
150
+ }
151
+ if di >= 10 && upper {
152
+ chars.set(count, upper_digits.at(di))
153
+ }
154
+ if di >= 10 && !upper {
155
+ chars.set(count, digits.at(di))
156
+ }
157
+ count += 1
158
+ }
159
+ }
160
+ var int total = count
161
+ if neg {
162
+ total += 1
163
+ }
164
+ var int padding = 0
165
+ if w > total {
166
+ padding = w - total
167
+ }
168
+ var sb = StringBuilder()
169
+ var int i = 0
170
+ if left {
171
+ // sign, digits, spaces
172
+ if neg {
173
+ sb.append_char('-')
174
+ }
175
+ while i < count; i += 1 {
176
+ sb.append_char(chars.at(count - 1 - i))
177
+ if group && base == 10 && count - 1 - i > 0 && (count - 1 - i) % 3 == 0 {
178
+ sb.append_char(',')
179
+ }
180
+ }
181
+ i = 0
182
+ while i < padding; i += 1 {
183
+ sb.append_char(' ')
184
+ }
185
+ } else {
186
+ if zero {
187
+ // sign, zeros, digits (printf's `0` flag)
188
+ if neg {
189
+ sb.append_char('-')
190
+ }
191
+ while i < padding; i += 1 {
192
+ sb.append_char('0')
193
+ }
194
+ } else {
195
+ // spaces, sign, digits
196
+ while i < padding; i += 1 {
197
+ sb.append_char(' ')
198
+ }
199
+ if neg {
200
+ sb.append_char('-')
201
+ }
202
+ }
203
+ i = 0
204
+ while i < count; i += 1 {
205
+ sb.append_char(chars.at(count - 1 - i))
206
+ if group && base == 10 && count - 1 - i > 0 && (count - 1 - i) % 3 == 0 {
207
+ sb.append_char(',')
208
+ }
209
+ }
210
+ }
211
+ return sb.to_string()
212
+ }
213
+
214
+ // Unsigned twin of int_base — the value argument keeps the uint64 family
215
+ // (uint8..uint64) so no value is reinterpreted through a signed width.
216
+ pub func uint_base = (uint64 value, int base: base >= 2 && base <= 16, bool upper, int width, bool zero, bool group, mv out string) {
217
+ const string digits = "0123456789abcdef"
218
+ const string upper_digits = "0123456789ABCDEF"
219
+ var int w = width
220
+ var bool left = false
221
+ if w < 0 {
222
+ left = true
223
+ w = 0 - w
224
+ }
225
+ var uint64 mag = value
226
+ var uint64 b = base as uint64
227
+ var Array<char> chars = Array<char>.with('0', 72)
228
+ var int count = 0
229
+ if mag == 0 {
230
+ chars.set(0, '0')
231
+ count = 1
232
+ } else {
233
+ while mag > 0 {
234
+ var uint64 d = mag % b
235
+ mag = mag / b
236
+ var int di = d as int
237
+ if di < 10 {
238
+ chars.set(count, digits.at(di))
239
+ }
240
+ if di >= 10 && upper {
241
+ chars.set(count, upper_digits.at(di))
242
+ }
243
+ if di >= 10 && !upper {
244
+ chars.set(count, digits.at(di))
245
+ }
246
+ count += 1
247
+ }
248
+ }
249
+ var int padding = 0
250
+ if w > count {
251
+ padding = w - count
252
+ }
253
+ var sb = StringBuilder()
254
+ var int i = 0
255
+ if left {
256
+ while i < count; i += 1 {
257
+ sb.append_char(chars.at(count - 1 - i))
258
+ if group && base == 10 && count - 1 - i > 0 && (count - 1 - i) % 3 == 0 {
259
+ sb.append_char(',')
260
+ }
261
+ }
262
+ i = 0
263
+ while i < padding; i += 1 {
264
+ sb.append_char(' ')
265
+ }
266
+ } else {
267
+ while i < padding; i += 1 {
268
+ if zero {
269
+ sb.append_char('0')
270
+ } else {
271
+ sb.append_char(' ')
272
+ }
273
+ }
274
+ i = 0
275
+ while i < count; i += 1 {
276
+ sb.append_char(chars.at(count - 1 - i))
277
+ if group && base == 10 && count - 1 - i > 0 && (count - 1 - i) % 3 == 0 {
278
+ sb.append_char(',')
279
+ }
280
+ }
281
+ }
282
+ return sb.to_string()
283
+ }
284
+
285
+ // Align an already-rendered text in a field of `|width|` spaces. The
286
+ // text is borrowed; the result is always a fresh owned string.
287
+ pub func pad = (string text, int width, mv out string) {
288
+ var int w = width
289
+ var bool left = false
290
+ if w < 0 {
291
+ left = true
292
+ w = 0 - w
293
+ }
294
+ if w <= text.length {
295
+ return text.copy()
296
+ }
297
+ var int padding = w - text.length
298
+ var sb = StringBuilder()
299
+ if left {
300
+ // text, then spaces
301
+ sb.append(text)
302
+ var int i = 0
303
+ while i < padding; i += 1 {
304
+ sb.append_char(' ')
305
+ }
306
+ } else {
307
+ // spaces, then text
308
+ var int i = 0
309
+ while i < padding; i += 1 {
310
+ sb.append_char(' ')
311
+ }
312
+ sb.append(text)
313
+ }
314
+ return sb.to_string()
315
+ }
316
+
317
+ // --- Custom patterns (the `\{value:#,##0.00}` mini-language) ------------
318
+ //
319
+ // Subset, validated at compile time (SPEC, String Interpolation):
320
+ // 0 digit slot, always renders (zero-pads)
321
+ // # digit slot, renders only when a digit exists
322
+ // . decimal point; trailing '#' fraction slots trim zeros
323
+ // , group separator between digit slots; the rightmost comma
324
+ // sets the group size for all groups
325
+ // % value scaled x100; '%' emitted at its position
326
+ // '...' quoted literal; any other character passes through
327
+ // ; at most two sections (positive;negative) — the negative
328
+ // section renders the absolute value, sign and all
329
+ // Not in the language: exponential patterns (use the `:e` spec), the
330
+ // per-mille sign, trailing-comma scaling, three-section forms.
331
+ //
332
+ // The scanners below share one shape: snapshot `was_quoted`, toggle the
333
+ // quote state on a quote character, then run each structural check as
334
+ // its own top-level `if` gated on "this character is structural" —
335
+ // Nomen has no `else if`.
336
+
337
+ // The section of `pattern` that applies to `negative` values.
338
+ internal func pat_section = (string pattern, bool negative, mv out string) {
339
+ var int split = -1
340
+ var bool quoted = false
341
+ var int i = 0
342
+ while i < pattern.length; i += 1 {
343
+ var char c = pattern.at(i)
344
+ var bool was_quoted = quoted
345
+ if c == '\'' {
346
+ if was_quoted {
347
+ quoted = false
348
+ }
349
+ if !was_quoted {
350
+ quoted = true
351
+ }
352
+ }
353
+ if !was_quoted && !quoted && c == ';' {
354
+ split = i
355
+ }
356
+ }
357
+ var sb = StringBuilder()
358
+ if split == -1 {
359
+ return pattern.copy()
360
+ }
361
+ var int from = 0
362
+ if negative {
363
+ from = split + 1
364
+ }
365
+ i = from
366
+ while i < pattern.length; i += 1 {
367
+ sb.append_char(pattern.at(i))
368
+ }
369
+ return sb.to_string()
370
+ }
371
+
372
+ // True when the pattern carries two ';'-separated sections.
373
+ internal func pat_has2 = (string pattern, out bool) {
374
+ var bool quoted = false
375
+ var bool found = false
376
+ var int i = 0
377
+ while i < pattern.length; i += 1 {
378
+ var char c = pattern.at(i)
379
+ var bool was_quoted = quoted
380
+ if c == '\'' {
381
+ if was_quoted {
382
+ quoted = false
383
+ }
384
+ if !was_quoted {
385
+ quoted = true
386
+ }
387
+ }
388
+ if !was_quoted && !quoted && c == ';' {
389
+ found = true
390
+ }
391
+ }
392
+ return found
393
+ }
394
+
395
+ // True when the pattern carries a '%' anywhere (unquoted).
396
+ internal func pat_has_percent = (string pattern, out bool) {
397
+ var bool quoted = false
398
+ var bool found = false
399
+ var int i = 0
400
+ while i < pattern.length; i += 1 {
401
+ var char c = pattern.at(i)
402
+ var bool was_quoted = quoted
403
+ if c == '\'' {
404
+ if was_quoted {
405
+ quoted = false
406
+ }
407
+ if !was_quoted {
408
+ quoted = true
409
+ }
410
+ }
411
+ if !was_quoted && !quoted && c == '%' {
412
+ found = true
413
+ }
414
+ }
415
+ return found
416
+ }
417
+
418
+ // The widest fraction-slot count across both sections — the snprintf
419
+ // pass renders at least this many digits; shaping trims to the applied
420
+ // section's own slots.
421
+ internal func pat_max_frac = (string pattern, out int) {
422
+ var bool quoted = false
423
+ var bool in_frac = false
424
+ var int count = 0
425
+ var int best = 0
426
+ var int i = 0
427
+ while i < pattern.length; i += 1 {
428
+ var char c = pattern.at(i)
429
+ var bool was_quoted = quoted
430
+ if c == '\'' {
431
+ if was_quoted {
432
+ quoted = false
433
+ }
434
+ if !was_quoted {
435
+ quoted = true
436
+ }
437
+ }
438
+ var bool structural = !was_quoted && !quoted
439
+ if structural && c == '.' {
440
+ in_frac = true
441
+ }
442
+ if structural && (c == '0' || c == '#') {
443
+ if in_frac {
444
+ count += 1
445
+ if count > best {
446
+ best = count
447
+ }
448
+ }
449
+ }
450
+ if structural && c == ';' {
451
+ in_frac = false
452
+ count = 0
453
+ }
454
+ if structural && c != '.' && c != '0' && c != '#' {
455
+ // any other literal ends the fraction run
456
+ in_frac = false
457
+ count = 0
458
+ }
459
+ }
460
+ return best
461
+ }
462
+
463
+ // Decimal digits of a magnitude, most-significant first, no sign.
464
+ internal func pat_digits10 = (uint64 mag, mv out string) {
465
+ var sb = StringBuilder()
466
+ if mag == 0 {
467
+ sb.append_char('0')
468
+ return sb.to_string()
469
+ }
470
+ var Array<char> chars = Array<char>.with('0', 20)
471
+ var int count = 0
472
+ var uint64 m = mag
473
+ var string dig = "0123456789"
474
+ while m > 0 {
475
+ var uint64 d = m % 10
476
+ m = m / 10
477
+ chars.set(count, dig.at(d as int))
478
+ count += 1
479
+ }
480
+ var int j = count - 1
481
+ while j >= 0; j -= 1 {
482
+ sb.append_char(chars.at(j))
483
+ }
484
+ return sb.to_string()
485
+ }
486
+
487
+ // Shape digit strings against one pattern section. `int_digits` is the
488
+ // unsigned integer part; `frac_raw` the rendered fraction digits at the
489
+ // section's full precision ("" for integers — zeros fill up to the
490
+ // section's minimum). Emits prefix literals, the grouped integer part,
491
+ // the trimmed fraction, and the suffix. Every helper call result lands
492
+ // in a single-binding local: no owned string is ever reassigned (the
493
+ // displaced-free pass does not cover every conditional reassignment
494
+ // shape — FOLLOWUP.md).
495
+ internal func pat_render = (string section, string int_digits, string frac_raw, bool negative, mv out string) {
496
+ // Pass 1 — locate the regions: prefix (before the first integer
497
+ // slot), the integer slot run, the fraction slots, and the suffix
498
+ // (after the last slot).
499
+ var bool quoted = false
500
+ var int first_slot = -1
501
+ var int last_slot = -1
502
+ var int dot = -1
503
+ var int pad_slots = 0
504
+ var int frac_slots = 0
505
+ var int frac_min = 0
506
+ var bool has_comma = false
507
+ var int slots_after_comma = 0
508
+ var int i = 0
509
+ while i < section.length; i += 1 {
510
+ var char c = section.at(i)
511
+ var bool was_quoted = quoted
512
+ if c == '\'' {
513
+ if was_quoted {
514
+ quoted = false
515
+ }
516
+ if !was_quoted {
517
+ quoted = true
518
+ }
519
+ }
520
+ var bool structural = !was_quoted && !quoted
521
+ if structural && c == '.' {
522
+ if dot == -1 {
523
+ dot = i
524
+ }
525
+ }
526
+ if structural && (c == '0' || c == '#') {
527
+ last_slot = i
528
+ if dot == -1 {
529
+ // an integer slot — never counted before the dot
530
+ if first_slot == -1 {
531
+ first_slot = i
532
+ }
533
+ slots_after_comma += 1
534
+ if c == '0' {
535
+ // only '0' slots force zero-padding ('#' drops out)
536
+ pad_slots += 1
537
+ }
538
+ } else {
539
+ frac_slots += 1
540
+ if c == '0' {
541
+ frac_min = frac_slots
542
+ }
543
+ }
544
+ }
545
+ if structural && c == ',' && dot == -1 {
546
+ has_comma = true
547
+ slots_after_comma = 0
548
+ }
549
+ }
550
+ // Pass 2 — emit.
551
+ var sb = StringBuilder()
552
+ if negative {
553
+ sb.append_char('-')
554
+ }
555
+ // prefix: [0, first_slot), quotes stripped
556
+ i = 0
557
+ quoted = false
558
+ while i < first_slot; i += 1 {
559
+ var char c = section.at(i)
560
+ var bool was_quoted = quoted
561
+ if c == '\'' {
562
+ if was_quoted {
563
+ quoted = false
564
+ }
565
+ if !was_quoted {
566
+ quoted = true
567
+ }
568
+ }
569
+ if was_quoted && c != '\'' {
570
+ sb.append_char(c)
571
+ }
572
+ if !was_quoted && c != '\'' {
573
+ sb.append_char(c)
574
+ }
575
+ }
576
+ // integer part: zero-pad to the '0' slots, group per the rightmost
577
+ // comma's run, drop a lone zero when every slot is '#' (the shape
578
+ // helper's own empty case). The shape result lands in a local — an
579
+ // inline call argument to append_string would never be anchored.
580
+ sb.append_string(Format.pat_shape_int(int_digits, pad_slots, has_comma, slots_after_comma))
581
+ // fraction: trim trailing zeros down to the section minimum, then
582
+ // zero-fill up to it (integer values arrive with no fraction at all).
583
+ var string kept = Format.pat_shape_frac(frac_raw, frac_slots, frac_min)
584
+ if kept.length > 0 {
585
+ sb.append_char('.')
586
+ sb.append(kept)
587
+ }
588
+ // suffix: (last_slot, end), quotes stripped, structural dot dropped
589
+ i = last_slot + 1
590
+ quoted = false
591
+ while i < section.length; i += 1 {
592
+ var char c = section.at(i)
593
+ var bool was_quoted = quoted
594
+ if c == '\'' {
595
+ if was_quoted {
596
+ quoted = false
597
+ }
598
+ if !was_quoted {
599
+ quoted = true
600
+ }
601
+ }
602
+ if was_quoted && c != '\'' {
603
+ sb.append_char(c)
604
+ }
605
+ if !was_quoted && c != '\'' && i != dot {
606
+ sb.append_char(c)
607
+ }
608
+ }
609
+ return sb.to_string()
610
+ }
611
+
612
+ // The integer part of a pattern: zero-pad to the '0' slots, then insert
613
+ // group separators every `group_size` digits. A value of "0" with no '0'
614
+ // slots renders empty (the `#`-only shapes).
615
+ internal func pat_shape_int = (string int_digits, int pad_slots, bool has_comma, int group_size, mv out string) {
616
+ var sb = StringBuilder()
617
+ if int_digits == "0" && pad_slots == 0 {
618
+ return sb.to_string()
619
+ }
620
+ var int total = int_digits.length
621
+ if total < pad_slots {
622
+ total = pad_slots
623
+ }
624
+ var int pad_count = total - int_digits.length
625
+ var int k = 0
626
+ while k < total; k += 1 {
627
+ if k < pad_count {
628
+ sb.append_char('0')
629
+ } else {
630
+ sb.append_char(int_digits.at(k - pad_count))
631
+ }
632
+ var int remaining = total - k - 1
633
+ if has_comma && group_size > 0 && remaining > 0 && remaining % group_size == 0 {
634
+ sb.append_char(',')
635
+ }
636
+ }
637
+ return sb.to_string()
638
+ }
639
+
640
+ // The fraction part of a pattern: clip to the slot count, trim trailing
641
+ // zeros down to the minimum ('0' slots), then zero-fill back up to it.
642
+ internal func pat_shape_frac = (string frac_raw, int frac_slots, int frac_min, mv out string) {
643
+ var sb = StringBuilder()
644
+ var int n = frac_raw.length
645
+ if n > frac_slots {
646
+ n = frac_slots
647
+ }
648
+ var bool trimming = true
649
+ while trimming && n > frac_min {
650
+ var char last = frac_raw.at(n - 1)
651
+ if last == '0' {
652
+ n -= 1
653
+ }
654
+ if last != '0' {
655
+ trimming = false
656
+ }
657
+ }
658
+ var int i = 0
659
+ while i < n; i += 1 {
660
+ sb.append_char(frac_raw.at(i))
661
+ }
662
+ while n < frac_min; n += 1 {
663
+ sb.append_char('0')
664
+ }
665
+ return sb.to_string()
666
+ }
667
+
668
+ // Pick the sign's section, render, and prepend '-' when a negative value
669
+ // has no dedicated section.
670
+ internal func pat_render_all = (string pattern, bool negative, string int_digits, string frac_raw, mv out string) {
671
+ var string section = Format.pat_section(pattern, negative)
672
+ var bool has2 = Format.pat_has2(pattern)
673
+ return Format.pat_render(section, int_digits, frac_raw, negative && !has2)
674
+ }
675
+
676
+ // `#,##0`-style rendering of a signed integer.
677
+ pub func int_pattern = (int64 value, string pattern, mv out string) {
678
+ var bool neg = value < 0
679
+ var uint64 mag = value as uint64
680
+ if neg {
681
+ var uint64 zero = 0
682
+ mag = zero - mag
683
+ }
684
+ if Format.pat_has_percent(pattern) {
685
+ mag = mag * 100
686
+ }
687
+ // The digit string stays a named local on purpose: a plain `string`
688
+ // parameter is a borrow, so this local must free at scope exit, and
689
+ // the per-function ownership sets must isolate it from the
690
+ // same-named `const string digits` locals in int_base/uint_base
691
+ // (name-keyed marks bleeding across library methods suppressed this
692
+ // free — FOLLOWUP receipt; displaced_free.test.ts pins the class).
693
+ var string digits = Format.pat_digits10(mag)
694
+ var string rendered = Format.pat_render_all(pattern, neg, digits, "")
695
+ return rendered
696
+ }
697
+
698
+ // Unsigned twin of int_pattern — no sign, no reinterpretation.
699
+ pub func uint_pattern = (uint64 value, string pattern, mv out string) {
700
+ var uint64 mag = value
701
+ if Format.pat_has_percent(pattern) {
702
+ mag = mag * 100
703
+ }
704
+ return Format.pat_render_all(pattern, false, Format.pat_digits10(mag), "")
705
+ }
706
+
707
+ // Fixed-point pattern rendering of a float: snprintf renders the
708
+ // fraction at the pattern's widest precision, then the shaping pass
709
+ // trims to the applied section's slots.
710
+ pub func float_pattern = (float value, string pattern, mv out string) {
711
+ var bool neg = value < 0
712
+ var float v = value
713
+ if neg {
714
+ var float zero = 0
715
+ v = zero - v
716
+ }
717
+ if Format.pat_has_percent(pattern) {
718
+ v = v * (100 as float)
719
+ }
720
+ var int max_frac = Format.pat_max_frac(pattern)
721
+ var spec_sb = StringBuilder()
722
+ spec_sb.append_char('%')
723
+ spec_sb.append_char('.')
724
+ spec_sb.append_int(max_frac)
725
+ spec_sb.append_char('f')
726
+ var string rendered = Format.float_fixed(v, spec_sb.to_string())
727
+ // rendered = digits '.' frac — split them
728
+ var int dot = rendered.length
729
+ var int i = 0
730
+ while i < rendered.length; i += 1 {
731
+ if rendered.at(i) == '.' {
732
+ dot = i
733
+ }
734
+ }
735
+ var int_sb = StringBuilder()
736
+ var frac_sb = StringBuilder()
737
+ var int s = 0
738
+ while s < rendered.length; s += 1 {
739
+ if s < dot {
740
+ int_sb.append_char(rendered.at(s))
741
+ }
742
+ if s > dot {
743
+ frac_sb.append_char(rendered.at(s))
744
+ }
745
+ }
746
+ // Both digit strings pass as call results (not locals at their last
747
+ // use) so the boundary keeps them borrows the call site anchors.
748
+ return Format.pat_render_all(pattern, neg, int_sb.to_string(), frac_sb.to_string())
749
+ }
750
+
751
+ }
package/dist/index.mjs CHANGED
@@ -7257,6 +7257,15 @@ var FunctionCallNode = class extends BaseNode {
7257
7257
  */
7258
7258
  in_place_payload_indices;
7259
7259
  /**
7260
+ * Per-argument format specs for a `_string_interpolate_N` call
7261
+ * (`\{expr:spec}`): entry `i - 1` corresponds to param `i` (index 0 is
7262
+ * the pattern literal). Null entry = no spec for that argument. Set by
7263
+ * the parser; the checker validates each spec against the argument's
7264
+ * type and rewrites the argument into a `Format.*` rendering call (see
7265
+ * check/utils/format_spec_arg.ts).
7266
+ */
7267
+ format_specs;
7268
+ /**
7260
7269
  * The checker-stamped mangled label for a call resolved to an overloaded
7261
7270
  * callee (`Struct_method_int`). For struct constructor calls resolved to
7262
7271
  * an overloaded `#init`, the backends emit/bl the call through this label
@@ -15835,7 +15844,11 @@ function is_fresh_heap_string(node) {
15835
15844
  if (!node) return false;
15836
15845
  if (node.node_type === "access") {
15837
15846
  const access = node.access;
15838
- if (access.node_type === "access_func" && access.name === "to_string") return true;
15847
+ if (access.node_type === "access_func") {
15848
+ const fn = access;
15849
+ if (fn.name === "to_string") return true;
15850
+ if (fn.owned_return) return true;
15851
+ }
15839
15852
  }
15840
15853
  if (node.type?.name !== "string") return false;
15841
15854
  if (node.node_type === "op") return true;
@@ -15843,8 +15856,7 @@ function is_fresh_heap_string(node) {
15843
15856
  if (node.node_type === "access") {
15844
15857
  const access = node.access;
15845
15858
  if (access.node_type !== "access_func") return false;
15846
- const fn = access;
15847
- return !(!fn.owned_return && is_call_site_borrow_accessor(fn.name));
15859
+ return !is_call_site_borrow_accessor(access.name);
15848
15860
  }
15849
15861
  return false;
15850
15862
  }
@@ -36405,6 +36417,7 @@ function clone_node(node) {
36405
36417
  c.swap_params = n.swap_params ? new Map([...n.swap_params].map(([k, v]) => [k, clone_node(v)])) : void 0;
36406
36418
  c.variadic_param_name = n.variadic_param_name;
36407
36419
  c.variadic_param_index = n.variadic_param_index;
36420
+ c.format_specs = n.format_specs?.slice();
36408
36421
  c.is_enum_shorthand = n.is_enum_shorthand;
36409
36422
  c.is_thread_ctor = n.is_thread_ctor;
36410
36423
  c.is_fiber_ctor = n.is_fiber_ctor;
@@ -47524,6 +47537,116 @@ function promote_allocations(node, status) {
47524
47537
  }
47525
47538
  }
47526
47539
  //#endregion
47540
+ //#region ../src/check/utils/format_spec_arg.ts
47541
+ /**
47542
+ * Compile-time-checked format specs (`\{expr:spec}` — SPEC, String
47543
+ * Interpolation). The parse layer splits the spec text off the interpolation
47544
+ * argument and attaches it to the call (`FunctionCallNode.format_specs`);
47545
+ * this module validates the spec against the argument's TYPE — the
47546
+ * compile-time half of the feature — and lowers the argument into a call of
47547
+ * the matching System `Format` function, so the interpolation helper keeps
47548
+ * receiving an ordinary owned string.
47549
+ *
47550
+ * \{f:.2} / \{f:08.2} / \{f:<10.3} → Format.float_fixed(v, "<directive>")
47551
+ * The checker builds the full printf directive (it knows the spec is
47552
+ * constant), so the raw body is one snprintf with no flag logic.
47553
+ * \{i:03} / \{i:x} / \{u:08X} / \{i:b} → Format.int_base / Format.uint_base
47554
+ * Signed arguments widen to int64, unsigned to uint64 (both accepted
47555
+ * families under the implicit-int coercion ranking), then the library's
47556
+ * digit loop renders base/width/zero/alignment.
47557
+ * \{s:>8} / anything with only width/align → Format.pad(rendered, width)
47558
+ * The argument keeps its `.to_string()` wrap; pad aligns the text.
47559
+ *
47560
+ * Type mismatches are REJECTED here (precision on a non-float, base on a
47561
+ * non-integer, zero-padding on a non-numeric) with the spec text in the
47562
+ * message — an invalid `\{expr:spec}` never reaches codegen.
47563
+ */
47564
+ /** Lower one spec'd interpolation argument. Returns the replacement node, or
47565
+ * undefined when the spec was rejected (error already reported) or the
47566
+ * argument shape is unexpected (keep the plain wrap). */
47567
+ function check_format_spec_arg(param, spec, status) {
47568
+ const wrap = param.node_type === "access" && param.access.node_type === "access_func" && param.access.name === "to_string" ? param : void 0;
47569
+ const target = wrap ? param.target : param;
47570
+ const t = type_from_value_node(target, status);
47571
+ const info = !t.is_array && !t.is_view && !t.is_ref && !t.is_pointer ? get_built_in_type(t.name) : void 0;
47572
+ const kind = info?.kind;
47573
+ const is_float = kind === "float";
47574
+ const is_int = kind === "int";
47575
+ if (spec.precision !== void 0 && !is_float) {
47576
+ add_error(status, `format spec ':${spec.text}': precision requires a float, but the value is ${t.name || "value"}`, spec.start);
47577
+ return;
47578
+ }
47579
+ if (spec.base && "xXbo".includes(spec.base) && !is_int) {
47580
+ add_error(status, `format spec ':${spec.text}': base requires an integer, but the value is ${t.name || "value"}`, spec.start);
47581
+ return;
47582
+ }
47583
+ if (spec.base && "nN".includes(spec.base) && !is_int && !is_float) {
47584
+ add_error(status, `format spec ':${spec.text}': grouping requires an integer or float, but the value is ${t.name || "value"}`, spec.start);
47585
+ return;
47586
+ }
47587
+ if (spec.pattern !== void 0 && !is_int && !is_float) {
47588
+ add_error(status, `format spec ':${spec.text}': pattern requires an integer or float, but the value is ${t.name || "value"}`, spec.start);
47589
+ return;
47590
+ }
47591
+ if (spec.base && "eE".includes(spec.base) && !is_float) {
47592
+ add_error(status, `format spec ':${spec.text}': exponential requires a float, but the value is ${t.name || "value"}`, spec.start);
47593
+ return;
47594
+ }
47595
+ if (spec.zero && !is_float && !is_int) {
47596
+ add_error(status, `format spec ':${spec.text}': zero-padding requires an integer or float, but the value is ${t.name || "value"}`, spec.start);
47597
+ return;
47598
+ }
47599
+ const signed_width = (spec.align === "left" ? -1 : 1) * (spec.width ?? 0);
47600
+ const is_exponential = spec.base === "e" || spec.base === "E";
47601
+ const is_grouped = spec.base === "n" || spec.base === "N";
47602
+ if (spec.pattern !== void 0) {
47603
+ const fname = is_float ? "float_pattern" : info?.signed ? "int_pattern" : "uint_pattern";
47604
+ const rendered = format_call(spec.start, fname, [new CastNode(target.start, target, new Type(is_float ? "float" : info?.signed ? "int64" : "uint64")), new ValueNode(target.start, `"${escape_pattern(spec.pattern)}"`, new Type("string", true))]);
47605
+ if (signed_width === 0) return rendered;
47606
+ return format_call(spec.start, "pad", [rendered, new ValueNode(target.start, String(signed_width), new Type("int", true))]);
47607
+ }
47608
+ if (is_grouped && is_float) {
47609
+ const places = spec.precision ?? 2;
47610
+ const grouped = format_call(spec.start, "float_grouped", [new CastNode(target.start, target, new Type("float")), new ValueNode(target.start, String(places), new Type("int", true))]);
47611
+ if (signed_width === 0) return grouped;
47612
+ return format_call(spec.start, "pad", [grouped, new ValueNode(target.start, String(signed_width), new Type("int", true))]);
47613
+ }
47614
+ if (is_float && (spec.precision !== void 0 || is_exponential)) {
47615
+ let directive = "%";
47616
+ if (spec.zero && signed_width > 0) directive += "0";
47617
+ if (spec.align === "left") directive += "-";
47618
+ if (signed_width !== 0) directive += String(signed_width);
47619
+ directive += `.${spec.precision ?? 6}`;
47620
+ directive += is_exponential ? spec.base : "f";
47621
+ return format_call(spec.start, "float_fixed", [new CastNode(target.start, target, new Type("float")), new ValueNode(target.start, `"${directive}"`, new Type("string", true))]);
47622
+ }
47623
+ if (is_int && (spec.base || spec.zero)) {
47624
+ const grouped = is_grouped;
47625
+ const base = spec.base === "x" || spec.base === "X" ? 16 : spec.base === "b" ? 2 : spec.base === "o" ? 8 : 10;
47626
+ const fname = info?.signed ? "int_base" : "uint_base";
47627
+ const render_width = grouped ? 0 : signed_width;
47628
+ const rendered = format_call(spec.start, fname, [
47629
+ new CastNode(target.start, target, new Type(info?.signed ? "int64" : "uint64")),
47630
+ new ValueNode(target.start, String(base), new Type("int", true)),
47631
+ new ValueNode(target.start, spec.base === "X" ? "true" : "false", new Type("bool", true)),
47632
+ new ValueNode(target.start, String(render_width), new Type("int", true)),
47633
+ new ValueNode(target.start, spec.zero && !grouped ? "true" : "false", new Type("bool", true)),
47634
+ new ValueNode(target.start, grouped ? "true" : "false", new Type("bool", true))
47635
+ ]);
47636
+ if (grouped && signed_width !== 0) return format_call(spec.start, "pad", [rendered, new ValueNode(target.start, String(signed_width), new Type("int", true))]);
47637
+ return rendered;
47638
+ }
47639
+ return format_call(spec.start, "pad", [wrap ?? param, new ValueNode(target.start, String(signed_width), new Type("int", true))]);
47640
+ }
47641
+ function format_call(start, fname, params) {
47642
+ return new AccessNode(start, new ValueNode(start, "Format"), new AccessFunctionCallNode(start, fname, void 0, params));
47643
+ }
47644
+ /** The pattern rides a Nomen string literal — escape the two characters
47645
+ * that would terminate or escape it. */
47646
+ function escape_pattern(pattern) {
47647
+ return pattern.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
47648
+ }
47649
+ //#endregion
47527
47650
  //#region ../src/check/utils/regex_pattern_lint.ts
47528
47651
  /**
47529
47652
  * Compile-time lint for Regex pattern literals (FOLLOWUP "Regex pattern
@@ -47949,6 +48072,15 @@ function check_function_call(node, status, func, target_type, self_value, self_p
47949
48072
  status.expected_type = old_expected_type;
47950
48073
  status.allow_null_value = old_allow_null;
47951
48074
  status.checking_variadic_arg = old_variadic_arg;
48075
+ const spec = i > 0 && is_interp_name(node.name) && node.node_type === "func_call" ? node.format_specs?.[i - 1] : void 0;
48076
+ if (spec) {
48077
+ const lowered = check_format_spec_arg(param, spec, status);
48078
+ if (lowered) {
48079
+ node.params[i] = lowered;
48080
+ param = lowered;
48081
+ check_node(lowered, status);
48082
+ }
48083
+ }
47952
48084
  if (i > 0 && is_interp_name(node.name) && parse_interp_name(node.name).kinds === null) {
47953
48085
  const rewrite = interp_arg_rewrite(param, status);
47954
48086
  if (rewrite.kind && rewrite.target) {
@@ -55356,6 +55488,12 @@ function build_struct_functions(node, status, skip_init = false) {
55356
55488
  status.ref_class_param_types = /* @__PURE__ */ new Map();
55357
55489
  status.scoped_declarations = enter_c_scope(status);
55358
55490
  status.c_borrow_only_strings = scan_borrow_only_strings(func);
55491
+ const old_string_borrow_vars = status.string_borrow_vars;
55492
+ status.string_borrow_vars = /* @__PURE__ */ new Set();
55493
+ const old_moved_string_vars = status.moved_string_vars;
55494
+ status.moved_string_vars = /* @__PURE__ */ new Set();
55495
+ const old_owned_string_vars = status.owned_string_vars;
55496
+ status.owned_string_vars = /* @__PURE__ */ new Set();
55359
55497
  status.force_heap_strings = scan_force_heap_strings(func.statements ?? [], status.structs);
55360
55498
  status.function_return_type = func.return_type;
55361
55499
  const old_nullable_ret_has = status.nullable_ret_has_param;
@@ -55480,6 +55618,9 @@ function build_struct_functions(node, status, skip_init = false) {
55480
55618
  status.ref_local_vars = old_ref_locals;
55481
55619
  status.ref_slot_vars = old_slot_locals;
55482
55620
  status.variable_types = old_variable_types;
55621
+ status.string_borrow_vars = old_string_borrow_vars;
55622
+ status.moved_string_vars = old_moved_string_vars;
55623
+ status.owned_string_vars = old_owned_string_vars;
55483
55624
  }
55484
55625
  status.current_struct = old_current_struct;
55485
55626
  for (let traitName of node.traits) {
@@ -71760,7 +71901,10 @@ const PREFIX_POSITIONS = /* @__PURE__ */ new Set([
71760
71901
  function join$2(pieces) {
71761
71902
  let out = "";
71762
71903
  for (let i = 0; i < pieces.length; i++) {
71763
- if (i > 0 && needs_space(pieces, i)) out += " ";
71904
+ if (i > 0) {
71905
+ if (pieces[i].kind === "comment") out += pieces[i].gap_before ?? (pieces[i].space_before ? " " : "");
71906
+ else if (needs_space(pieces, i)) out += " ";
71907
+ }
71764
71908
  out += pieces[i].text;
71765
71909
  }
71766
71910
  return out;
@@ -71770,13 +71914,12 @@ function needs_space(pieces, i) {
71770
71914
  const current = pieces[i];
71771
71915
  if (KEEP_SPACING.has(current.text) || KEEP_SPACING.has(previous.text)) return current.space_before;
71772
71916
  if (NO_SPACE_BEFORE.has(current.text)) return false;
71773
- if (current.text === "}") return true;
71917
+ if (current.text === "}") return previous.text !== "{";
71774
71918
  if (NO_SPACE_AFTER.has(previous.text)) return false;
71775
71919
  if (previous.text === "out") return current.text !== ".";
71776
71920
  if (is_sign(pieces, i - 1)) return false;
71777
71921
  if (current.text === ".") return !is_value_end(pieces, i - 1);
71778
71922
  if (current.text === "(" || current.text === "[") return !is_value_end(pieces, i - 1) && !CALL_KEYWORDS.has(previous.text);
71779
- if (current.kind === "comment") return current.space_before;
71780
71923
  return true;
71781
71924
  }
71782
71925
  /** Is the piece at `i` a sign (`-5`) rather than an operator (`a - 5`)? */
@@ -72000,11 +72143,21 @@ function consume_string(input, status) {
72000
72143
  });
72001
72144
  let end = consume_interpolated_expression(input, status.i);
72002
72145
  let fragment = input.substring(status.i + 2, end);
72146
+ const spec_at = find_format_spec_split(fragment);
72147
+ let spec_token;
72148
+ if (spec_at !== -1) {
72149
+ spec_token = {
72150
+ value: fragment.substring(spec_at),
72151
+ i: status.i + 2 + spec_at
72152
+ };
72153
+ fragment = fragment.substring(0, spec_at);
72154
+ }
72003
72155
  status.i = status.i + 2;
72004
72156
  for (let subtoken of tokenize(fragment)) {
72005
72157
  subtoken.i += status.i;
72006
72158
  status.tokens.push(subtoken);
72007
72159
  }
72160
+ if (spec_token) status.tokens.push(spec_token);
72008
72161
  status.i = end;
72009
72162
  status.tokens.push({
72010
72163
  value: "}",
@@ -72031,6 +72184,35 @@ function consume_interpolated_expression(input, i) {
72031
72184
  }
72032
72185
  return input.length - 1;
72033
72186
  }
72187
+ /** Index of the `:` starting a format spec in an interpolation body, or -1.
72188
+ * The spec (if any) always runs to the end of the fragment, so the
72189
+ * RIGHTMOST top-level `:` wins; colons inside string/char literals and
72190
+ * parentheses/brackets are skipped. The parse layer validates the spec
72191
+ * grammar — a top-level `:` has no other meaning in an expression, so any
72192
+ * one found here is treated as a spec attempt and reported there. */
72193
+ function find_format_spec_split(fragment) {
72194
+ let depth = 0;
72195
+ let candidate = -1;
72196
+ let in_string = false;
72197
+ let in_char = false;
72198
+ for (let j = 0; j < fragment.length; j++) {
72199
+ const c = fragment[j];
72200
+ if (in_string || in_char) {
72201
+ if (c === "\\") j += 1;
72202
+ else if (in_string && c === "\"") in_string = false;
72203
+ else if (in_char && c === "'") in_char = false;
72204
+ continue;
72205
+ }
72206
+ if (c === "\"") in_string = true;
72207
+ else if (c === "'") in_char = true;
72208
+ else if (c === "(" || c === "[") depth += 1;
72209
+ else if (c === ")" || c === "]") depth -= 1;
72210
+ else if (c === ":" && depth === 0) {
72211
+ if (fragment[j + 1] !== ":" && fragment[j - 1] !== ":") candidate = j;
72212
+ }
72213
+ }
72214
+ return candidate;
72215
+ }
72034
72216
  function consume_comment(input, status) {
72035
72217
  for (let j = status.i + 1; j < input.length; j++) if (input[j] === "\n") return j;
72036
72218
  return input.length - 1;
@@ -72125,12 +72307,12 @@ function scan_line(text, state) {
72125
72307
  return verbatim(text);
72126
72308
  }
72127
72309
  const pieces = [];
72128
- let space_before = false;
72310
+ let gap = "";
72129
72311
  let i = 0;
72130
72312
  while (i < text.length) {
72131
72313
  const c = text[i];
72132
72314
  if (is_space(c)) {
72133
- space_before = true;
72315
+ gap += c;
72134
72316
  i++;
72135
72317
  continue;
72136
72318
  }
@@ -72164,9 +72346,10 @@ function scan_line(text, state) {
72164
72346
  pieces.push({
72165
72347
  kind,
72166
72348
  text: text.slice(start, i),
72167
- space_before
72349
+ space_before: gap.length > 0,
72350
+ gap_before: gap
72168
72351
  });
72169
- space_before = false;
72352
+ gap = "";
72170
72353
  }
72171
72354
  if (!pieces.length) return {
72172
72355
  kind: "blank",
@@ -72415,6 +72598,11 @@ function join_import(pieces) {
72415
72598
  out = piece.text;
72416
72599
  continue;
72417
72600
  }
72601
+ if (piece.kind === "comment") {
72602
+ out += piece.gap_before ?? (piece.space_before ? " " : "");
72603
+ out += piece.text;
72604
+ continue;
72605
+ }
72418
72606
  if (!(piece.text === "::" || piece.text === "." || piece.text === "-" || pieces[i - 1].text === "::" || pieces[i - 1].text === "." || pieces[i - 1].text === "-")) out += " ";
72419
72607
  out += piece.text;
72420
72608
  }
@@ -74437,17 +74625,202 @@ function parse_match_branch(status) {
74437
74625
  }
74438
74626
  //#endregion
74439
74627
  //#region ../src/parse/parse_string_interpolation.ts
74628
+ /** A format spec token emitted by the tokenizer: the `:` plus the raw spec
74629
+ * text (`:.2`, `:>8`, `:08x` — or invalid text, which the strict grammar
74630
+ * below reports). The tokenizer splits on ANY top-level `:` (none can occur
74631
+ * in an ordinary expression; `::` namespace separators are excluded), so
74632
+ * this accepts any colon-leading token, including a bare `:`. */
74633
+ const FORMAT_SPEC_TOKEN = /^:/;
74634
+ /** Strict grammar walk over the raw spec text (without the `:`). A spec
74635
+ * containing `#` or `,` tries the simple grammar first, then the custom
74636
+ * pattern grammar; everything else reports the simple-grammar failure. */
74637
+ function parse_format_spec(text, start, status) {
74638
+ const simple = parse_simple_spec(text, start);
74639
+ if (simple.spec) return simple.spec;
74640
+ if (!simple.structural) {
74641
+ add_error(status, simple.error ?? "empty format spec", start);
74642
+ return;
74643
+ }
74644
+ const pattern = parse_pattern_spec(text, start);
74645
+ if (pattern.spec) return pattern.spec;
74646
+ if (/[#,;%'"]/.test(text) || text.includes("E")) add_error(status, pattern.error ?? "empty format spec", start);
74647
+ else add_error(status, simple.error ?? "empty format spec", start);
74648
+ }
74649
+ /** The simple spec grammar: `[fill][width][.precision][mode]`. Returns the
74650
+ * spec, or an error plus whether the failure was STRUCTURAL (the text
74651
+ * isn't simple-shaped at all — a custom pattern may still apply) versus a
74652
+ * combination rule the simple grammar rejects outright. */
74653
+ function parse_simple_spec(text, start) {
74654
+ const spec = {
74655
+ text,
74656
+ start
74657
+ };
74658
+ let i = 0;
74659
+ let filled = false;
74660
+ const read_digits = () => {
74661
+ const begin = i;
74662
+ while (i < text.length && text[i] >= "0" && text[i] <= "9") i += 1;
74663
+ if (i === begin) return void 0;
74664
+ return parseInt(text.substring(begin, i), 10);
74665
+ };
74666
+ if (text[i] === "<" || text[i] === ">") {
74667
+ spec.align = text[i] === "<" ? "left" : "right";
74668
+ i += 1;
74669
+ filled = true;
74670
+ const width = read_digits();
74671
+ if (width === void 0) return { error: `invalid format spec ':${text}': expected width digits after '${spec.align === "left" ? "<" : ">"}'` };
74672
+ spec.width = width;
74673
+ } else if (text[i] === "0") {
74674
+ spec.zero = true;
74675
+ i += 1;
74676
+ filled = true;
74677
+ const width = read_digits();
74678
+ if (width === void 0) return {
74679
+ error: `invalid format spec ':${text}': expected width digits after '0'`,
74680
+ structural: true
74681
+ };
74682
+ spec.width = width;
74683
+ }
74684
+ if (spec.width === void 0) {
74685
+ const width = read_digits();
74686
+ if (width !== void 0) {
74687
+ spec.width = width;
74688
+ filled = true;
74689
+ }
74690
+ }
74691
+ if (text[i] === ".") {
74692
+ i += 1;
74693
+ const precision = read_digits();
74694
+ if (precision === void 0) return {
74695
+ error: `invalid format spec ':${text}': expected digits after '.'`,
74696
+ structural: true
74697
+ };
74698
+ spec.precision = precision;
74699
+ filled = true;
74700
+ }
74701
+ const BASE_LETTERS = "xXbo";
74702
+ if (i < text.length && "xXbonNeE".includes(text[i]) && text.length - i === 1) {
74703
+ spec.base = text[i];
74704
+ i += 1;
74705
+ filled = true;
74706
+ }
74707
+ if (i < text.length) return {
74708
+ error: `invalid format spec ':${text}': unexpected character '${text[i]}'`,
74709
+ structural: true
74710
+ };
74711
+ if (!filled) return {
74712
+ error: "empty format spec",
74713
+ structural: true
74714
+ };
74715
+ if (spec.precision !== void 0 && spec.base && BASE_LETTERS.includes(spec.base)) return { error: `invalid format spec ':${text}': precision and base cannot combine` };
74716
+ if (spec.zero && spec.base && (spec.base === "n" || spec.base === "N")) return { error: `invalid format spec ':${text}': zero-padding cannot combine with grouping` };
74717
+ if (spec.width === 0) return { error: `invalid format spec ':${text}': width must be at least 1` };
74718
+ return { spec };
74719
+ }
74720
+ /** The custom-pattern grammar (C#-style subset — see FormatSpec). An
74721
+ * optional `<N`/`>N` alignment prefix composes; the rest is the pattern,
74722
+ * validated section by section. Returns the spec or the error message. */
74723
+ function parse_pattern_spec(text, start) {
74724
+ const spec = {
74725
+ text,
74726
+ start
74727
+ };
74728
+ let i = 0;
74729
+ if (text[i] === "<" || text[i] === ">") {
74730
+ spec.align = text[i] === "<" ? "left" : "right";
74731
+ i += 1;
74732
+ const begin = i;
74733
+ while (i < text.length && text[i] >= "0" && text[i] <= "9") i += 1;
74734
+ if (i === begin) return { error: `invalid format spec ':${text}': expected width digits after '${spec.align === "left" ? "<" : ">"}'` };
74735
+ spec.width = parseInt(text.substring(begin, i), 10);
74736
+ if (spec.width === 0) return { error: `invalid format spec ':${text}': width must be at least 1` };
74737
+ }
74738
+ const pattern = text.substring(i);
74739
+ if (pattern.length === 0) return { error: "empty format spec" };
74740
+ const failf = (message) => {
74741
+ return { error: `invalid format spec ':${text}': ${message}` };
74742
+ };
74743
+ let quoted = false;
74744
+ let sections = 1;
74745
+ let section_has_slot = false;
74746
+ let section_dot = false;
74747
+ let slots_since_comma = -1;
74748
+ for (const c of pattern) {
74749
+ if (quoted) {
74750
+ if (c === "'") quoted = false;
74751
+ continue;
74752
+ }
74753
+ if (c === "'") {
74754
+ quoted = true;
74755
+ continue;
74756
+ }
74757
+ if (c === ";") {
74758
+ if (!section_has_slot) return failf("each section needs at least one digit placeholder ('0' or '#')");
74759
+ if (slots_since_comma === 0) return failf("group commas must sit between digit placeholders");
74760
+ sections += 1;
74761
+ if (sections > 2) return failf("at most two ';'-separated sections");
74762
+ section_has_slot = false;
74763
+ section_dot = false;
74764
+ slots_since_comma = -1;
74765
+ continue;
74766
+ }
74767
+ if (c === "E" || c === "e") return failf("exponential patterns are not supported — use the ':e' spec");
74768
+ if (c === ".") {
74769
+ if (section_dot) return failf("at most one '.' per section");
74770
+ section_dot = true;
74771
+ slots_since_comma = -1;
74772
+ continue;
74773
+ }
74774
+ if (c === "0" || c === "#") {
74775
+ section_has_slot = true;
74776
+ if (slots_since_comma >= 0) slots_since_comma += 1;
74777
+ continue;
74778
+ }
74779
+ if (c === ",") {
74780
+ if (!section_has_slot || section_dot || slots_since_comma === 0) return failf("group commas must sit between digit placeholders");
74781
+ slots_since_comma = 0;
74782
+ continue;
74783
+ }
74784
+ }
74785
+ if (quoted) return failf("unbalanced pattern quotes");
74786
+ if (!section_has_slot) return failf("each section needs at least one digit placeholder ('0' or '#')");
74787
+ if (slots_since_comma === 0) return failf("group commas must sit between digit placeholders");
74788
+ spec.pattern = pattern;
74789
+ return { spec };
74790
+ }
74440
74791
  function parse_string_interpolation(status) {
74441
74792
  const start = get_index(status);
74442
74793
  let pattern = consume(status);
74443
74794
  let values = [];
74795
+ let specs = [];
74796
+ let has_spec = false;
74444
74797
  while (true) {
74445
74798
  const value = consume(status);
74446
74799
  if (value === "\\{") {
74447
74800
  pattern += "\\{}";
74801
+ const peeked = status.tokens[status.i];
74802
+ if (peeked && FORMAT_SPEC_TOKEN.test(peeked.value)) {
74803
+ add_error(status, `format spec '${peeked.value}' requires an expression before ':'`, peeked.i);
74804
+ while (status.i < status.tokens.length && status.tokens[status.i].value !== "}") status.i += 1;
74805
+ accept("}", status);
74806
+ values.push(new ValueNode(start, "\"\"", new Type("string", true)));
74807
+ specs.push(null);
74808
+ continue;
74809
+ }
74448
74810
  let param = parse_expression(status);
74811
+ let spec = null;
74812
+ const spec_token = status.tokens[status.i];
74813
+ if (spec_token && FORMAT_SPEC_TOKEN.test(spec_token.value)) {
74814
+ status.i += 1;
74815
+ const parsed = parse_format_spec(spec_token.value.substring(1), spec_token.i, status);
74816
+ if (parsed) {
74817
+ spec = parsed;
74818
+ has_spec = true;
74819
+ }
74820
+ }
74449
74821
  if (!(param.node_type === "access" && param.access.node_type === "access_func" && param.access.name === "to_string")) param = new AccessNode(start, param, new AccessFunctionCallNode(start, "to_string"));
74450
74822
  values.push(param);
74823
+ specs.push(spec);
74451
74824
  accept("}", status);
74452
74825
  } else if (value.endsWith("\"")) {
74453
74826
  pattern += value;
@@ -74455,7 +74828,9 @@ function parse_string_interpolation(status) {
74455
74828
  } else pattern += value;
74456
74829
  }
74457
74830
  pattern = pattern.replaceAll("\\{}", "%s");
74458
- return new FunctionCallNode(start, `_string_interpolate_${values.length}`, new Type("string"), [new ValueNode(0, pattern, new Type("string", true)), ...values]);
74831
+ const call = new FunctionCallNode(start, `_string_interpolate_${values.length}`, new Type("string"), [new ValueNode(0, pattern, new Type("string", true)), ...values]);
74832
+ if (has_spec) call.format_specs = specs;
74833
+ return call;
74459
74834
  }
74460
74835
  //#endregion
74461
74836
  //#region ../src/parse/parse_switch.ts
@@ -76188,6 +76563,7 @@ function resolve_linked_types(source, library, file_path) {
76188
76563
  if (user_defined.has(token.value)) continue;
76189
76564
  if (library.types.has(token.value) && !BASE_TYPES.includes(token.value)) needed.add(token.value);
76190
76565
  else if (library.functions.has(token.value) && !user_declared_funcs.has(token.value)) needed.add(token.value);
76566
+ else if (token.value.length > 1 && token.value.startsWith(":") && !token.value.startsWith("::")) needed.add("Format");
76191
76567
  }
76192
76568
  if (tokens.some((t) => t.value === "async")) needed.add("Nursery");
76193
76569
  const resolved = resolve_types_with_deps(needed, library, is_library_file ? file_path : void 0);
@@ -76599,11 +76975,18 @@ function heading_of(item) {
76599
76975
  return `${item.kind} ${item.name}${generic}`;
76600
76976
  }
76601
76977
  function signature_of(fn) {
76602
- const params = fn.params.filter((p) => !p.is_self_param).map((p) => `${render_type(p.type)}${p.name ? " " + p.name : ""}`);
76978
+ const params = fn.params.filter((p) => !p.is_self_param).map((p) => `${render_type(p.type)}${p.name ? " " + p.name : ""}${field_set_of(p)}`);
76603
76979
  let sig = `${fn.name}(${params.join(", ")})`;
76604
76980
  if (fn.return_type?.name) sig += ` -> ${render_type(fn.return_type)}`;
76605
76981
  return sig;
76606
76982
  }
76983
+ /** The declared field set suffix (FIELD_EFFECTS.md): `{}` for "displaces
76984
+ * nothing", `{ a, b }` for a named set, nothing for a wildcard. */
76985
+ function field_set_of(param) {
76986
+ if (param.field_set === void 0) return "";
76987
+ if (param.field_set.length === 0) return " {}";
76988
+ return ` { ${param.field_set.join(", ")} }`;
76989
+ }
76607
76990
  function members_of(node) {
76608
76991
  if (node.node_type === "struct" || node.node_type === "trait") return node.functions.filter((f) => f.visibility === "pub" && !f.name.startsWith("#"));
76609
76992
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nomen-lang",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "The CLI for the Nomen programming language.",
5
5
  "keywords": [],
6
6
  "license": "ISC",
package/src/docs.ts CHANGED
@@ -6,6 +6,7 @@ import type BaseNode from "../../src/nodes/BaseNode.ts";
6
6
  import type BitsetNode from "../../src/nodes/BitsetNode.ts";
7
7
  import type EnumNode from "../../src/nodes/EnumNode.ts";
8
8
  import type FunctionNode from "../../src/nodes/FunctionNode.ts";
9
+ import type ParameterNode from "../../src/nodes/ParameterNode.ts";
9
10
  import type StructNode from "../../src/nodes/StructNode.ts";
10
11
  import type TraitNode from "../../src/nodes/TraitNode.ts";
11
12
  import type Type from "../../src/nodes/Type.ts";
@@ -231,12 +232,20 @@ function heading_of(item: DocItem): string {
231
232
  function signature_of(fn: FunctionNode): string {
232
233
  const params = fn.params
233
234
  .filter((p) => !p.is_self_param)
234
- .map((p) => `${render_type(p.type)}${p.name ? " " + p.name : ""}`);
235
+ .map((p) => `${render_type(p.type)}${p.name ? " " + p.name : ""}${field_set_of(p)}`);
235
236
  let sig = `${fn.name}(${params.join(", ")})`;
236
237
  if (fn.return_type?.name) sig += ` -> ${render_type(fn.return_type)}`;
237
238
  return sig;
238
239
  }
239
240
 
241
+ /** The declared field set suffix (FIELD_EFFECTS.md): `{}` for "displaces
242
+ * nothing", `{ a, b }` for a named set, nothing for a wildcard. */
243
+ function field_set_of(param: ParameterNode): string {
244
+ if (param.field_set === undefined) return "";
245
+ if (param.field_set.length === 0) return " {}";
246
+ return ` { ${param.field_set.join(", ")} }`;
247
+ }
248
+
240
249
  function members_of(node: BaseNode): FunctionNode[] {
241
250
  if (node.node_type === "struct" || node.node_type === "trait") {
242
251
  const n = node as unknown as { functions: FunctionNode[] };