@kexhq/kex 0.4.0-beta → 0.4.0-beta.3

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.
@@ -1,14 +1,8 @@
1
- # Algebraic structures, ordering, and combining values associatively.
1
+ # Algebraic structures: combining values associatively.
2
2
  #
3
3
  # Kex traits do not inherit from one another, so concrete types explicitly
4
4
  # implement every structure whose laws they satisfy.
5
5
  #
6
- # The two most important things that you will meet in everyday code. +Ordering+ is what
7
- # a comparison answers, and it composes. This is how a multi-key sort is
8
- # written without nested +if+s:
9
- #
10
- # a.age.compare(b.age).thenBy { a.score.compare(b.score) }
11
- #
12
6
  # +Monoid+ is "these two values combine, and there is a neutral one". Numbers,
13
7
  # strings and lists all satisfy it, which is what lets +repeat+ be written
14
8
  # once:
@@ -16,70 +10,15 @@
16
10
  # "ab".repeat(3) # => "ababab"
17
11
  # [1].repeat(2) # => [1, 1]
18
12
  # 5.repeat(3) # => 15
19
-
20
- # The result of a comparison: +Less+, +Equal+ or +Greater+.
21
- #
22
- # Declared here rather than only inside the interpreter so that `Ordering`,
23
- # `Less`, `Equal` and `Greater` reach the semantic layer the same way every
24
- # other stdlib type does (through the collected interfaces) instead of
25
- # existing solely as native environment bindings the type checker and name
26
- # resolver cannot see.
27
- type Ordering = Less | Equal | Greater
28
-
29
- # Types that have a total order.
30
13
  #
31
- # Implemented by +Number+, which covers both +Integer+ and +Float+.
32
- trait Comparable do
33
- # Compares this value with +other+ and answers +Less+, +Equal+ or
34
- # +Greater+.
35
- #
36
- # +==+ stays independent of this: a type may be equatable without being
37
- # ordered.
38
- #
39
- # @param other [This] the value to compare against
40
- # @return [Ordering] how this value orders against +other+
41
- #
42
- # @example
43
- # 1.compare(2) # => Less
44
- # 2.compare(2) # => Equal
45
- # 3.compare(2) # => Greater
46
- #
47
- # @example Sorting with an explicit comparison
48
- # people.sort { |a, b| a.age.compare(b.age) == Less }
49
- # A total order: `compare` answers Less, Equal or Greater. `==` stays
50
- # independent: a type may be Equatable without being ordered.
51
- compare :> This -> Ordering
52
- end
53
-
54
- # Number carries the implementation, so Integer and Float both inherit it
55
- # rather than repeating the same three comparisons. Mixed receivers work
56
- # because `<` and `>` promote across the two (`1.compare(1.0)` is Equal).
57
- make Number, implement: Comparable do
58
- # Compares two numbers, across the +Integer+/+Float+ boundary.
59
- #
60
- # Mixed receivers work because +<+ and +>+ promote across the two, so
61
- # +1.compare(1.0)+ is +Equal+.
62
- #
63
- # @param other [Number] the number to compare against
64
- # @return [Ordering] how this number orders against +other+
65
- #
66
- # @example
67
- # 1.compare(2) # => Less
68
- # 1.compare(1.0) # => Equal
69
- # 2.5.compare(2) # => Greater
70
- let compare(other: Number) -> Ordering do
71
- return Less if this < other
72
- return Greater if this > other
73
-
74
- return Equal
75
- end
76
- end
14
+ # Ordering and comparison (+Ordering+, +Comparable+) live in
15
+ # `comparable.kex`.
77
16
 
78
17
  # Types whose values combine associatively and have a neutral element.
79
18
  #
80
19
  # Implemented by +Integer+ (addition), +String+ and +List+ (concatenation),
81
20
  # +Map+ and both +Set+ flavours (union), and +Ordering+ ("first decision
82
- # wins").
21
+ # wins" — declared beside +Ordering+ in `comparable.kex`).
83
22
  trait Monoid do
84
23
  # The neutral element: combining it with any value gives that value back.
85
24
  #
@@ -145,7 +84,6 @@ trait Group do
145
84
 
146
85
  # Combines this value with +other+.
147
86
  #
148
- # @param other [This] the value to combine with
149
87
  # @return [This] the combined value
150
88
  combine :> This -> This
151
89
 
@@ -233,82 +171,6 @@ make [A], implement: Monoid do
233
171
  # groups.reduce([]) { |acc, g| acc.combine(g) }
234
172
  let combine(other: This) -> This = this + other
235
173
  end
236
-
237
- # `Ordering` is a Monoid under "first decision wins", with Equal as identity.
238
- # That is what makes multi-key comparison compose instead of nesting ifs:
239
- #
240
- # a.name.compare(b.name).combine(a.age.compare(b.age))
241
- #
242
- # `combine` evaluates its argument eagerly, so the later comparison runs even
243
- # when the earlier one already decided. Use `thenBy` when that matters.
244
- make Ordering, implement: Monoid do
245
- # +Equal+ is the neutral element, since an undecided comparison lets the next
246
- # one decide.
247
- #
248
- # @return [Ordering] +Equal+
249
- let identity = Equal
250
-
251
- # Returns the first decisive ordering: this one if it is not +Equal+,
252
- # otherwise +other+.
253
- #
254
- # This is what makes multi-key comparison compose. Note that +other+ is
255
- # evaluated eagerly, so the later comparison runs even when the earlier one
256
- # has already decided: use +thenBy+ when that matters.
257
- #
258
- # @param other [Ordering] the tie-breaking ordering
259
- # @return [Ordering] the first decisive ordering
260
- #
261
- # @example
262
- # Equal.combine(Less) # => Less
263
- # Less.combine(Greater) # => Less
264
- #
265
- # @example Sorting by surname, then by first name
266
- # a.last.compare(b.last).combine(a.first.compare(b.first))
267
- let combine(@Equal, other: Ordering) -> Ordering = other
268
- let combine(@Less, _) -> Ordering = Less
269
- let combine(@Greater, _) -> Ordering = Greater
270
-
271
- # Returns the opposite ordering: +Less+ becomes +Greater+, +Greater+ becomes
272
- # +Less+, and +Equal+ stays +Equal+.
273
- #
274
- # The one-word way to turn an ascending comparison into a descending one.
275
- #
276
- # @return [Ordering] the reversed ordering
277
- #
278
- # @example
279
- # Less.reverse # => Greater
280
- # Greater.reverse # => Less
281
- # Equal.reverse # => Equal
282
- #
283
- # @example Sorting newest first
284
- # a.created.compare(b.created).reverse
285
- reverse :> Ordering
286
- let reverse(@Less) -> Ordering = Greater
287
- let reverse(@Greater) -> Ordering = Less
288
- let reverse(@Equal) -> Ordering = Equal
289
-
290
- # Returns this ordering if it is decisive, otherwise the result of calling
291
- # +tieBreaker+.
292
- #
293
- # The short-circuiting form of +combine+: the block runs only when this
294
- # comparison is +Equal+, so a tie-breaker costs nothing once the order is
295
- # already decided. Prefer it whenever the tie-breaker is more than a field
296
- # read.
297
- #
298
- # @param tieBreaker [Block<Ordering>] evaluated only on a tie
299
- # @return [Ordering] the first decisive ordering
300
- #
301
- # @example
302
- # Equal.thenBy { 2.compare(1) } # => Greater
303
- # Less.thenBy { 2.compare(1) } # => Less
304
- #
305
- # @example Sorting by age, then by an expensive score
306
- # a.age.compare(b.age).thenBy { score(a).compare(score(b)) }
307
- thenBy :> Block<Ordering> -> Ordering
308
- let thenBy(@Equal, tieBreaker) -> Ordering = tieBreaker()
309
- let thenBy(@Less, _) -> Ordering = Less
310
- let thenBy(@Greater, _) -> Ordering = Greater
311
- end
312
174
  # An opaque, immutable sequence of bytes. A +Binary+ never implicitly becomes
313
175
  # text.
314
176
  #
@@ -829,19 +691,191 @@ end
829
691
  make [X], implement: Blankable do
830
692
  # Returns +true+ when the list has no elements.
831
693
  #
832
- # @return [Bool] +true+ for the empty list
694
+ # @return [Bool] +true+ for the empty list
695
+ #
696
+ # @example
697
+ # [].blank? # => true
698
+ # [1, 2].blank? # => false
699
+ #
700
+ # @example Reporting an empty result set
701
+ # if matches.blank?
702
+ # IO.printLine("no matches")
703
+ # end
704
+ blank? :> Bool
705
+ let blank?(@[]) = true
706
+ let blank?(@[_|_]) = false
707
+ end
708
+ # Ordering and comparison: what a comparison answers, and the types that
709
+ # have a total order.
710
+ #
711
+ # Kex traits do not inherit from one another, so concrete types explicitly
712
+ # implement every structure whose laws they satisfy.
713
+ #
714
+ # The two things you meet in everyday code here. +Ordering+ is what a
715
+ # comparison answers, and it composes. This is how a multi-key sort is
716
+ # written without nested +if+s:
717
+ #
718
+ # a.age.compare(b.age).thenBy { a.score.compare(b.score) }
719
+ #
720
+ # +Ordering+ is also a +Monoid+ under "first decision wins", which is what
721
+ # makes comparisons chain with +combine+; that conformance is declared here,
722
+ # beside the type it belongs to, rather than in `algebra.kex` with the
723
+ # +Monoid+ trait.
724
+
725
+ # The result of a comparison: +Less+, +Equal+ or +Greater+.
726
+ #
727
+ # Declared here rather than only inside the interpreter so that `Ordering`,
728
+ # `Less`, `Equal` and `Greater` reach the semantic layer the same way every
729
+ # other stdlib type does (through the collected interfaces) instead of
730
+ # existing solely as native environment bindings the type checker and name
731
+ # resolver cannot see.
732
+ type Ordering = Less | Equal | Greater
733
+
734
+ # Types that have a total order.
735
+ #
736
+ # Implemented by +Number+, which covers both +Integer+ and +Float+, and by
737
+ # +String+.
738
+ trait Comparable do
739
+ # Compares this value with +other+ and answers +Less+, +Equal+ or
740
+ # +Greater+.
741
+ #
742
+ # +==+ stays independent of this: a type may be equatable without being
743
+ # ordered.
744
+ #
745
+ # @param other [This] the value to compare against
746
+ # @return [Ordering] how this value orders against +other+
747
+ #
748
+ # @example
749
+ # 1.compare(2) # => Less
750
+ # 2.compare(2) # => Equal
751
+ # 3.compare(2) # => Greater
752
+ #
753
+ # @example Sorting with an explicit comparison
754
+ # people.sort { |a, b| a.age.compare(b.age) == Less }
755
+ # A total order: `compare` answers Less, Equal or Greater. `==` stays
756
+ # independent: a type may be Equatable without being ordered.
757
+ compare :> This -> Ordering
758
+ end
759
+
760
+ # Number carries the implementation, so Integer and Float both inherit it
761
+ # rather than repeating the same three comparisons. Mixed receivers work
762
+ # because `<` and `>` promote across the two (`1.compare(1.0)` is Equal).
763
+ make Number, implement: Comparable do
764
+ # Compares two numbers, across the +Integer+/+Float+ boundary.
765
+ #
766
+ # Mixed receivers work because +<+ and +>+ promote across the two, so
767
+ # +1.compare(1.0)+ is +Equal+.
768
+ #
769
+ # @param other [Number] the number to compare against
770
+ # @return [Ordering] how this number orders against +other+
771
+ #
772
+ # @example
773
+ # 1.compare(2) # => Less
774
+ # 1.compare(1.0) # => Equal
775
+ # 2.5.compare(2) # => Greater
776
+ let compare(other: Number) -> Ordering do
777
+ return Less if this < other
778
+ return Greater if this > other
779
+
780
+ return Equal
781
+ end
782
+ end
783
+
784
+ # Strings order lexicographically, character by character, by code point —
785
+ # the same order `<` and `>` already give them.
786
+ make String, implement: Comparable do
787
+ # Compares this string with +other+ lexicographically, the same order
788
+ # +<+ and +>+ give strings.
789
+ #
790
+ # @param other [String] the string to compare against
791
+ # @return [Ordering] how this string orders against +other+
792
+ #
793
+ # @example
794
+ # "apple".compare("banana") # => Less
795
+ # "kex".compare("kex") # => Equal
796
+ # "cherry".compare("apple") # => Greater
797
+ let compare(other: String) -> Ordering do
798
+ return Less if this < other
799
+ return Greater if this > other
800
+
801
+ return Equal
802
+ end
803
+ end
804
+
805
+ # `Ordering` is a Monoid under "first decision wins", with Equal as identity.
806
+ # That is what makes multi-key comparison compose instead of nesting ifs:
807
+ #
808
+ # a.name.compare(b.name).combine(a.age.compare(b.age))
809
+ #
810
+ # `combine` evaluates its argument eagerly, so the later comparison runs even
811
+ # when the earlier one already decided. Use `thenBy` when that matters.
812
+ make Ordering, implement: Monoid do
813
+ # +Equal+ is the neutral element, since an undecided comparison lets the next
814
+ # one decide.
815
+ #
816
+ # @return [Ordering] +Equal+
817
+ let identity = Equal
818
+
819
+ # Returns the first decisive ordering: this one if it is not +Equal+,
820
+ # otherwise +other+.
821
+ #
822
+ # This is what makes multi-key comparison compose. Note that +other+ is
823
+ # evaluated eagerly, so the later comparison runs even when the earlier one
824
+ # has already decided: use +thenBy+ when that matters.
825
+ #
826
+ # @param other [Ordering] the tie-breaking ordering
827
+ # @return [Ordering] the first decisive ordering
828
+ #
829
+ # @example
830
+ # Equal.combine(Less) # => Less
831
+ # Less.combine(Greater) # => Less
832
+ #
833
+ # @example Sorting by surname, then by first name
834
+ # a.last.compare(b.last).combine(a.first.compare(b.first))
835
+ let combine(@Equal, other: Ordering) -> Ordering = other
836
+ let combine(@Less, _) -> Ordering = Less
837
+ let combine(@Greater, _) -> Ordering = Greater
838
+
839
+ # Returns the opposite ordering: +Less+ becomes +Greater+, +Greater+ becomes
840
+ # +Less+, and +Equal+ stays +Equal+.
841
+ #
842
+ # The one-word way to turn an ascending comparison into a descending one.
843
+ #
844
+ # @return [Ordering] the reversed ordering
845
+ #
846
+ # @example
847
+ # Less.reverse # => Greater
848
+ # Greater.reverse # => Less
849
+ # Equal.reverse # => Equal
850
+ #
851
+ # @example Sorting newest first
852
+ # a.created.compare(b.created).reverse
853
+ reverse :> Ordering
854
+ let reverse(@Less) -> Ordering = Greater
855
+ let reverse(@Greater) -> Ordering = Less
856
+ let reverse(@Equal) -> Ordering = Equal
857
+
858
+ # Returns this ordering if it is decisive, otherwise the result of calling
859
+ # +tieBreaker+.
860
+ #
861
+ # The short-circuiting form of +combine+: the block runs only when this
862
+ # comparison is +Equal+, so a tie-breaker costs nothing once the order is
863
+ # already decided. Prefer it whenever the tie-breaker is more than a field
864
+ # read.
865
+ #
866
+ # @param tieBreaker [Block<Ordering>] evaluated only on a tie
867
+ # @return [Ordering] the first decisive ordering
833
868
  #
834
869
  # @example
835
- # [].blank? # => true
836
- # [1, 2].blank? # => false
870
+ # Equal.thenBy { 2.compare(1) } # => Greater
871
+ # Less.thenBy { 2.compare(1) } # => Less
837
872
  #
838
- # @example Reporting an empty result set
839
- # if matches.blank?
840
- # IO.printLine("no matches")
841
- # end
842
- blank? :> Bool
843
- let blank?(@[]) = true
844
- let blank?(@[_|_]) = false
873
+ # @example Sorting by age, then by an expensive score
874
+ # a.age.compare(b.age).thenBy { score(a).compare(score(b)) }
875
+ thenBy :> Block<Ordering> -> Ordering
876
+ let thenBy(@Equal, tieBreaker) -> Ordering = tieBreaker()
877
+ let thenBy(@Less, _) -> Ordering = Less
878
+ let thenBy(@Greater, _) -> Ordering = Greater
845
879
  end
846
880
  # ANSI terminal styling: colors, text attributes, and cursor control.
847
881
  #
@@ -1074,232 +1108,346 @@ module Console do
1074
1108
  enabled? : Bool
1075
1109
  let enabled? = Kex.Intrinsic.Console.enabled?
1076
1110
  end
1077
- # Bounded retries for operations whose failures can be classified by the
1078
- # application.
1111
+ # Runs bounded retries with a reusable schedule.
1079
1112
  #
1080
- # A retry is never automatically safe just because an error was temporary.
1081
- # The caller owns the operation and, where necessary, a predicate that excludes
1082
- # permanent failures and non-idempotent work. Policies bound attempts, delay,
1083
- # and optionally total sleep so a dependency cannot stall the program forever.
1113
+ # +Retry.run+ executes the block immediately. An +Ok+ stops the run; an
1114
+ # +Error+ schedules another attempt. When attempts or total sleep run out,
1115
+ # the last error is returned unchanged. Runtime faults are not caught.
1084
1116
  #
1117
+ # @example Retry a request and handle the final result
1085
1118
  # using Control.Retry
1119
+ # using Net.HTTP
1086
1120
  #
1087
- # let policy = Retry.exponential(4, 100.milliseconds, 2.seconds)
1088
- # .withJitter(0.2)
1089
- # Retry.run(policy, ~retryable?) do
1090
- # client.get("https://api.example.com/inventory")
1121
+ # let result = Retry.run(attempts: 5) do
1122
+ # HTTP.get("https://api.example.com/inventory")
1123
+ # end
1124
+ # match result do
1125
+ # Ok(response) => IO.printLine(response.status.code)
1126
+ # Error(error) => IO.printLine(error.message)
1091
1127
  # end
1128
+ #
1129
+ # HTTP responses, including 429 and 503, are +Ok(response)+. Automatic
1130
+ # retries handle request failures, not response statuses. Use +again+ and
1131
+ # +done+ to classify responses or stop on permanent errors. Repeat writes
1132
+ # only when the application makes them safe, for example with idempotency
1133
+ # keys. A schedule bounds retry sleep, not the operation's execution time;
1134
+ # configure request timeouts separately.
1092
1135
  module Control.Retry
1093
1136
 
1094
- # A bounded retry schedule. Attempts includes the initial call.
1095
- # Delays are immutable +Duration+ values and never exceed +maximumDelay+.
1096
- record Policy do
1097
- maximumAttempts : Integer
1098
- initialDelay : Duration
1099
- multiplier : Float
1100
- maximumDelay : Duration
1101
- maximumElapsed : Duration? = None
1102
- jitterFraction : Float = 0.0
1103
- end
1104
-
1105
- # Decides whether an application error is eligible for another attempt.
1106
- type Predicate<E> = E -> Bool
1107
-
1108
- # Performs one scheduled delay. Supplying this callback makes retry tests
1109
- # deterministic without sleeping.
1110
- type Sleeper = Duration -> Void
1111
-
1112
- # Builds a constant-delay retry policy.
1113
- #
1114
- # Fixed delays are predictable and useful for a local resource expected to
1115
- # become ready shortly. For many clients sharing a remote dependency, prefer
1116
- # exponential backoff with jitter to avoid synchronized retry bursts.
1117
- #
1118
- # @param maximumAttempts [Integer] total calls including the first
1119
- # @param delay [Duration] delay before each subsequent call
1120
- # @return [Policy] the retry schedule
1121
- #
1122
- # @example Waiting briefly for a local test server to start
1123
- # let policy = Retry.fixed(5, 50.milliseconds)
1124
- fixed : Integer -> Duration -> Policy
1125
- let fixed(maximumAttempts, delay) = Policy {
1126
- maximumAttempts: maximumAttempts,
1127
- initialDelay: delay,
1128
- multiplier: 1.0,
1129
- maximumDelay: delay,
1130
- maximumElapsed: None,
1131
- jitterFraction: 0.0
1132
- }
1137
+ # Provides automatic error retries and explicit retry decisions.
1138
+ module Retry do
1139
+ # Describes the timing and limits of one execution of +run+.
1140
+ #
1141
+ # All fields are optional. Defaults allow three attempts with exponential
1142
+ # backoff from 100 milliseconds, capped at 5 seconds, with 20% jitter.
1143
+ # Creating a schedule performs no work. It is immutable configuration:
1144
+ # each run starts a fresh attempt count and delay sequence.
1145
+ #
1146
+ # * +attempts+: maximum executions, including the first; default 3.
1147
+ # * +delay+: initial base wait; default 100 milliseconds.
1148
+ # * +backoff+: base-delay multiplier; default 2.0, or 1.0 for fixed waits.
1149
+ # * +maximumDelay+: cap on each actual wait, including jitter; default 5 seconds.
1150
+ # * +jitter+: symmetric proportional variation; default 0.2, or 0.0 to disable.
1151
+ # * +maximumTotalDelay+: cumulative sleep allowance; default +None+.
1152
+ # Operation execution time is excluded. A wait that exceeds the remaining
1153
+ # allowance ends the run without sleeping or calling the operation again.
1154
+ #
1155
+ # @example Fixed delays: three waits of 250 milliseconds
1156
+ # let schedule = Retry.Schedule {
1157
+ # attempts: 4, delay: 250.milliseconds, backoff: 1.0,
1158
+ # maximumDelay: 250.milliseconds, jitter: 0.0
1159
+ # }
1160
+ # Retry.run(schedule: schedule) do
1161
+ # Error("not ready")
1162
+ # end
1163
+ # # => Error("not ready"), after four calls and 750 ms of sleep
1164
+ #
1165
+ # @example Exponential delays: 200, 400, 800, 1600 milliseconds
1166
+ # let schedule = Retry.Schedule {
1167
+ # attempts: 5, delay: 200.milliseconds, backoff: 2.0,
1168
+ # maximumDelay: 5.seconds, jitter: 0.0
1169
+ # }
1170
+ # Retry.run(schedule: schedule) do
1171
+ # Ok("ready")
1172
+ # end
1173
+ # # => Ok("ready"), immediately, without sleeping
1174
+ #
1175
+ # @example Capped delays: 500 ms, 1 s, 2 s, 2 s, 2 s
1176
+ # Retry.Schedule {
1177
+ # attempts: 6, delay: 500.milliseconds, backoff: 2.0,
1178
+ # maximumDelay: 2.seconds, jitter: 0.0
1179
+ # }
1180
+ #
1181
+ # @example Share settings, not an attempt budget
1182
+ # using Net.HTTP
1183
+ # let schedule = Retry.Schedule { attempts: 5 }
1184
+ # let inventory = Retry.run(schedule: schedule) do
1185
+ # HTTP.get("https://api.example.com/inventory")
1186
+ # end
1187
+ # let orders = Retry.run(schedule: schedule) do
1188
+ # HTTP.get("https://api.example.com/orders")
1189
+ # end
1190
+ # # Each request gets up to five attempts, independently.
1191
+ record Schedule do
1192
+ # Maximum executions including the initial call. Default: 3.
1193
+ # Values below 1 are treated as 1: the initial call always happens.
1194
+ attempts : Integer = 3
1195
+ # Base wait before the second attempt. Default: 100 milliseconds.
1196
+ # Negative durations are treated as zero; the delay cap applies here too.
1197
+ delay : Duration = Duration.milliseconds(100)
1198
+ # Base-delay multiplier. Default: 2.0. Values below 1 become 1.
1199
+ # Set to 1.0 for fixed delays. Jitter never changes the next base delay.
1200
+ backoff : Float = 2.0
1201
+ # Maximum actual wait, including jitter. Default: 5 seconds.
1202
+ # Negative durations become zero. Hitting this cap does not stop retries.
1203
+ maximumDelay : Duration = Duration.seconds(5)
1204
+ # Symmetric proportional variation. Default: 0.2; clamped to 0..1.
1205
+ # For a 200 ms base, 0.2 samples 160..240 ms, then applies the cap.
1206
+ # Set to 0.0 for exact waits. Samples are independent between runs.
1207
+ jitter : Float = 0.2
1208
+ # Optional bound on cumulative actual sleep. Default: None.
1209
+ # A wait exceeding the remaining allowance stops retries before sleeping.
1210
+ # Negative bounds become zero. Operation execution time is not counted.
1211
+ maximumTotalDelay : Duration? = None
1212
+ end
1133
1213
 
1134
- # Builds a doubling backoff capped at +maximumDelay+.
1135
- #
1136
- # The first retry waits +initialDelay+; later delays double until they reach
1137
- # the cap. Add jitter for production network traffic.
1138
- #
1139
- # @param maximumAttempts [Integer] total calls including the first
1140
- # @param initialDelay [Duration] delay after the first failure
1141
- # @param maximumDelay [Duration] upper bound for each delay
1142
- # @return [Policy] the retry schedule
1143
- #
1144
- # @example Backing off calls to a busy upstream service
1145
- # Retry.exponential(5, 200.milliseconds, 5.seconds).withJitter(0.25)
1146
- exponential : Integer -> Duration -> Duration -> Policy
1147
- let exponential(maximumAttempts, initialDelay, maximumDelay) = Policy {
1148
- maximumAttempts: maximumAttempts,
1149
- initialDelay: initialDelay,
1150
- multiplier: 2.0,
1151
- maximumDelay: maximumDelay,
1152
- maximumElapsed: None,
1153
- jitterFraction: 0.0
1154
- }
1214
+ # Carries an explicit decision and the last application value.
1215
+ # +Done(value)+ means the block stopped deliberately. +Again(value)+
1216
+ # returned by +run+ means the schedule was exhausted before completion.
1217
+ # Neither form wraps the application value in an additional +Result+.
1218
+ type Decision<X> = Again(X) | Done(X)
1219
+
1220
+ # Requests another attempt if the schedule allows it.
1221
+ #
1222
+ # @param value [X] the last outcome, retained if the schedule is exhausted
1223
+ # @return [Decision<X>] +Again(value)+
1224
+ # @example Polling an application job
1225
+ # Retry.again("pending")
1226
+ again : X -> Decision<X>
1227
+ let again(value) = Again(value)
1228
+
1229
+ # Stops immediately, even when the application value represents failure.
1230
+ #
1231
+ # @param value [X] the final application outcome
1232
+ # @return [Decision<X>] +Done(value)+
1233
+ # @example Stop on invalid credentials without retrying
1234
+ # Retry.done(Error("invalid credentials"))
1235
+ done : X -> Decision<X>
1236
+ let done(value) = Done(value)
1237
+
1238
+ # Describes a retry that is about to wait and then execute another attempt.
1239
+ #
1240
+ # Passed to +onRetry+ only after the last outcome requests a retry and
1241
+ # the schedule permits it. There is no notification for the initial call,
1242
+ # success, explicit completion, or exhaustion. Reporting does not consume
1243
+ # attempts. Durations describe scheduled sleep, not wall-clock elapsed time.
1244
+ #
1245
+ # * +attempt+: the just-completed attempt, starting at 1.
1246
+ # * +nextAttempt+: the attempt that follows the upcoming wait.
1247
+ # * +maximumAttempts+: total permitted executions, including the first.
1248
+ # * +remainingAttempts+: executions remaining, including the upcoming one.
1249
+ # * +delay+: actual upcoming wait, after jitter and the delay cap.
1250
+ # * +totalDelay+: sleep already performed, excluding the upcoming wait.
1251
+ # * +result+: the last +Error+ or +Again+, including its application payload.
1252
+ record Info<X> do
1253
+ # The attempt that just finished, starting at 1.
1254
+ attempt : Integer
1255
+ # The attempt that will run after the upcoming delay.
1256
+ nextAttempt : Integer
1257
+ # Total allowed executions, normalized to at least 1.
1258
+ maximumAttempts : Integer
1259
+ # Executions still allowed, including the upcoming attempt.
1260
+ remainingAttempts : Integer
1261
+ # The actual upcoming wait, after jitter and the delay cap.
1262
+ delay : Duration
1263
+ # Sleep already performed in this run, excluding the upcoming wait.
1264
+ totalDelay : Duration
1265
+ # The last Error or Again value, including the application's payload.
1266
+ result : X
1267
+ end
1155
1268
 
1156
- make Policy do
1157
- # Returns the same schedule with a bound on total scheduled sleep time.
1158
- #
1159
- # The operation's own execution time is not counted; use operation-specific
1160
- # deadlines for that. A retry whose next delay would exceed this bound is
1161
- # not started.
1162
- #
1163
- # @param maximumElapsed [Duration] maximum cumulative scheduled delay
1164
- # @return [Policy] a copied policy with the elapsed bound
1165
- # @example +Retry.fixed(5, 1.seconds).withMaximumElapsed(2.seconds)+.
1166
- let withMaximumElapsed(maximumElapsed: Duration) -> Policy = Policy {
1167
- maximumAttempts: @maximumAttempts,
1168
- initialDelay: @initialDelay,
1169
- multiplier: @multiplier,
1170
- maximumDelay: @maximumDelay,
1171
- maximumElapsed: Just(maximumElapsed),
1172
- jitterFraction: @jitterFraction
1173
- }
1269
+ # Runs a fresh operation until it succeeds, stops, or exhausts its schedule.
1270
+ #
1271
+ # The first attempt is immediate. Every later attempt follows one sleep.
1272
+ # There is no sleep after success or the final error. Named timing options
1273
+ # override the corresponding schedule field for this execution only.
1274
+ #
1275
+ # An ordinary block returns +Result<X, E>+: +Ok+ stops, +Error+ retries,
1276
+ # and exhaustion returns the last +Error+ unchanged. An explicit block
1277
+ # returns +Decision<X>+: +Done+ stops, +Again+ retries, and exhaustion
1278
+ # returns the last +Again+. Match the returned decision to distinguish
1279
+ # completion from exhaustion. Use one form consistently within a block.
1280
+ #
1281
+ # Finite settings are normalized: attempts below one become one, negative
1282
+ # durations become zero, backoff below one becomes one, jitter and random
1283
+ # samples are clamped to 0..1. Non-finite Float settings are unsupported.
1284
+ #
1285
+ # @param operation [Block<X>] fresh Result or Decision on each attempt
1286
+ # @param schedule [Schedule] reusable settings; defaults to +Schedule {}+
1287
+ # @param attempts [Integer] total attempts; defaults to the schedule field
1288
+ # @param delay [Duration] initial wait; defaults to the schedule field
1289
+ # @param backoff [Float] delay multiplier; defaults to the schedule field
1290
+ # @param maximumDelay [Duration] sleep cap; defaults to the schedule field
1291
+ # @param jitter [Float] variation fraction; defaults to the schedule field
1292
+ # @param maximumTotalDelay [Duration?] sleep budget; defaults to the field
1293
+ # @param sleeper [Duration -> Void] defaults to real +Task.sleep+
1294
+ # @param random [Block<Float>] defaults to secure backend sampling in 0..1
1295
+ # @param onRetry [Info<X> -> Void] reports an allowed retry; defaults to no action
1296
+ # @return [X] first Ok/Done or last Error/Again, without extra wrapping
1297
+ #
1298
+ # @example Named options without constructing a schedule
1299
+ # Retry.run(attempts: 5, delay: 200.milliseconds, jitter: 0.0) do
1300
+ # Ok("ready")
1301
+ # end
1302
+ # # => Ok("ready")
1303
+ #
1304
+ # @example Stop before a wait exceeds the total sleep budget
1305
+ # Retry.run(
1306
+ # attempts: 5, delay: 1.seconds, backoff: 2.0,
1307
+ # jitter: 0.0, maximumTotalDelay: Just(2.seconds)
1308
+ # ) do
1309
+ # Error("busy")
1310
+ # end
1311
+ # # => Error("busy"), two calls and one second of sleep
1312
+ #
1313
+ # @example Test jitter without waiting
1314
+ # Retry.run(
1315
+ # attempts: 2, delay: 4.seconds, jitter: 0.25,
1316
+ # sleeper: { |wait| Assert.equal(wait, 3.seconds) },
1317
+ # random: { 0.0 }
1318
+ # ) do
1319
+ # Error("temporary")
1320
+ # end
1321
+ # # => Error("temporary"), two calls and one fake sleep
1322
+ #
1323
+ # @example Report progress to a user or log
1324
+ # using Net.HTTP
1325
+ # Retry.run(attempts: 5, onRetry: { |info|
1326
+ # let progress = "retrying ${info.nextAttempt}/${info.maximumAttempts}"
1327
+ # IO.printLine("${progress} in ${info.delay.seconds} seconds")
1328
+ # }) do
1329
+ # HTTP.get("https://api.example.com/inventory")
1330
+ # end
1331
+ #
1332
+ # @example Retry temporary HTTP failures and selected statuses
1333
+ # using Net
1334
+ # using Net.HTTP
1335
+ # let schedule = Retry.Schedule { attempts: 5 }
1336
+ # let outcome = Retry.run(schedule: schedule) do
1337
+ # let result = HTTP.get("https://api.example.com/inventory")
1338
+ # match result do
1339
+ # Error(error) => if error.kind == Timeout || error.kind == Connect
1340
+ # Retry.again(result)
1341
+ # else
1342
+ # Retry.done(result)
1343
+ # end
1344
+ # Ok(response) => if [429, 502, 503, 504].contains?(response.status.code)
1345
+ # Retry.again(result)
1346
+ # else
1347
+ # Retry.done(result)
1348
+ # end
1349
+ # end
1350
+ # end
1351
+ # match outcome do
1352
+ # Done(result) => IO.printLine(result)
1353
+ # Again(last) => IO.printLine("retry budget exhausted: ${last}")
1354
+ # end
1355
+ #
1356
+ # +Done(Error(...))+ means the application stopped on a permanent error.
1357
+ # +Again(Ok(response))+ means an unacceptable HTTP status persisted until
1358
+ # exhaustion. HTTP status classification and Retry-After handling are the
1359
+ # application's responsibility; +run+ has no HTTP-specific behavior.
1360
+ #
1361
+ # The testing hooks are independent: replacing sleep keeps normal random
1362
+ # sampling, and replacing randomness keeps real sleep. A random sample of
1363
+ # 0 selects the lower jitter bound, 0.5 the base, and 1 the upper bound.
1364
+ # With zero jitter no random sample is needed.
1365
+ # lint:allow parameter-count — named options make each setting independent
1366
+ run : Block<X> -> Schedule -> Integer -> Duration -> Float -> Duration -> Float -> Duration? -> (Duration -> Void) -> Block<Float> -> (Info<X> -> Void) -> X
1367
+ foul run(
1368
+ operation,
1369
+ schedule = Schedule {},
1370
+ attempts = schedule.attempts,
1371
+ delay = schedule.delay,
1372
+ backoff = schedule.backoff,
1373
+ maximumDelay = schedule.maximumDelay,
1374
+ jitter = schedule.jitter,
1375
+ maximumTotalDelay = schedule.maximumTotalDelay,
1376
+ sleeper = { |wait| Task.sleep(wait) },
1377
+ random = { Kex.Intrinsic.Retry.randomUnit() },
1378
+ onRetry = { |_info| () }
1379
+ ) do
1380
+ let settings = Schedule {
1381
+ attempts: attempts,
1382
+ delay: delay,
1383
+ backoff: backoff,
1384
+ maximumDelay: maximumDelay,
1385
+ jitter: jitter,
1386
+ maximumTotalDelay: maximumTotalDelay
1387
+ }
1388
+ attempt(settings, sleeper, random, onRetry, operation, 1, boundedDelay(settings, delay.seconds), 0.0)
1389
+ end
1174
1390
 
1175
- # Returns the same schedule with symmetric bounded jitter. A fraction of
1176
- # +0.25+ selects each actual delay from 75% through 125% of its scheduled
1177
- # value. Fractions are clamped to +0.0..1.0+.
1178
- #
1179
- # Jitter prevents many workers that failed together from retrying together.
1180
- # It changes delay timing, never the number of attempts or the backoff cap.
1181
- #
1182
- # @param fraction [Float] maximum proportional variation on either side
1183
- # @return [Policy] a copied policy with bounded jitter
1184
- #
1185
- # @example Spreading retries by up to 20 percent
1186
- # Retry.exponential(4, 1.seconds, 10.seconds).withJitter(0.2)
1187
- let withJitter(fraction: Float) -> Policy = Policy {
1188
- maximumAttempts: @maximumAttempts,
1189
- initialDelay: @initialDelay,
1190
- multiplier: @multiplier,
1191
- maximumDelay: @maximumDelay,
1192
- maximumElapsed: @maximumElapsed,
1193
- jitterFraction: if fraction < 0.0 then 0.0 else if fraction > 1.0 then 1.0 else fraction end end
1194
- }
1195
- end
1391
+ private do
1392
+ retry? : Result<X, E> -> Bool
1393
+ let retry?(Ok(_)) = false
1394
+ let retry?(Error(_)) = true
1395
+ retry? : Decision<X> -> Bool
1396
+ let retry?(Done(_)) = false
1397
+ let retry?(Again(_)) = true
1398
+
1399
+ let clamp(value: Float, low: Float, high: Float) -> Float do
1400
+ if value < low
1401
+ low
1402
+ elif value > high
1403
+ high
1404
+ else
1405
+ value
1406
+ end
1407
+ end
1196
1408
 
1197
- # Runs +operation+ until it succeeds or exhausts the policy.
1198
- #
1199
- # The last application error is returned unchanged. This helper performs no
1200
- # network-specific classification: callers decide what operation to wrap.
1201
- #
1202
- # @param policy [Policy] the bounded schedule
1203
- # @param operation [Block<Result<X,E>>] a fresh attempt
1204
- # @return [Result<X,E>] the first success or final failure
1205
- #
1206
- # @example Retrying an idempotent health check
1207
- # Retry.run(Retry.fixed(3, 250.milliseconds)) do
1208
- # HTTP.get("https://service.example.com/health")
1209
- # end
1210
- run : Policy -> Block<Result<X, E>> -> Result<X, E>
1211
- foul run(policy, operation) = runWithRandom(policy, { |error| true }, { |delay| Task.sleep(delay) }, { Kex.Intrinsic.Retry.randomUnit() }, operation)
1409
+ let boundedDelay(schedule: Schedule, waitSeconds: Float) -> Float do
1410
+ let cap = if schedule.maximumDelay.seconds < 0.0
1411
+ 0.0
1412
+ else
1413
+ schedule.maximumDelay.seconds
1414
+ end
1415
+ clamp(waitSeconds, 0.0, cap)
1416
+ end
1212
1417
 
1213
- # Runs with an application-specific error predicate.
1214
- #
1215
- # A false predicate returns that error immediately. The predicate is evaluated
1216
- # only when another attempt would otherwise be possible.
1217
- #
1218
- # @param policy [Policy] the bounded schedule
1219
- # @param predicate [Predicate<E>] whether an error may be retried
1220
- # @param operation [Block<Result<X,E>>] a fresh attempt
1221
- # @return [Result<X,E>] the first success or final/non-retryable failure
1222
- #
1223
- # @example Retrying timeouts but returning parse failures immediately
1224
- # Retry.run(policy, { |error| error.kind == Timeout }) do
1225
- # client.get(url)
1226
- # end
1227
- run : Policy -> Predicate<E> -> Block<Result<X, E>> -> Result<X, E>
1228
- foul run(policy, predicate, operation) = runWithRandom(policy, predicate, { |delay| Task.sleep(delay) }, { Kex.Intrinsic.Retry.randomUnit() }, operation)
1229
-
1230
- # Runs with injected error classification and sleeping.
1231
- #
1232
- # This is the deterministic testing seam: a fake sleeper can record durations
1233
- # or advance a virtual clock. Production callers normally use +Retry.run+.
1234
- #
1235
- # @param policy [Policy] the bounded schedule
1236
- # @param predicate [Predicate<E>] whether an error may be retried
1237
- # @param sleeper [Sleeper] performs or records each scheduled delay
1238
- # @param operation [Block<Result<X,E>>] a fresh attempt
1239
- # @return [Result<X,E>] the first success or final failure
1240
- runWith : Policy -> Predicate<E> -> Sleeper -> Block<Result<X, E>> -> Result<X, E>
1241
- foul runWith(policy, predicate, sleeper, operation) = runWithRandom(policy, predicate, sleeper, { 0.5 }, operation)
1242
-
1243
- # Runs with injected sleeping and a random source returning a value in
1244
- # +0.0..1.0+. Out-of-range test values are clamped. Production +run+ uses a
1245
- # cryptographically secure backend source; this overload makes jitter specs
1246
- # deterministic.
1247
- runWithRandom : Policy -> Predicate<E> -> Sleeper -> Block<Float> -> Block<Result<X, E>> -> Result<X, E>
1248
- foul runWithRandom(policy, predicate, sleeper, random, operation) = attempt(policy, predicate, sleeper, random, operation, 1, policy.initialDelay, Duration.seconds(0))
1249
-
1250
- foul attempt(policy: Policy, predicate: Predicate<E>, sleeper: Sleeper, random: Block<Float>, operation: Block<Result<X, E>>, number: Integer, delay: Duration, elapsed: Duration) -> Result<X, E> do
1251
- let result = operation()
1252
- return match result do
1253
- Ok(value) => Ok(value)
1254
- Error(error) => do
1255
- if number >= policy.maximumAttempts || !predicate(error)
1256
- return Error(error)
1418
+ attempt : Schedule -> (Duration -> Void) -> Block<Float> -> (Info<X> -> Void) -> Block<X> -> Integer -> Float -> Float -> X
1419
+ foul attempt(schedule, sleeper, random, onRetry, operation, number, base, elapsed) do
1420
+ let result = operation()
1421
+ if number >= schedule.attempts || !retry?(result)
1422
+ return result
1257
1423
  end
1258
- let sample = random()
1259
- let boundedSample = if sample < 0.0 then 0.0 else if sample > 1.0 then 1.0 else sample end end
1260
- let jittered = Duration { seconds: delay.seconds * (1.0 + ((boundedSample * 2.0 - 1.0) * policy.jitterFraction)) }
1261
- let nextElapsed = Duration { seconds: elapsed.seconds + jittered.seconds }
1262
- let withinElapsed = match policy.maximumElapsed do
1424
+ let fraction = clamp(schedule.jitter, 0.0, 1.0)
1425
+ let sample = if fraction == 0.0 then 0.5 else clamp(random(), 0.0, 1.0) end
1426
+ let sleepSeconds = boundedDelay(schedule, base * (1.0 + (sample * 2.0 - 1.0) * fraction))
1427
+ let nextElapsed = elapsed + sleepSeconds
1428
+ let allowed = match schedule.maximumTotalDelay do
1263
1429
  None => true
1264
- Just(maximum) => nextElapsed.seconds <= maximum.seconds
1430
+ Just(limit) => nextElapsed <= (if limit.seconds < 0.0 then 0.0 else limit.seconds end)
1265
1431
  end
1266
- if !withinElapsed
1267
- return Error(error)
1432
+ if !allowed
1433
+ return result
1268
1434
  end
1269
- sleeper(jittered)
1270
- let nextSeconds = delay.seconds * policy.multiplier
1271
- let bounded = if nextSeconds > policy.maximumDelay.seconds then policy.maximumDelay else Duration { seconds: nextSeconds } end
1272
- return attempt(policy, predicate, sleeper, random, operation, number + 1, bounded, nextElapsed)
1435
+ onRetry(Info {
1436
+ attempt: number,
1437
+ nextAttempt: number + 1,
1438
+ maximumAttempts: schedule.attempts,
1439
+ remainingAttempts: schedule.attempts - number,
1440
+ delay: Duration { seconds: sleepSeconds },
1441
+ totalDelay: Duration { seconds: elapsed },
1442
+ result: result
1443
+ })
1444
+ sleeper(Duration { seconds: sleepSeconds })
1445
+ let factor = if schedule.backoff < 1.0 then 1.0 else schedule.backoff end
1446
+ let nextBase = boundedDelay(schedule, base * factor)
1447
+ attempt(schedule, sleeper, random, onRetry, operation, number + 1, nextBase, nextElapsed)
1273
1448
  end
1274
1449
  end
1275
1450
  end
1276
-
1277
- # The imported public namespace: `using Control.Retry` then `Retry.run(...)`.
1278
- module Retry do
1279
- # Public imported alias of +Control.Retry.fixed+.
1280
- fixed : Integer -> Duration -> Policy
1281
- let fixed(maximumAttempts, delay) = Control.Retry.fixed(maximumAttempts, delay)
1282
-
1283
- # Public imported alias of +Control.Retry.exponential+.
1284
- exponential : Integer -> Duration -> Duration -> Policy
1285
- let exponential(maximumAttempts, initialDelay, maximumDelay) = Control.Retry.exponential(maximumAttempts, initialDelay, maximumDelay)
1286
-
1287
- # Public imported alias of +Control.Retry.run+.
1288
- run : Policy -> Block<Result<X, E>> -> Result<X, E>
1289
- foul run(policy, operation) = Control.Retry.run(policy, operation)
1290
-
1291
- # Retries only errors accepted by +predicate+.
1292
- run : Policy -> Predicate<E> -> Block<Result<X, E>> -> Result<X, E>
1293
- foul run(policy, predicate, operation) = Control.Retry.run(policy, predicate, operation)
1294
-
1295
- # Deterministic seam with an injected sleeper, primarily for specifications.
1296
- runWith : Policy -> Predicate<E> -> Sleeper -> Block<Result<X, E>> -> Result<X, E>
1297
- foul runWith(policy, predicate, sleeper, operation) = Control.Retry.runWith(policy, predicate, sleeper, operation)
1298
-
1299
- # Deterministic seam with injected sleeping and random sampling.
1300
- runWithRandom : Policy -> Predicate<E> -> Sleeper -> Block<Float> -> Block<Result<X, E>> -> Result<X, E>
1301
- foul runWithRandom(policy, predicate, sleeper, random, operation) = Control.Retry.runWithRandom(policy, predicate, sleeper, random, operation)
1302
- end
1303
1451
  # A first-in-first-out queue.
1304
1452
  #
1305
1453
  # Opt-in: nothing here is in scope until `using Data.Queue`.
@@ -2447,6 +2595,89 @@ module Digest do
2447
2595
  fileSha256 : String -> String?
2448
2596
  let fileSha256(path) = Kex.Intrinsic.Digest.fileSha256(path)
2449
2597
  end
2598
+ # Dimension algebra, implemented with ordinary Kex maps and integers.
2599
+ #
2600
+ # A base dimension is identified by the type of a marker value. Reuse that
2601
+ # marker type to share a dimension across modules. Its display name and the
2602
+ # marker's field values do not participate in dimensional arithmetic.
2603
+ #
2604
+ # Multiplication adds exponents, division subtracts them, and integer powers
2605
+ # multiply them. Zero exponents are removed so cancellation is structural.
2606
+ module Dimensions
2607
+
2608
+ # A normalized map from base identities to integer exponents.
2609
+ #
2610
+ # Construct dimensions with +Dimensions.base+ and compose them with arithmetic.
2611
+ # If importing a map, use +Dimensions.fromPowers+ to remove zero exponents.
2612
+ # This runtime representation does not itself provide static measure typing.
2613
+ record Dimension do
2614
+ powers : {Type: Integer}
2615
+ end
2616
+
2617
+ # The dimension with no remaining base factors.
2618
+ #
2619
+ # @return [Dimension] the multiplicative identity
2620
+ let one -> Dimension = Dimension { powers: {} }
2621
+
2622
+ # Defines a base dimension using the nominal type of a marker value.
2623
+ #
2624
+ # @param marker [A] a value of a dedicated marker type
2625
+ # @return [Dimension] that base dimension, raised to the first power
2626
+ let base(marker: A) -> Dimension = Dimension {
2627
+ powers: {}.put(Type.of(marker), 1)
2628
+ }
2629
+
2630
+ # Normalizes a map of base identities and powers.
2631
+ #
2632
+ # @param powers [Map<Type, Integer>] the dimension's exponents
2633
+ # @return [Dimension] the same dimension with zero exponents removed
2634
+ let fromPowers(powers: {Type: Integer}) -> Dimension = Dimension {
2635
+ powers: powers.filter { |_, exponent| exponent != 0 }
2636
+ }
2637
+
2638
+ make Dimension do
2639
+ # Whether every base factor has cancelled.
2640
+ #
2641
+ # @return [Bool] true for a dimensionless quantity
2642
+ dimensionless? :> Bool
2643
+ let dimensionless? = @powers.values.all? { |exponent| exponent == 0 }
2644
+
2645
+ # The exponent of the supplied marker's dimension, or zero if absent.
2646
+ #
2647
+ # @param marker [A] a value of the base marker type
2648
+ # @return [Integer] the base's exponent
2649
+ exponentOf :> A -> Integer
2650
+ let exponentOf(marker) = @powers.get(Type.of(marker), 0)
2651
+
2652
+ # Composes dimensions by adding their base exponents.
2653
+ #
2654
+ # @param other [Dimension] the other factor
2655
+ # @return [Dimension] the normalized product
2656
+ * :> Dimension -> Dimension
2657
+ let *(other) do
2658
+ let powers = other.powers.entries.reduce(@powers) do |result, entry|
2659
+ let (base, exponent) = entry
2660
+ result.put(base, result.get(base, 0) + exponent)
2661
+ end
2662
+ Dimensions.fromPowers(powers)
2663
+ end
2664
+
2665
+ # Composes dimensions by subtracting the denominator's exponents.
2666
+ #
2667
+ # @param other [Dimension] the denominator's dimension
2668
+ # @return [Dimension] the normalized quotient
2669
+ / :> Dimension -> Dimension
2670
+ let /(other) = this * (other ^ -1)
2671
+
2672
+ # Raises a dimension to an integer power, including zero and negatives.
2673
+ #
2674
+ # @param exponent [Integer] the power
2675
+ # @return [Dimension] the normalized powered dimension
2676
+ ^ :> Integer -> Dimension
2677
+ let ^(exponent) = Dimensions.fromPowers(
2678
+ @powers.mapValues { |power| power * exponent }
2679
+ )
2680
+ end
2450
2681
  # Traversal operations that every foldable collection gets for free.
2451
2682
  #
2452
2683
  # A type becomes +Foldable+ by implementing one method, +reduce+; the rest:
@@ -3565,6 +3796,36 @@ make FileHandle<R, W> do
3565
3796
  # end
3566
3797
  close :> Void
3567
3798
  foul close = Kex.Intrinsic.FileHandle.close(this)
3799
+
3800
+ # Moves the handle's cursor to an absolute byte offset from the start of
3801
+ # the file. Read and write share one cursor, so this repositions both —
3802
+ # a `readLine` right after `seek(0)` starts over from the top, and a
3803
+ # `write` right after does too, overwriting from that point.
3804
+ #
3805
+ # @param offset [Integer] the byte offset to seek to, from the start of
3806
+ # the file
3807
+ # @return [Result<Void, ReadError>] +Ok+ on success, or why the seek
3808
+ # failed
3809
+ #
3810
+ # @example Reading a length-prefixed record, then rewinding past it
3811
+ # let length = handle.readLine.or("0").to(Integer).or(0)
3812
+ # let record = handle.readBytes.try
3813
+ # handle.seek(0)
3814
+ seek : Integer -> Result<Void, ReadError>
3815
+ foul seek(offset) = Kex.Intrinsic.FileHandle.seek(this, offset)
3816
+
3817
+ # Moves the handle's cursor back to the start of the file — the same as
3818
+ # +seek(0)+, for the common case of re-reading a handle from the top.
3819
+ #
3820
+ # @return [Result<Void, ReadError>] +Ok+ on success, or why the reset
3821
+ # failed
3822
+ #
3823
+ # @example Reading a file twice
3824
+ # let firstPass = handle.read.or("")
3825
+ # handle.reset
3826
+ # let secondPass = handle.read.or("")
3827
+ reset :> Result<Void, ReadError>
3828
+ foul reset = Kex.Intrinsic.FileHandle.reset(this)
3568
3829
  end
3569
3830
  # The filesystem: reading and writing files, walking directories, and
3570
3831
  # manipulating paths.
@@ -4009,6 +4270,33 @@ module FS do
4009
4270
  # because it is not lexical: it asks the process where it is.
4010
4271
  absolute : FilePath -> String?
4011
4272
  foul absolute(path) = Kex.Intrinsic.File.absolute(path)
4273
+
4274
+ # The canonical form of +path+: absolute, with every +.+, +..+ and
4275
+ # symlink resolved, like +realpath(3)+.
4276
+ #
4277
+ # Unlike +absolute+ this reads the filesystem, so a path that does not
4278
+ # exist (or a symlink loop) is an error. Use it to keep reads and writes
4279
+ # inside a directory: compare the real path's prefix, and neither +../+
4280
+ # nor a symlink can escape.
4281
+ #
4282
+ # @param path [FilePath] the path to resolve
4283
+ # @return [Result<String, FileError>] the canonical path, or +ReadFailed+
4284
+ #
4285
+ # @example
4286
+ # FS.File.canonical("/tmp/../etc") # => Ok("/private/etc") on macOS
4287
+ canonical : FilePath -> Result<String, FileError>
4288
+ foul canonical(path) = Kex.Intrinsic.File.canonical(path)
4289
+
4290
+ # Whether +path+ is itself a symlink, without following it. A dangling
4291
+ # link is still a symlink.
4292
+ #
4293
+ # @param path [FilePath] the path to inspect
4294
+ # @return [Bool] +true+ for a symlink
4295
+ #
4296
+ # @example
4297
+ # FS.File.symlink?("current") # => true
4298
+ symlink? : FilePath -> Bool
4299
+ foul symlink?(path) = Kex.Intrinsic.File.symlink?(path)
4012
4300
  end
4013
4301
 
4014
4302
  # Path arithmetic: joining, splitting, normalising and comparing paths.
@@ -4855,6 +5143,14 @@ private do
4855
5143
  end
4856
5144
 
4857
5145
  let allowComments = Kex.Intrinsic.Map.getWithDefault(options, allowCommentsKey(), false)
5146
+ # On BEAM, OTP's own decoder builds the same value in a fraction of the
5147
+ # time (kexhq/kex#333). It answers None for anything it will not vouch
5148
+ # for — invalid input, JSONC, the tree-walker — and this parser runs.
5149
+ if !allowComments
5150
+ if let Just(value) = Kex.Intrinsic.Json.decode(text)
5151
+ return Ok(value)
5152
+ end
5153
+ end
4858
5154
  let cursor = skipIgnored(Input { input: text }, allowComments).try
4859
5155
  let (value, afterValue) = parseValue(cursor, allowComments).try
4860
5156
  let rest = skipIgnored(afterValue, allowComments).try
@@ -4889,25 +5185,40 @@ end
4889
5185
  # JSON.parse(JSON.stringify({ a: 1 })) # => Ok({ a: 1 })
4890
5186
  stringify : Any -> String
4891
5187
  let stringify(value: Any) -> String do
4892
- match Kex.Intrinsic.Kex.kind(value) do
4893
- :none => return "null"
4894
- :bool => return value then "true" else "false"
4895
- :integer => return "${value}"
4896
- :float => return "${value}"
4897
- :string => return encodeString(value)
4898
- :list => return "[${value.map(~stringify).join(",")}]"
4899
- :map => do
4900
- let fields = Kex.Intrinsic.Map.entries(value).map do |entry|
4901
- let (key, item) = entry
4902
- "${encodeString(objectKey(key))}:${stringify(item)}"
4903
- end
4904
- return "{${fields.join(",")}}"
4905
- end
4906
- _ => return "null"
5188
+ # The BEAM encoder follows `stringifyValue` rule for rule (kexhq/kex#333);
5189
+ # the tree-walker has none and answers None.
5190
+ if let Just(text) = Kex.Intrinsic.Json.encode(value)
5191
+ return text
4907
5192
  end
5193
+ return stringifyValue(value)
4908
5194
  end
4909
5195
 
4910
5196
  private do
5197
+ let stringifyValue(value: Any) -> String do
5198
+ # An optional is JSON's nullable: `Just(x)` is `x`, as `None` is `null`.
5199
+ # Without this a caller had to unwrap first, and the only spelling that
5200
+ # type-checked was the meaningless `.or(None)`.
5201
+ if let Just(inner) = value
5202
+ return stringifyValue(inner)
5203
+ end
5204
+ match Kex.Intrinsic.Kex.kind(value) do
5205
+ :none => return "null"
5206
+ :bool => return value then "true" else "false"
5207
+ :integer => return "${value}"
5208
+ :float => return "${value}"
5209
+ :string => return encodeString(value)
5210
+ :list => return "[${value.map(~stringifyValue).join(",")}]"
5211
+ :map => do
5212
+ let fields = Kex.Intrinsic.Map.entries(value).map do |entry|
5213
+ let (key, item) = entry
5214
+ "${encodeString(objectKey(key))}:${stringifyValue(item)}"
5215
+ end
5216
+ return "{${fields.join(",")}}"
5217
+ end
5218
+ _ => return "null"
5219
+ end
5220
+ end
5221
+
4911
5222
  # A JSON object key is a string, but a Kex map literal is written with ATOM
4912
5223
  # keys (`{ n: 1 }`): the shape most values being stringified actually have.
4913
5224
  # Rendering one gives `:n`, so drop the leading colon; anything else goes
@@ -5686,6 +5997,144 @@ module Kex.AST do
5686
5997
  parseExpression : String -> Result<Expression, ParseError>
5687
5998
  let parseExpression(source) = Kex.Intrinsic.AST.parseExpression(source)
5688
5999
 
6000
+ # One token of a lossless syntax tree, exactly as written.
6001
+ #
6002
+ # Where +parse+ gives a program's meaning, +parseSyntax+ gives its text:
6003
+ # nothing is normalised or dropped, so +toSource+ reprints the file byte for
6004
+ # byte. It is the tree a formatter or a linter works on (kexhq/kex#136).
6005
+ record SyntaxToken do
6006
+ # The lexer's name for the token: +LowerIdent+, +Newline+, +Eof+, ...
6007
+ kind : String
6008
+
6009
+ # The token exactly as written, quotes, escapes and underscores included.
6010
+ text : String
6011
+
6012
+ # Everything between the previous token and this one: spaces, tabs and
6013
+ # comments. A comment on a line of its own sits in front of the +Newline+
6014
+ # that ends that line; whatever follows the last token belongs to +Eof+.
6015
+ trivia : String
6016
+ end
6017
+
6018
+ # One child of a +SyntaxNode+: a token, or a nested node.
6019
+ type SyntaxElement = TokenElement(SyntaxToken) | NodeElement(SyntaxNode)
6020
+
6021
+ # A declaration, expression, pattern or type, holding its own tokens and
6022
+ # nested nodes in source order.
6023
+ #
6024
+ # let tree = Kex.AST.parseSyntax("# the answer\nlet x = 42\n").try
6025
+ # tree.kind # => "Program"
6026
+ # Kex.AST.toSource(tree) # => "# the answer\nlet x = 42\n"
6027
+ record SyntaxNode do
6028
+ # What the parser built there: +FunctionDef+, +MethodCall+, +ListPattern+,
6029
+ # ... The root is +Program+.
6030
+ kind : String
6031
+
6032
+ # The node's tokens and nested nodes, in source order.
6033
+ children : [SyntaxElement]
6034
+ end
6035
+
6036
+ # Parses Kex source into its lossless syntax tree.
6037
+ #
6038
+ # @param source [String] the Kex source text
6039
+ # @return [Result<SyntaxNode, ParseError>] the tree rooted at +Program+, or why it failed
6040
+ #
6041
+ # @example
6042
+ # Kex.AST.parseSyntax("let x = 1\n").map { |tree| tree.kind } # => Ok("Program")
6043
+ # Kex.AST.parseSyntax("let x =").error? # => true
6044
+ parseSyntax : String -> Result<SyntaxNode, ParseError>
6045
+ let parseSyntax(source) = Kex.Intrinsic.AST.parseSyntax(source)
6046
+
6047
+ # Reprints a syntax tree as the source it was parsed from, byte for byte.
6048
+ #
6049
+ # Every node prints its own tokens and its nested nodes in order, never a
6050
+ # slice of the original text, so a rearranged tree prints the rearranged
6051
+ # program, its comments moving with it.
6052
+ #
6053
+ # @param node [SyntaxNode] a tree, or any node in one
6054
+ # @return [String] the source the node covers, trivia included
6055
+ #
6056
+ # @example
6057
+ # let source = "let x = 1 # one\n"
6058
+ # Kex.AST.parseSyntax(source).map { |tree| Kex.AST.toSource(tree) } # => Ok(source)
6059
+ toSource : SyntaxNode -> String
6060
+ let toSource(node) = node.children.map { |child| Kex.AST.elementSource(child) }.join("")
6061
+
6062
+ # The source of one child of a node: a token's trivia and text, or a nested
6063
+ # node reprinted.
6064
+ #
6065
+ # @param element [SyntaxElement] the child
6066
+ # @return [String] its source text
6067
+ elementSource : SyntaxElement -> String
6068
+ let elementSource(element) = match element do
6069
+ TokenElement(token) => token.trivia + token.text
6070
+ NodeElement(child) => Kex.AST.toSource(child)
6071
+ end
6072
+
6073
+ # The comments written on their own lines directly above the child at
6074
+ # +index+: the ones that belong to it, move with it when a formatter moves
6075
+ # it, and hold a `# kex:disable-next-line` meant for it.
6076
+ #
6077
+ # A comment at the end of the previous line trails that line instead, and is
6078
+ # not included.
6079
+ #
6080
+ # @param parent [SyntaxNode] the node holding the child
6081
+ # @param index [Integer] the child's position in +parent.children+
6082
+ # @return [[String]] the comment lines, top to bottom, each starting with +#+
6083
+ #
6084
+ # @example
6085
+ # let tree = Kex.AST.parseSyntax("let x = 1 # x\n# about y\nlet y = 2\n").try
6086
+ # Kex.AST.commentsBefore(tree, 3) # => ["# about y"]
6087
+ commentsBefore : SyntaxNode -> Integer -> [String]
6088
+ let commentsBefore(parent, index) do
6089
+ var comments: [String] = []
6090
+ Kex.AST.linesBefore(parent, index).each do |line|
6091
+ let text = line.trim
6092
+ comments = comments + [text] if text.startsWith?("#")
6093
+ end
6094
+ return comments
6095
+ end
6096
+
6097
+ # How many blank lines separate the child at +index+ from what precedes it.
6098
+ #
6099
+ # Kept as a count, not a flag: a formatter preserves one blank line between
6100
+ # declarations and collapses longer runs, which needs to know how many there
6101
+ # were.
6102
+ #
6103
+ # @param parent [SyntaxNode] the node holding the child
6104
+ # @param index [Integer] the child's position in +parent.children+
6105
+ # @return [Integer] the number of blank lines directly above the child
6106
+ #
6107
+ # @example
6108
+ # let tree = Kex.AST.parseSyntax("let x = 1\n\n\nlet y = 2\n").try
6109
+ # Kex.AST.blankLinesBefore(tree, 4) # => 2
6110
+ blankLinesBefore : SyntaxNode -> Integer -> Integer
6111
+ let blankLinesBefore(parent, index) = Kex.AST.linesBefore(parent, index).filter { |line| line.trim.empty? }.count
6112
+
6113
+ # The whole lines between the child at +index+ and the code before it, as
6114
+ # the trivia of the +Newline+ tokens that end them. The newline that ends the
6115
+ # previous line of code is not one of them: what sits in front of it trails
6116
+ # that code.
6117
+ #
6118
+ # @param parent [SyntaxNode] the node holding the child
6119
+ # @param index [Integer] the child's position in +parent.children+
6120
+ # @return [[String]] the lines, top to bottom, without their newlines
6121
+ linesBefore : SyntaxNode -> Integer -> [String]
6122
+ let linesBefore(parent, index) do
6123
+ var position = index - 1
6124
+ var lines: [String] = []
6125
+ var scanning = true
6126
+ while scanning && position >= 0 do
6127
+ match parent.children.at(position) do
6128
+ Just(TokenElement(token)) when token.kind == "Newline" => do
6129
+ lines = [token.trivia] + lines
6130
+ position = position - 1
6131
+ end
6132
+ _ => scanning = false
6133
+ end
6134
+ end
6135
+ return position >= 0 then lines.drop(1) else lines
6136
+ end
6137
+
5689
6138
  # A type as it was written in source.
5690
6139
  #
5691
6140
  # +typeRefText+ renders one back to the source spelling.
@@ -6246,6 +6695,17 @@ make [Number] do
6246
6695
  let max = Kex.Intrinsic.List.max(this)
6247
6696
  end
6248
6697
 
6698
+ make [[Y]] do
6699
+ # Flattens exactly one level of nesting.
6700
+ #
6701
+ # @return [[Y]]
6702
+ #
6703
+ # @example
6704
+ # [[1, 2], [3, 4]].flatten # => [1, 2, 3, 4]
6705
+ flatten :> [Y]
6706
+ let flatten = Kex.Intrinsic.List.flatten(this)
6707
+ end
6708
+
6249
6709
  make [X], implement: Enumerable, Foldable do
6250
6710
  # Returns the first element wrapped in +Just+, or +None+ if the list is empty.
6251
6711
  #
@@ -6638,15 +7098,6 @@ make [X], implement: Enumerable, Foldable do
6638
7098
  zip :> [Y] -> [(X, Y)]
6639
7099
  let zip(other) = Kex.Intrinsic.List.zip(this, other)
6640
7100
 
6641
- # Flattens exactly one level of nesting. Only valid on lists of lists.
6642
- #
6643
- # @return [[Y]]
6644
- #
6645
- # @example
6646
- # [[1, 2], [3, 4]].flatten # => [1, 2, 3, 4]
6647
- flatten :> [X]
6648
- let flatten = Kex.Intrinsic.List.flatten(this)
6649
-
6650
7101
  # Returns the elements sorted in ascending natural order.
6651
7102
  #
6652
7103
  # @return [[X]]
@@ -7830,6 +8281,11 @@ module Mock do
7830
8281
  foul file?(path) = this.cannedRead(path) != None
7831
8282
  foul directory?(path) = false
7832
8283
  foul absolute(path) = Just(path)
8284
+ foul canonical(path) = match this.cannedRead(path) do
8285
+ Just(_) => Ok(path)
8286
+ None => Error(ReadFailed(path))
8287
+ end
8288
+ foul symlink?(path) = false
7833
8289
 
7834
8290
  # A fake is a value, so there is nowhere for a write to go. Refusing is
7835
8291
  # the honest answer and the useful one: a test that did not expect a
@@ -8243,6 +8699,18 @@ module Net.HTTP
8243
8699
 
8244
8700
  # An insertion-ordered HTTP field collection. Names compare case-insensitively
8245
8701
  # and duplicate fields are preserved.
8702
+ #
8703
+ # A list of pairs rather than a +{String: String}+ map, and deliberately so.
8704
+ # A map cannot hold the same name twice, and +Set-Cookie+ needs exactly that:
8705
+ # RFC 6265 does not define it as a comma-separated list, so two cookies must
8706
+ # travel as two fields and cannot be joined into one. A map interface would
8707
+ # read as the obvious one right up to the first response that sets two
8708
+ # cookies, then silently keep one — the same class of quiet data loss this
8709
+ # module's +Result+-returning builders exist to avoid.
8710
+ #
8711
+ # Order is kept for the same reason. RFC 9110 makes order insignificant
8712
+ # BETWEEN different names but significant between fields sharing a name, and
8713
+ # a map has no order to keep.
8246
8714
  record Headers do
8247
8715
  entries : [(String, String)]
8248
8716
  end
@@ -8345,8 +8813,8 @@ module Headers do
8345
8813
  #
8346
8814
  # @example Building request headers immutably
8347
8815
  # Headers.empty
8348
- # .add("Accept", "application/json")
8349
- # .add("User-Agent", "inventory-sync/1.0")
8816
+ # .add("Accept", "application/json").try
8817
+ # .add("User-Agent", "inventory-sync/1.0").try
8350
8818
  empty : Headers
8351
8819
  let empty = Headers { entries: [] }
8352
8820
 
@@ -8366,6 +8834,22 @@ module Headers do
8366
8834
  from : [(String, String)] -> Result<Headers, NetError>
8367
8835
  let from(entries) = Kex.Intrinsic.NetHTTP.headers(entries)
8368
8836
 
8837
+ # Validates header names and values from unordered pairs, for the common
8838
+ # case where every name is used once. Do not use this for `Set-Cookie` or
8839
+ # any other field that legitimately repeats — a `Map` cannot hold a name
8840
+ # twice, so unlike the list form above, a second value for the same name
8841
+ # here does not add a second field; it silently replaces the first (see
8842
+ # this module's own +Headers+ record doc for why that specific field
8843
+ # cannot be folded into one entry). Reach for the ordered list form for
8844
+ # anything that might repeat a name.
8845
+ #
8846
+ # @return [Result<Headers, NetError>] validated fields, or +Parse+
8847
+ #
8848
+ # @example Forwarding a set of request headers that are each sent once
8849
+ # Headers.from({ "Accept": "application/json", "X-Request-ID": requestId }).try
8850
+ from : Map<String, String> -> Result<Headers, NetError>
8851
+ let from(entries: Map<String, String>) = Kex.Intrinsic.NetHTTP.headers(entries.entries)
8852
+
8369
8853
  # Parses CRLF- or LF-separated header fields.
8370
8854
  #
8371
8855
  # Use this at a protocol boundary when headers arrive as text. Application
@@ -8398,7 +8882,7 @@ module Response do
8398
8882
  # Builds a buffered binary response with validated headers.
8399
8883
  #
8400
8884
  # @example Returning a downloaded file without decoding it as text
8401
- # Response.binary(200, archive, Headers.empty.set("Content-Type", "application/zip"))
8885
+ # Response.binary(200, archive, Headers.empty.add("Content-Type", "application/zip").try)
8402
8886
  foul binary(status: Integer, body: Binary, headers: Headers) -> Response<Binary> = Kex.Intrinsic.NetHTTP.responseBinary(status, body, headers)
8403
8887
  # Builds a UTF-8 text response with an explicit text/plain content type.
8404
8888
  #
@@ -8524,8 +9008,8 @@ make Client do
8524
9008
  #
8525
9009
  # @example Sending JSON with an idempotency key
8526
9010
  # let headers = Headers.empty
8527
- # .set("Content-Type", "application/json")
8528
- # .set("Idempotency-Key", requestId)
9011
+ # .add("Content-Type", "application/json").try
9012
+ # .add("Idempotency-Key", requestId).try
8529
9013
  # client.request("POST", url, headers, JSON.stringify(order).to(Binary).try)
8530
9014
  foul request(method: String, url: String, headers: Headers, body: Binary) -> Result<Response<Binary>, NetError> = Kex.Intrinsic.NetHTTPClient.request(this, method, url, headers, body)
8531
9015
  # Sends a buffered GET request.
@@ -8557,12 +9041,27 @@ make Client do
8557
9041
  end
8558
9042
 
8559
9043
  make Headers, implement: Showable, Inspectable do
8560
- # Appends a field without replacing existing fields of the same name.
8561
- # @example +Headers.empty.add("Accept", "text/plain")+
8562
- let add(name: String, value: String) -> Headers = Kex.Intrinsic.NetHTTP.addHeader(this, name, value)
8563
- # Replaces all fields of +name+ with one value.
8564
- # @example +headers.set("Content-Type", "application/json")+
8565
- let set(name: String, value: String) -> Headers = Kex.Intrinsic.NetHTTP.setHeader(this, name, value)
9044
+ # Appends a field, keeping existing fields of the same name.
9045
+ #
9046
+ # Repeats are how +Set-Cookie+ works: it is not a comma-separated list, so
9047
+ # two cookies must be two fields. For replace-semantics, +remove+ first.
9048
+ #
9049
+ # An invalid name or value is an +Error+, not a silent drop. Rejecting
9050
+ # +"a\r\nX: y"+ is what stops response splitting, but dropping it quietly
9051
+ # left the caller holding a valid +Headers+ that simply lacked the field it
9052
+ # asked for, and a response with no +Content-Type+ invites MIME sniffing.
9053
+ # +from+ and +parse+ already answer with a +Result+ for this same input.
9054
+ #
9055
+ # @return [Result<Headers, NetError>] the extended fields, or +Parse+
9056
+ #
9057
+ # @example Two cookies on one response
9058
+ # Headers.empty
9059
+ # .add("Set-Cookie", "session=abc; HttpOnly").try
9060
+ # .add("Set-Cookie", "theme=dark").try
9061
+ #
9062
+ # @example Replacing a field
9063
+ # headers.remove("Content-Type").add("Content-Type", "application/json").try
9064
+ let add(name: String, value: String) -> Result<Headers, NetError> = Kex.Intrinsic.NetHTTP.addHeader(this, name, value)
8566
9065
  # Removes every field matching +name+ case-insensitively.
8567
9066
  #
8568
9067
  # @example Stripping hop-by-hop state before forwarding
@@ -8619,7 +9118,7 @@ module HTTP do
8619
9118
  # occasional requests; use +Client+ for a service making repeated calls.
8620
9119
  #
8621
9120
  # @example A one-off authenticated request in a command-line tool
8622
- # let headers = Headers.empty.set("Authorization", "Bearer ${token}")
9121
+ # let headers = Headers.empty.add("Authorization", "Bearer ${token}").try
8623
9122
  # HTTP.request("GET", url, headers).try
8624
9123
  request : String -> String -> Headers -> Binary -> Result<Response<Binary>, NetError>
8625
9124
  let request(method, url, headers = Headers.empty, body = Binary.empty) = Kex.Intrinsic.NetHTTP.request(method, url, headers, body)
@@ -8669,6 +9168,8 @@ end
8669
9168
  # socket.close
8670
9169
  module Net.HTTP.WebSocket
8671
9170
 
9171
+ using Net.HTTP
9172
+
8672
9173
  # A complete high-level WebSocket message. Fragmentation and ping/pong control
8673
9174
  # frames are handled by the connection runtime.
8674
9175
  #
@@ -8692,10 +9193,31 @@ record Session do
8692
9193
  subprotocol : String?
8693
9194
  end
8694
9195
 
8695
- # An opaque RFC 6455 client connection. It does not reconnect automatically.
9196
+ # An opaque RFC 6455 connection, client- or server-side. It does not
9197
+ # reconnect automatically.
8696
9198
  type Connection
8697
9199
 
8698
- # Constructors for high-level WebSocket client connections.
9200
+ # Subprotocols offered by an incoming upgrade request, in the order the peer
9201
+ # listed them.
9202
+ record Handshake do
9203
+ subprotocols : [String] = []
9204
+ end
9205
+
9206
+ # A server's decision after inspecting a +Handshake+.
9207
+ #
9208
+ # +Accept+ takes over the connection once the 101 response is sent: +handler+
9209
+ # runs with the negotiated server +Connection+, and its return value is
9210
+ # discarded. +headers+ are added to the 101 response; a name that manages the
9211
+ # handshake itself (+Upgrade+, +Connection+, +Sec-WebSocket-Accept+,
9212
+ # +Sec-WebSocket-Protocol+) is dropped rather than overridden. +subprotocol+
9213
+ # must be one +handshake.subprotocols+ actually offered, or +None+.
9214
+ #
9215
+ # +Reject+ answers with an ordinary buffered response instead, leaving the
9216
+ # connection as plain HTTP — a client requesting an unsupported subprotocol
9217
+ # might get +Response.text(426, "chat.v2 required")+, for instance.
9218
+ type Upgrade = Accept(Connection -> Void, Headers, String?) | Reject(Response<Binary>)
9219
+
9220
+ # Constructors for high-level WebSocket connections, client- and server-side.
8699
9221
  module WebSocket do
8700
9222
  # Opens a +ws:+ or verified +wss:+ connection with default options.
8701
9223
  #
@@ -8720,6 +9242,32 @@ module WebSocket do
8720
9242
  # maximumMessageBytes: 1024 * 1024
8721
9243
  # }).try
8722
9244
  foul connect(url: String, options: ClientOptions) -> Result<Connection, NetError> = Kex.Intrinsic.NetWebSocket.connect(url, options)
9245
+
9246
+ # Decides whether to accept an incoming +Net.HTTP.Server+ upgrade request.
9247
+ #
9248
+ # Call from a route handler and return the result directly — it types as
9249
+ # an ordinary +Response<Binary>+, and +Net.HTTP.Server+ recognizes what it
9250
+ # actually is: a request that isn't a syntactically valid WebSocket
9251
+ # handshake at all (wrong method, missing +Sec-WebSocket-Key+, unsupported
9252
+ # +Sec-WebSocket-Version+) is answered automatically without calling
9253
+ # +decide+; a valid one reaches +decide+ for an application decision.
9254
+ #
9255
+ # @param request [Request<Binary>] the route handler's own request
9256
+ # @param decide [Handshake -> Upgrade] the accept/reject decision
9257
+ # @return [Response<Binary>] the handler's response — an upgrade in
9258
+ # disguise on +Accept+, sent as given on +Reject+
9259
+ #
9260
+ # @example An authenticated, subprotocol-gated chat route
9261
+ # foul socketRoute(request: Request<Binary>, context: Context) -> Response<Binary> = WebSocket.upgrade(request) do |handshake|
9262
+ # if handshake.subprotocols.contains?("chat.v2")
9263
+ # let handler : Connection -> Void = { |socket| serveChat(socket) }
9264
+ # Accept(handler, Headers.empty, Just("chat.v2"))
9265
+ # else
9266
+ # Reject(Response.text(426, "chat.v2 required"))
9267
+ # end
9268
+ # end
9269
+ # let router = Router.build.get("/socket", ~socketRoute)
9270
+ foul upgrade(request: Request<Binary>, decide: Handshake -> Upgrade) -> Response<Binary> = Kex.Intrinsic.NetWebSocket.upgrade(request, decide)
8723
9271
  end
8724
9272
 
8725
9273
  make Connection do
@@ -8748,6 +9296,32 @@ make Connection do
8748
9296
  # end
8749
9297
  # end
8750
9298
  foul receiveMessage() -> Result<Message, NetError> = Kex.Intrinsic.NetWebSocket.receiveMessage(this)
9299
+ # `receiveMessage`, but the deadline is explicit rather than implicit in
9300
+ # whether you passed an argument at all: +None+ waits exactly as long as
9301
+ # a bare +receiveMessage+ does (as long as the peer stays connected —
9302
+ # routinely indefinitely, for a WebSocket left open with nothing to say),
9303
+ # and +Just(duration)+ gives up and answers +Timeout+ once +duration+
9304
+ # elapses without a message.
9305
+ #
9306
+ # A real disconnect is still reported as +Closed+, not +Timeout+: the two
9307
+ # are distinguishable, unlike a bare +receiveMessage+ that assumes a
9308
+ # failed read always means the peer is gone (kexhq/kex#381).
9309
+ #
9310
+ # @param timeout [Duration?] how long to wait before giving up, or +None+
9311
+ # to wait as long as the peer stays connected
9312
+ # @return [Result<Message, NetError>] the next data or close message, or
9313
+ # +Timeout+ if none arrived in time
9314
+ #
9315
+ # @example Prompting an otherwise-quiet client every 30 seconds
9316
+ # match connection.receiveMessage(timeout: Just(30.seconds)).try do
9317
+ # Text(text) => handleEvent(text)
9318
+ # _ => sendPing(connection)
9319
+ # end
9320
+ #
9321
+ # @example Being explicit that a wait has no deadline, rather than relying
9322
+ # on the zero-argument form's default
9323
+ # connection.receiveMessage(timeout: None)
9324
+ foul receiveMessage(timeout: Duration?) -> Result<Message, NetError> = Kex.Intrinsic.NetWebSocket.receiveMessageWithin(this, timeout)
8751
9325
  # Returns handshake details negotiated with the server.
8752
9326
  #
8753
9327
  # @return [Session] the selected subprotocol, if any
@@ -9473,7 +10047,13 @@ make Integer do
9473
10047
  #
9474
10048
  # @example Repeating an action
9475
10049
  # retries.times { |_| attemptConnection }
10050
+ #
10051
+ # @example Repeating an action that needs no index
10052
+ # 3.times do
10053
+ # IO.printLine("hi")
10054
+ # end
9476
10055
  times :> (Integer -> Void) -> Void
10056
+ times :> Block<Void> -> Void
9477
10057
  let times(block) = Kex.Intrinsic.Integer.times(this, block)
9478
10058
 
9479
10059
  # Returns the integer unchanged. Present so that code written against
@@ -9748,6 +10328,7 @@ end
9748
10328
  # empty. Most of the time that is a single +.or(default)+ at the end of a
9749
10329
  # chain.
9750
10330
  #
10331
+ # @example
9751
10332
  # let names = ["ada", "grace"]
9752
10333
  # names.first.or("nobody") # => "ada"
9753
10334
  # names.at(9).or("nobody") # => "nobody"
@@ -9755,6 +10336,7 @@ end
9755
10336
  #
9756
10337
  # Pattern matching handles the cases that need more than a default:
9757
10338
  #
10339
+ # @example
9758
10340
  # match config.get("port") do
9759
10341
  # Just(port) => IO.printLine("listening on ${port}")
9760
10342
  # None => IO.printLine("no port configured")
@@ -9767,7 +10349,7 @@ type Optional<X> = Just(X) | None
9767
10349
  # Use +Result+ over +Optional+ when the *reason* for failure matters to the
9768
10350
  # caller. Parsing is the standard example: +"12x".to(Integer)+ answers +None+,
9769
10351
  # while +Integer.parse("12x")+ answers an +Error+ that says where it stopped.
9770
- #
10352
+ # @example
9771
10353
  # Integer.parse("42").or(0) # => 42
9772
10354
  # Integer.parse("4x").or(0) # => 0
9773
10355
  #
@@ -9782,6 +10364,7 @@ type Result<X, E> = Ok(X) | Error(E)
9782
10364
  # Unlike +Result+, neither side means failure: +Either+ is for a value that is
9783
10365
  # legitimately one of two shapes.
9784
10366
  #
10367
+ # @example
9785
10368
  # type Id = Either<Integer, String>
9786
10369
  #
9787
10370
  # let describe(id: Id) -> String do
@@ -9792,19 +10375,85 @@ type Result<X, E> = Ok(X) | Error(E)
9792
10375
  # end
9793
10376
  type Either<L, R> = Left(L) | Right(R)
9794
10377
 
9795
- # Marker trait for +Optional+. Constrain a generic parameter with it when a
10378
+ # A value that may be absent. Constrain a generic parameter with it when a
9796
10379
  # function accepts any optional value.
10380
+ #
10381
+ # The two operations below are what make the constraint worth having: an
10382
+ # empty trait would accept a value and then let you do nothing with it, since
10383
+ # there would be no method to call. +map+ is deliberately NOT required — its
10384
+ # result type differs per implementer (+Y?+ here, +Result<Y, E>+ for
10385
+ # +Resultable+), which needs a higher-kinded parameter Kex does not have.
9797
10386
  trait Optionable do
10387
+ # Answers +true+ when a value is present.
10388
+ #
10389
+ # The one operation an +Optionable+ type must define; +none?+ is its
10390
+ # negation.
10391
+ #
10392
+ # @return [Bool] +true+ for a present value
10393
+ #
10394
+ # @example
10395
+ # Just(1).set? # => true
10396
+ # None.set? # => false
10397
+ set? :> Bool
10398
+
10399
+ # Returns the wrapped value, or +default+ when there is none.
10400
+ #
10401
+ # The way out of the optional world, and the reason a constrained parameter
10402
+ # is usable at all.
10403
+ #
10404
+ # @param default [X] the value to use when absent
10405
+ # @return [X] the wrapped value, or +default+
10406
+ #
10407
+ # @example
10408
+ # Just(42).or(0) # => 42
10409
+ # None.or(0) # => 0
10410
+ or :> X -> X
9798
10411
  end
9799
10412
 
9800
- # Marker trait for +Result+. Constrain a generic parameter with it when a
9801
- # function accepts any result value.
10413
+ # A value that either succeeded or failed with a reason. Constrain a generic
10414
+ # parameter with it when a function accepts any result value.
9802
10415
  trait Resultable do
10416
+ # Answers +true+ for a success.
10417
+ #
10418
+ # The one operation a +Resultable+ type must define; +error?+ is its
10419
+ # negation.
10420
+ #
10421
+ # @return [Bool] +true+ for +Ok+
10422
+ #
10423
+ # @example
10424
+ # Ok(1).ok? # => true
10425
+ # Error("!").ok? # => false
10426
+ ok? :> Bool
10427
+
10428
+ # Returns the success value, or +default+ on failure.
10429
+ #
10430
+ # @param default [X] the value to use on failure
10431
+ # @return [X] the +Ok+ value, or +default+
10432
+ #
10433
+ # @example
10434
+ # Ok(42).or(0) # => 42
10435
+ # Error("!").or(0) # => 0
10436
+ or :> X -> X
9803
10437
  end
9804
10438
 
9805
- # Marker trait for +Either+. Constrain a generic parameter with it when a
9806
- # function accepts any either value.
10439
+ # One of two values, neither meaning failure. Constrain a generic parameter
10440
+ # with it when a function accepts any either value.
9807
10441
  trait Eitherable do
10442
+ # Case analysis: applies +onLeft+ to a +Left+ and +onRight+ to a +Right+.
10443
+ #
10444
+ # The one operation an +Eitherable+ type must define — +left?+ and +right?+
10445
+ # are both written in terms of it. A discriminator alone would let you ask
10446
+ # which side a value is on without being able to reach it, which is why this
10447
+ # is the requirement rather than those.
10448
+ #
10449
+ # @param onLeft [L -> A] applied to a +Left+
10450
+ # @param onRight [R -> A] applied to a +Right+
10451
+ # @return [A] whichever branch ran
10452
+ #
10453
+ # @example
10454
+ # Left(2).either(~toString, ~upperCase) # => "2"
10455
+ # Right("ok").either(~toString, ~upperCase) # => "OK"
10456
+ either :> (L -> A) -> (R -> A) -> A
9808
10457
  end
9809
10458
 
9810
10459
  make Optional<X>, implement: Optionable do
@@ -10003,21 +10652,6 @@ make Result<X, E>, implement: Resultable do
10003
10652
  let optional(@Error(_)) = None
10004
10653
  end
10005
10654
 
10006
- # Returns the value unchanged.
10007
- #
10008
- # The catch-all clause of +or+: a value that is neither an +Optional+ nor a
10009
- # +Result+ has already succeeded, so there is nothing to fall back to. This is
10010
- # what lets +.or(default)+ be written after a call whose return type may later
10011
- # stop being optional, without the call site changing.
10012
- #
10013
- # @param value [A] any plain value
10014
- # @return [A] the same value
10015
- #
10016
- # @example
10017
- # 42.or(0) # => 42
10018
- # "text".or("") # => "text"
10019
- let or(value, _) = value
10020
-
10021
10655
  # Converts +value+ to the type +t+, or +None+ if it cannot be represented.
10022
10656
  #
10023
10657
  # +t+ is a runtime type value: write the type name itself: +String+,
@@ -10065,7 +10699,50 @@ let to(value, t) = Kex.Intrinsic.Fun.convertTo(value, t)
10065
10699
  # "ff".to(Integer, radix: 10) # => None
10066
10700
  let to(value, t, radix: Integer) = Kex.Intrinsic.Fun.convertTo(value, t, radix)
10067
10701
 
10068
- make Either<L, R>, implement: Eitherable do
10702
+ make Either<L, R>, implement: Eitherable do
10703
+ # Case analysis: applies +onLeft+ to a +Left+ and +onRight+ to a +Right+.
10704
+ #
10705
+ # The way an +Either+ is consumed without a +match+, and the operation
10706
+ # +Eitherable+ requires. Both branches answer the same type, so the result
10707
+ # is a plain value rather than another +Either+.
10708
+ #
10709
+ # @param onLeft [L -> A] applied to a +Left+
10710
+ # @param onRight [R -> A] applied to a +Right+
10711
+ # @return [A] whichever branch ran
10712
+ #
10713
+ # @example
10714
+ # Left(2).either(~toString, ~upperCase) # => "2"
10715
+ # Right("ok").either(~toString, ~upperCase) # => "OK"
10716
+ #
10717
+ # @example Collapsing an id to text
10718
+ # let render(id: Either<Integer, String>) -> String do
10719
+ # id.either({ |n| "#${n}" }, { |slug| slug })
10720
+ # end
10721
+ either :> (L -> A) -> (R -> A) -> A
10722
+ let either(@Left(l), onLeft, _) = onLeft(l)
10723
+ let either(@Right(r), _, onRight) = onRight(r)
10724
+
10725
+ # Returns +true+ for a +Left+.
10726
+ #
10727
+ # @return [Bool]
10728
+ #
10729
+ # @example
10730
+ # Left(1).left? # => true
10731
+ # Right(1).left? # => false
10732
+ left? :> Bool
10733
+ let left?(@Left(_)) = true
10734
+ let left?(@Right(_)) = false
10735
+
10736
+ # Returns +true+ for a +Right+. The opposite of +left?+.
10737
+ #
10738
+ # @return [Bool]
10739
+ #
10740
+ # @example
10741
+ # Right(1).right? # => true
10742
+ # Left(1).right? # => false
10743
+ right? :> Bool
10744
+ let right?(@Left(_)) = false
10745
+ let right?(@Right(_)) = true
10069
10746
  end
10070
10747
  # Declarative command-line parsing, shared by Kex tools and applications.
10071
10748
  #
@@ -10144,6 +10821,15 @@ record CommandSpec do
10144
10821
  # What to run when this command is named.
10145
10822
  handler : CommandHandler
10146
10823
 
10824
+ # Whether the command parses its own options. A passthrough command owns
10825
+ # every token after its name: the outer parser stops matching options
10826
+ # against its OWN declarations and forwards them verbatim in `arguments`,
10827
+ # the way everything after `--` is forwarded. Without this a subcommand
10828
+ # that runs its own OptionParser can only receive the option names the
10829
+ # outer tool does not itself declare, and a collision is silent — the
10830
+ # outer tool consumes the value and the subcommand sees nothing.
10831
+ passthrough : Bool = false
10832
+
10147
10833
  # The help heading this command is listed under. "" is the tool's own
10148
10834
  # `Commands:` block; anything else gets its own heading, in the order the
10149
10835
  # sections were first declared. Commands a tool discovers at runtime: a
@@ -10393,6 +11079,31 @@ make OptionConfig do
10393
11079
  New { commands: [...@commands, spec] }
10394
11080
  end
10395
11081
 
11082
+ # Declares a command that parses its own options.
11083
+ #
11084
+ # Everything after the command's name is handed to the handler in
11085
+ # +arguments+ untouched, including options this tool also declares. Use it
11086
+ # for a subcommand backed by its own +OptionConfig+: without it, an option
11087
+ # the outer tool happens to declare too is consumed here and never reaches
11088
+ # the subcommand, with no error to say so.
11089
+ #
11090
+ # @param name [String] the command's name, one or more words
11091
+ # @param usage [String] the argument shape shown in the help text
11092
+ # @param description [String] the help-text description
11093
+ # @param handler [CommandHandler] what to run
11094
+ # @return [OptionConfig] the config, with the command added
11095
+ #
11096
+ # @example
11097
+ # config.passthroughCommand("docs", "<build|serve>",
11098
+ # "generate documentation", ~docs)
11099
+ let passthroughCommand(name: String, usage: String, description: String,
11100
+ handler: CommandHandler) -> OptionConfig do
11101
+ let spec = CommandSpec { name: name, description: description,
11102
+ usage: usage, handler: handler,
11103
+ passthrough: true }
11104
+ New { commands: [...@commands, spec] }
11105
+ end
11106
+
10396
11107
  # Parses +args+ into option values and leftover words, without dispatching
10397
11108
  # to a command.
10398
11109
  #
@@ -10588,6 +11299,28 @@ module OptionParser do
10588
11299
  end
10589
11300
  end
10590
11301
 
11302
+ # Whether the command +arguments+ names parses its own options.
11303
+ #
11304
+ # +false+ when no command matches yet, so options before any command word
11305
+ # are still this parser's to claim.
11306
+ #
11307
+ # @param commands [[CommandSpec]] the declared commands
11308
+ # @param arguments [[String]] the positional words seen so far
11309
+ # @return [Bool] +true+ when a matched command is passthrough
11310
+ #
11311
+ # @example
11312
+ # OptionParser.passthrough?(commands, ["docs", "build"]) # => true
11313
+ # OptionParser.passthrough?(commands, []) # => false
11314
+ let passthrough?(commands: [CommandSpec], arguments: [String]) -> Bool do
11315
+ match OptionParser.commandFor(commands, arguments) do
11316
+ None => false
11317
+ Just(pair) => do
11318
+ let (command, _) = pair
11319
+ return command.passthrough
11320
+ end
11321
+ end
11322
+ end
11323
+
10591
11324
  # Returns +true+ when +arguments+ begins with the words of +name+.
10592
11325
  #
10593
11326
  # The word-wise prefix test +commandFor+ matches with: +"docs build"+ opens
@@ -10682,6 +11415,15 @@ module OptionParser do
10682
11415
  return OptionParser.walk(options, commands, rest, values, [...arguments, argument], false)
10683
11416
  end
10684
11417
 
11418
+ # A passthrough command owns the rest of the line. Once its words are
11419
+ # in `arguments`, every option belongs to ITS vocabulary, so none is
11420
+ # matched against this tool's declarations — otherwise a name both
11421
+ # parsers declare (`tey --package` and `tey docs --package`) is eaten
11422
+ # here and the subcommand silently never sees it.
11423
+ if OptionParser.passthrough?(commands, arguments)
11424
+ return OptionParser.walk(options, commands, rest, values, [...arguments, argument], false)
11425
+ end
11426
+
10685
11427
  let equals = argument.startsWith?("--") then argument.split("=") else [argument]
10686
11428
  let spelling = equals.first.or(argument)
10687
11429
  match OptionParser.findOption(options, spelling) do
@@ -11082,6 +11824,7 @@ end
11082
11824
  using Algebra
11083
11825
  using Binary
11084
11826
  using Blankable
11827
+ using Comparable
11085
11828
  using Console
11086
11829
  using Enumerable
11087
11830
  using Env
@@ -11269,6 +12012,32 @@ module Process do
11269
12012
  run : String -> [String] -> Result<ProcessResult, String>
11270
12013
  foul run(command, args) = Kex.Intrinsic.Process.run(command, args)
11271
12014
 
12015
+ # Runs an executable with a time budget, and kills it if it overruns.
12016
+ #
12017
+ # Everything +run+ does, plus a deadline: when +timeoutMs+ milliseconds
12018
+ # pass with the child still running, the child's process group is sent
12019
+ # SIGTERM (then SIGKILL after a short grace) and reaped, and the call
12020
+ # answers a timeout Error rather than the child's output. Signalling the
12021
+ # group means forked grandchildren die with it. The budget bounds the
12022
+ # whole call, so a hung child cannot hang the caller.
12023
+ #
12024
+ # A child that finishes in time answers exactly as +run+ would; a missing
12025
+ # program still reports +executable not found+ without waiting.
12026
+ #
12027
+ # @param command [String] the executable to run
12028
+ # @param args [[String]] its arguments, one per element
12029
+ # @param timeoutMs [Integer] milliseconds before the child is killed
12030
+ # @return [Result<ProcessResult, String>] the captured result, or why it
12031
+ # could not start — or that the budget ran out
12032
+ #
12033
+ # @example
12034
+ # match Process.run("sleep", ["30"], 300) do
12035
+ # Error(error) => IO.printLine(error) # prints: timed out after 300ms
12036
+ # Ok(result) => IO.printLine(result.exitCode)
12037
+ # end
12038
+ run : String -> [String] -> Integer -> Result<ProcessResult, String>
12039
+ foul run(command, args, timeoutMs) = Kex.Intrinsic.Process.run(command, args, timeoutMs)
12040
+
11272
12041
  # Runs an executable with the CALLER's stdout and stderr, so its output
11273
12042
  # appears as it is produced rather than in one block when it exits.
11274
12043
  #
@@ -11655,6 +12424,409 @@ make Reference do
11655
12424
  demonitor :> Void
11656
12425
  let demonitor = Kex.Intrinsic.Process.demonitor(this)
11657
12426
  end
12427
+ # Pseudorandom values: integers, floats, choices, and shuffles.
12428
+ #
12429
+ # Opt-in: nothing here is in scope until `using Random`, which brings both
12430
+ # the seeded generator and the ambient conveniences into scope at once.
12431
+ #
12432
+ # using Random
12433
+ #
12434
+ # main do
12435
+ # IO.printLine(Random.between(1, 6))
12436
+ # IO.printLine(Random.shuffle(["a", "b", "c"]))
12437
+ # end
12438
+ #
12439
+ # Two layers, for two needs. `Rng` is a small deterministic generator with
12440
+ # explicit state: the same seed answers the same sequence on every run and
12441
+ # on both backends, which is what makes randomized code testable. `Random`
12442
+ # is the ambient convenience layer over it: each call draws fresh entropy
12443
+ # from the host, so answers differ between runs.
12444
+ #
12445
+ # let rng = Rng.seeded(42)
12446
+ # let (roll, next) = rng.nextBounded(6) # 0..5, then keep going with next
12447
+ # Random.integer(6) # 0..5, fresh entropy, foul
12448
+ #
12449
+ # The generator is SplitMix64: tiny, fast, and good enough for modelling,
12450
+ # games, sampling, shuffling, and randomized tests. It is NOT cryptographic:
12451
+ # its state follows directly from its output, so never use it for secrets,
12452
+ # tokens, or anything an adversary gets to see. The ambient layer draws its
12453
+ # seeds from the host's secure source, but one secure seed does not make
12454
+ # the stream that follows secure.
12455
+
12456
+ # The state of a deterministic generator: one 64-bit word.
12457
+ #
12458
+ # A value, not a handle: every step answers a new `Rng` alongside its
12459
+ # output, and the old one keeps answering what it always did. Thread the
12460
+ # answer forward and the sequence is reproducible from its seed.
12461
+ #
12462
+ # let rng = Rng.seeded(42)
12463
+ # let (a, rng) = rng.nextUint64
12464
+ # let (b, rng) = rng.nextUint64 # same `a` and `b` on every run
12465
+ #
12466
+ # Build one with +Rng.seeded+ rather than by hand: the record literal does
12467
+ # no range reduction, and every step here assumes a state inside
12468
+ # 0..2^64 - 1.
12469
+ record Rng do
12470
+ state : Integer = 0
12471
+ end
12472
+
12473
+ # Constructors and constants for the deterministic +Rng+. The draws
12474
+ # themselves are methods in the +make+ block below, so every one answers
12475
+ # to Uniform Function Call Syntax: +rng.nextBounded(6)+.
12476
+ module Rng do
12477
+ # One past the largest representable state: all arithmetic here is
12478
+ # modulo 2^64, keeping the word closed under the mixing below. Also the
12479
+ # largest exclusive bound one 64-bit draw can cover directly.
12480
+ #
12481
+ # Public because the +make+ block below lives outside this module and
12482
+ # reaches it by qualification.
12483
+ let maxBound = 18446744073709551616
12484
+
12485
+ # The SplitMix64 odd increment: every addition steps the state by a
12486
+ # different odd multiple, so even a seed of 0 walks the whole space.
12487
+ let gamma = 0x9e3779b97f4a7c15
12488
+
12489
+ # The two xor-shift/multiply mixing constants.
12490
+ let mixA = 0xbf58476d1ce4e5b9
12491
+ let mixB = 0x94d049bb133111eb
12492
+
12493
+ # Builds a generator from any integer seed.
12494
+ #
12495
+ # The seed is reduced modulo 2^64, so negative seeds and huge ones are
12496
+ # fine: every integer names a generator, and equal integers name the
12497
+ # same one.
12498
+ #
12499
+ # @param seed [Integer] any integer; equal seeds answer equal sequences
12500
+ # @return [Rng] the generator
12501
+ #
12502
+ # @example
12503
+ # let (word, _) = Rng.seeded(42).nextUint64
12504
+ # word # => 13679457532755275413, always
12505
+ #
12506
+ # @example Reproducible sampling in a test
12507
+ # let (pick, _) = Rng.seeded(7).choice(["a", "b", "c"])
12508
+ seeded : Integer -> Rng
12509
+ let seeded(seed: Integer) -> Rng do
12510
+ return Rng { state: seed.modulo(Rng.maxBound) }
12511
+ end
12512
+
12513
+ end
12514
+
12515
+ # The draws on an +Rng+. Each answers its draw alongside the generator
12516
+ # that follows it, so state threads through a chain of calls. All are
12517
+ # methods, so Uniform Function Call Syntax reaches every one.
12518
+ make Rng do
12519
+ # Draws one 64-bit unsigned word and the generator that follows it.
12520
+ #
12521
+ # This is the primitive everything else here is built on: the raw
12522
+ # SplitMix64 output, uniformly spread over 0..2^64 - 1. Pure arithmetic
12523
+ # over unbounded integers with an explicit mask, so both backends agree
12524
+ # bit for bit with the reference C.
12525
+ #
12526
+ # @return [(Integer, Rng)] the word and the next generator
12527
+ #
12528
+ # @example
12529
+ # let (word, next) = Rng.seeded(0).nextUint64
12530
+ # word # => 16294208416658607535
12531
+ nextUint64 :> (Integer, Rng)
12532
+ let nextUint64 do
12533
+ let mask = Rng.maxBound - 1
12534
+ let advanced = Kex.Intrinsic.Bits.and(@state + Rng.gamma, mask)
12535
+ let spread1 = Kex.Intrinsic.Bits.xor(advanced, Kex.Intrinsic.Bits.shiftRight(advanced, 30))
12536
+ let mixed1 = Kex.Intrinsic.Bits.and(spread1 * Rng.mixA, mask)
12537
+ let spread2 = Kex.Intrinsic.Bits.xor(mixed1, Kex.Intrinsic.Bits.shiftRight(mixed1, 27))
12538
+ let mixed2 = Kex.Intrinsic.Bits.and(spread2 * Rng.mixB, mask)
12539
+ let output = Kex.Intrinsic.Bits.xor(mixed2, Kex.Intrinsic.Bits.shiftRight(mixed2, 31))
12540
+ return (output, Rng { state: advanced })
12541
+ end
12542
+
12543
+ # Draws a uniform integer in 0..bound-1 and the generator that follows.
12544
+ #
12545
+ # Uses rejection sampling rather than a bare remainder, so small bounds
12546
+ # are not biased toward small answers: draws that would tilt the range
12547
+ # are discarded and redrawn. Dies when +bound+ is not positive or does
12548
+ # not fit in one 64-bit word.
12549
+ #
12550
+ # @param bound [Integer] the exclusive upper bound, 1..2^64
12551
+ # @return [(Integer, Rng)] the draw and the next generator
12552
+ #
12553
+ # @example
12554
+ # let (roll, _) = Rng.seeded(42).nextBounded(6) # => 0..5
12555
+ #
12556
+ # @example Rolling again with the threaded state
12557
+ # var rng = Rng.seeded(42)
12558
+ # let (first, advanced) = rng.nextBounded(6)
12559
+ # rng = advanced
12560
+ nextBounded :> Integer -> (Integer, Rng)
12561
+ let nextBounded(bound: Integer) -> (Integer, Rng) do
12562
+ if bound <= 0
12563
+ die("Rng.nextBounded: bound must be positive, got ${bound}")
12564
+ end
12565
+ if bound > Rng.maxBound
12566
+ die("Rng.nextBounded: bound does not fit in 64 bits")
12567
+ end
12568
+ let (draw, advanced) = this.nextUint64
12569
+ # Draws at or above this limit would make some residues likelier than
12570
+ # others; there is always less than one bound's worth of them, so the
12571
+ # expected number of redraws stays below two.
12572
+ let limit = Rng.maxBound - Rng.maxBound.modulo(bound)
12573
+ return (draw.modulo(bound), advanced) if draw < limit
12574
+ return advanced.nextBounded(bound)
12575
+ end
12576
+
12577
+ # Draws a uniform float in 0.0..1.0 and the generator that follows.
12578
+ #
12579
+ # Takes the top 53 bits of one word: exactly what a double's mantissa
12580
+ # holds, so every representable value in range is reachable and 1.0
12581
+ # itself never comes out.
12582
+ #
12583
+ # @return [(Float, Rng)] the draw and the next generator
12584
+ #
12585
+ # @example
12586
+ # let (unit, _) = Rng.seeded(42).nextFloat # => 0.0..1.0
12587
+ nextFloat :> (Float, Rng)
12588
+ let nextFloat do
12589
+ let (draw, advanced) = this.nextUint64
12590
+ # Into a float by multiplication, not conversion: `* 1.0` is the
12591
+ # typed idiom (`Duration.seconds` is built the same way).
12592
+ let mantissa = Kex.Intrinsic.Bits.shiftRight(draw, 11) * 1.0
12593
+ return (mantissa / 9007199254740992.0, advanced)
12594
+ end
12595
+
12596
+ # Draws a fair coin flip and the generator that follows.
12597
+ #
12598
+ # Reads the top bit of one word rather than the bottom one, which is
12599
+ # the bit a multiply-mixed generator decorrelates fastest.
12600
+ #
12601
+ # @return [(Bool, Rng)] the flip and the next generator
12602
+ #
12603
+ # @example
12604
+ # let (heads, _) = Rng.seeded(42).nextBoolean
12605
+ nextBoolean :> (Bool, Rng)
12606
+ let nextBoolean do
12607
+ let (draw, advanced) = this.nextUint64
12608
+ return (Kex.Intrinsic.Bits.shiftRight(draw, 63) == 1, advanced)
12609
+ end
12610
+
12611
+ # Shuffles a list into a new order, answering the generator that follows.
12612
+ #
12613
+ # A selection shuffle: each position draws uniformly among the elements
12614
+ # not yet placed, so every permutation is equally likely. The input is
12615
+ # untouched; shuffling an empty list answers an empty list.
12616
+ #
12617
+ # @param items [[A]] the elements to order
12618
+ # @return [([A], Rng)] the shuffled elements and the next generator
12619
+ #
12620
+ # @example
12621
+ # let (order, _) = Rng.seeded(42).shuffle([1, 2, 3])
12622
+ shuffle :> [A] -> ([A], Rng)
12623
+ let shuffle(items: [A]) -> ([A], Rng) do
12624
+ var pool = items
12625
+ var out: [A] = []
12626
+ var state = this
12627
+ while !pool.empty? do
12628
+ let (index, advanced) = state.nextBounded(pool.count)
12629
+ state = advanced
12630
+ # `index` is below `pool.count` by construction, so `None` is
12631
+ # unreachable; `die` says what the typechecker cannot.
12632
+ let picked = match pool.at(index) do
12633
+ Just(x) => x
12634
+ None => die("Rng.shuffle: unreachable empty draw")
12635
+ end
12636
+ out.push!(picked)
12637
+ pool = pool.take(index) + pool.drop(index + 1)
12638
+ end
12639
+ return (out, state)
12640
+ end
12641
+
12642
+ # Draws +n+ distinct elements in random order, with the next generator.
12643
+ #
12644
+ # Shuffles and takes the front: uniform over every ordered +n+-subset.
12645
+ # Asking for more than the list holds answers the whole list shuffled;
12646
+ # asking for none answers an empty list. Dies for a negative +n+.
12647
+ #
12648
+ # @param items [[A]] the elements to draw from
12649
+ # @param n [Integer] how many to draw, 0 or more
12650
+ # @return [([A], Rng)] the drawn elements and the next generator
12651
+ #
12652
+ # @example
12653
+ # let (hand, _) = Rng.seeded(42).sample([1, 2, 3, 4, 5], 2)
12654
+ # hand.count # => 2
12655
+ sample :> [A] -> Integer -> ([A], Rng)
12656
+ let sample(items: [A], n: Integer) -> ([A], Rng) do
12657
+ if n < 0
12658
+ die("Rng.sample: count must not be negative, got ${n}")
12659
+ end
12660
+ let (order, advanced) = this.shuffle(items)
12661
+ return (order.take(n), advanced)
12662
+ end
12663
+
12664
+ # Draws one element uniformly, or +None+ from an empty list.
12665
+ #
12666
+ # The only fallible draw here, and the failure carries no information
12667
+ # worth an error type: an empty list has no element to give, whatever
12668
+ # the seed, so +None+ is the whole story.
12669
+ #
12670
+ # @param items [[A]] the elements to draw from
12671
+ # @return [(A?, Rng)] the drawn element, if any, and the next generator
12672
+ #
12673
+ # @example
12674
+ # let (pick, _) = Rng.seeded(42).choice(["a", "b", "c"])
12675
+ # ["a", "b", "c"].contains?(pick.or("")) # => true
12676
+ choice :> [A] -> (A?, Rng)
12677
+ let choice(items: [A]) -> (A?, Rng) do
12678
+ return (None, this) if items.empty?
12679
+ let (index, advanced) = this.nextBounded(items.count)
12680
+ return (items.at(index), advanced)
12681
+ end
12682
+ end
12683
+
12684
+ # Ambient randomness: fresh host entropy on every call.
12685
+ #
12686
+ # Each function here draws its own seed from the host's secure source and
12687
+ # runs the deterministic core above on it, so answers differ between runs
12688
+ # the way the clock differs between reads. That is why every one is
12689
+ # +foul+: the same call with the same arguments may answer differently.
12690
+ #
12691
+ # Reach for +Rng+ instead when the answers must repeat: seeded simulation,
12692
+ # property tests, anything asserting on a particular draw.
12693
+ module Random do
12694
+ # Builds a generator from fresh host entropy.
12695
+ #
12696
+ # The entry point for hand-threaded flows that still vary between runs:
12697
+ # seed once here, then draw purely from the answer.
12698
+ #
12699
+ # @return [Rng] a generator seeded from the host's secure source
12700
+ #
12701
+ # @example
12702
+ # var rng = Random.fresh()
12703
+ # let (roll, advanced) = rng.nextBounded(6)
12704
+ # rng = advanced
12705
+ foul fresh() -> Rng do
12706
+ let high = Math.floor(Kex.Intrinsic.Retry.randomUnit() * 4294967296.0)
12707
+ let low = Math.floor(Kex.Intrinsic.Retry.randomUnit() * 4294967296.0)
12708
+ return Rng.seeded(high * 4294967296 + low)
12709
+ end
12710
+
12711
+ # Builds a generator from any integer seed. The deterministic entry:
12712
+ # the same seed replays the same sequence, which is what tests want.
12713
+ #
12714
+ # @param seed [Integer] any integer; equal seeds answer equal sequences
12715
+ # @return [Rng] the generator
12716
+ #
12717
+ # @example
12718
+ # let (roll, _) = Random.seeded(42).nextBounded(6)
12719
+ seeded : Integer -> Rng
12720
+ let seeded(seed: Integer) -> Rng = Rng.seeded(seed)
12721
+
12722
+ # Returns a uniform float in 0.0..1.0. Never answers 1.0 itself.
12723
+ #
12724
+ # @return [Float] the draw
12725
+ #
12726
+ # @example Scaling into a range
12727
+ # let jitter = Random.float() * maxJitter
12728
+ foul float() -> Float do
12729
+ let (value, _) = Random.fresh().nextFloat
12730
+ return value
12731
+ end
12732
+
12733
+ # Returns a uniform integer in 0..bound-1. Dies unless +bound+ is
12734
+ # positive and fits in 64 bits.
12735
+ #
12736
+ # @param bound [Integer] the exclusive upper bound, 1..2^64
12737
+ # @return [Integer] the draw
12738
+ #
12739
+ # @example
12740
+ # Random.integer(6) # => 0..5, like a die minus one
12741
+ foul integer(bound: Integer) -> Integer do
12742
+ let (value, _) = Random.fresh().nextBounded(bound)
12743
+ return value
12744
+ end
12745
+
12746
+ # Returns a uniform integer in +low+..+high+, endpoints included. Dies
12747
+ # unless +low+ is below +high+ and the span fits in 64 bits.
12748
+ #
12749
+ # @param low [Integer] the smallest answer
12750
+ # @param high [Integer] the largest answer
12751
+ # @return [Integer] the draw
12752
+ #
12753
+ # @example
12754
+ # Random.between(1, 6) # => a die roll
12755
+ foul between(low: Integer, high: Integer) -> Integer do
12756
+ if low > high
12757
+ die("Random.between: low must not exceed high, got ${low}..${high}")
12758
+ end
12759
+ return low + Random.integer(high - low + 1)
12760
+ end
12761
+
12762
+ # Returns a fair coin flip.
12763
+ #
12764
+ # @return [Bool] the flip
12765
+ #
12766
+ # @example
12767
+ # if Random.boolean() then IO.printLine("heads") else IO.printLine("tails") end
12768
+ foul boolean() -> Bool do
12769
+ let (value, _) = Random.fresh().nextBoolean
12770
+ return value
12771
+ end
12772
+
12773
+ # Returns +true+ with probability +p+: a coin weighted by its argument.
12774
+ # Dies unless +p+ is inside 0.0..1.0.
12775
+ #
12776
+ # @param p [Float] the chance of +true+, 0.0..1.0
12777
+ # @return [Bool] +true+ with probability +p+
12778
+ #
12779
+ # @example
12780
+ # Random.chance?(0.25) # => true about one call in four
12781
+ #
12782
+ # @example Simulating a failure rate
12783
+ # if Random.chance?(0.01) then Error("flaky") else Ok(send(request)) end
12784
+ foul chance?(p: Float) -> Bool do
12785
+ if p < 0.0 || p > 1.0
12786
+ die("Random.chance?: chance must be inside 0.0..1.0, got ${p}")
12787
+ end
12788
+ return Random.float() < p
12789
+ end
12790
+
12791
+ # Returns a uniformly drawn element, or +None+ from an empty list.
12792
+ #
12793
+ # @param items [[A]] the elements to draw from
12794
+ # @return [A?] the drawn element, if any
12795
+ #
12796
+ # @example
12797
+ # Random.choice(["heads", "tails"]) # => Just("heads") or Just("tails")
12798
+ foul choice(items: [A]) -> A? do
12799
+ let (value, _) = Random.fresh().choice(items)
12800
+ return value
12801
+ end
12802
+
12803
+ # Returns +n+ distinct elements in random order. Asking for more than
12804
+ # the list holds answers the whole list shuffled. Dies for a
12805
+ # negative +n+.
12806
+ #
12807
+ # @param items [[A]] the elements to draw from
12808
+ # @param n [Integer] how many to draw, 0 or more
12809
+ # @return [[A]] the drawn elements
12810
+ #
12811
+ # @example
12812
+ # Random.sample(["a", "b", "c", "d"], 2) # => two of the four
12813
+ foul sample(items: [A], n: Integer) -> [A] do
12814
+ let (value, _) = Random.fresh().sample(items, n)
12815
+ return value
12816
+ end
12817
+
12818
+ # Returns the elements in a uniformly random order.
12819
+ #
12820
+ # @param items [[A]] the elements to order
12821
+ # @return [[A]] the shuffled elements
12822
+ #
12823
+ # @example
12824
+ # Random.shuffle([1, 2, 3, 4]) # => the four, in a random order
12825
+ foul shuffle(items: [A]) -> [A] do
12826
+ let (value, _) = Random.fresh().shuffle(items)
12827
+ return value
12828
+ end
12829
+ end
11658
12830
  # A span between two bounds, written +(1..10)+ or +('a'..'z')+.
11659
12831
  #
11660
12832
  # A range stores only its two endpoints and computes everything else from
@@ -12986,6 +14158,52 @@ make String, implement: Enumerable, Foldable do
12986
14158
  bytes :> [Byte]
12987
14159
  let bytes = Kex.Intrinsic.String.bytes(this)
12988
14160
 
14161
+ # The storage size in bytes, without building the byte list.
14162
+ #
14163
+ # +bytes.count+ answers the same number by materialising every byte first,
14164
+ # which on a megabyte payload means a million values to count them. This
14165
+ # reads the encoded length directly.
14166
+ #
14167
+ # @return [Integer] the length of the UTF-8 encoding
14168
+ #
14169
+ # @example Text length and storage length differ
14170
+ # "héllo".count # => 5
14171
+ # "héllo".byteSize # => 6
14172
+ byteSize :> Integer
14173
+ let byteSize = Kex.Intrinsic.String.byteSize(this)
14174
+
14175
+ # One byte of the storage view, or +None+ when the index is out of range.
14176
+ #
14177
+ # This indexes the ENCODING, not the text: +byteAt+ on a multi-byte
14178
+ # character returns one of its bytes, never the character. Use +at+ or
14179
+ # +chars+ for the text view.
14180
+ #
14181
+ # @param index [Integer] a zero-based byte offset
14182
+ # @return [Byte?] the byte, or +None+ past the end
14183
+ #
14184
+ # @example The two views of the same string
14185
+ # "héllo".byteAt(1) # => Just(195)
14186
+ # "héllo".at(1) # => Just("é")
14187
+ # "héllo".byteAt(99) # => None
14188
+ byteAt :> Integer -> Byte?
14189
+ let byteAt(index: Integer) = Kex.Intrinsic.String.byteAt(this, index)
14190
+
14191
+ # A slice of the storage view, by byte offset and byte count.
14192
+ #
14193
+ # The range is clamped rather than refused, the way +take+ and +drop+
14194
+ # already behave. Slicing mid-character yields a string holding partial
14195
+ # UTF-8 — legal storage, but not text: decode it only at a boundary you
14196
+ # know is a character boundary.
14197
+ #
14198
+ # @param offset [Integer] a zero-based byte offset, clamped to the string
14199
+ # @param count [Integer] how many bytes, clamped to what remains
14200
+ # @return [String] the bytes in that range
14201
+ #
14202
+ # @example Reading a length-prefixed field out of a payload
14203
+ # payload.bytePart(4, payload.byteSize - 4)
14204
+ bytePart :> Integer -> Integer -> String
14205
+ let bytePart(offset: Integer, count: Integer) = Kex.Intrinsic.String.bytePart(this, offset, count)
14206
+
12989
14207
  # Splits the string into its individual characters, as one-character
12990
14208
  # strings.
12991
14209
  #
@@ -13816,6 +15034,24 @@ end
13816
15034
  # # TemplateParam { name: "library", type: "Bool" }]
13817
15035
  # parsed.nodes # => [Text("Hi "), Interpolate("name"), Text("!")]
13818
15036
  #
15037
+ # A template file carries its host's extension ahead of `.ket`, so an editor
15038
+ # can highlight it as what it is — `README.md.ket`, `profile.html.ket`:
15039
+ #
15040
+ # ---
15041
+ # params: [name, library: Bool, dependencies: [Dependency]]
15042
+ # ---
15043
+ # # <%= name %>
15044
+ #
15045
+ # <% if library %>
15046
+ # A Kex library.
15047
+ # <% else %>
15048
+ # A Kex application.
15049
+ # <% end %>
15050
+ #
15051
+ # <% dependencies.map do |dep| %>
15052
+ # - `<%= dep.name %>` ~> <%= dep.version %>
15053
+ # <% end %>
15054
+ #
13819
15055
  # ## Syntax
13820
15056
  #
13821
15057
  # <%= expr %> interpolate (escaping is a later stage's job)
@@ -13823,10 +15059,40 @@ end
13823
15059
  # <% ... %> a Kex control region: `if`/`match` arms, block bodies, `let`
13824
15060
  # <%# ... %> comment, emits nothing
13825
15061
  # <%- ... -%> whitespace control: trims the line's leading indent before
13826
- # the tag, and the newline right after it
15062
+ # the tag, and the newline right after it. A `<% %>` or
15063
+ # `<%# %>` tag standing alone on its line does this on its
15064
+ # own, so the markers are for a tag sharing its line with
15065
+ # real content
13827
15066
  # <%% a literal `<%`, for a template that generates ERB-shaped
13828
15067
  # output itself
13829
15068
  #
15069
+ # ## Whitespace
15070
+ #
15071
+ # A tag that emits nothing — `<% %>` and `<%# %>` — and stands alone on its
15072
+ # line takes that line with it. Only whitespace may share the line with it:
15073
+ # the indent before it and the newline after it are scaffolding, never
15074
+ # content, so a template reads the way its output does:
15075
+ #
15076
+ # <% if dependencies.count > 0 %>
15077
+ # ## Dependencies
15078
+ # <% end %>
15079
+ #
15080
+ # # => "## Dependencies\n" — no blank line where the tags were
15081
+ #
15082
+ # The `<%- -%>` markers are for the case this does not cover: a tag sharing
15083
+ # its line with real content, where what to trim is a judgement call rather
15084
+ # than obvious.
15085
+ #
15086
+ # Total: <%= total %> <%- if pending > 0 %>(<%= pending %> pending)<% end %>
15087
+ #
15088
+ # An interpolation is never trimmed, on its own line or not. It stands in for
15089
+ # content, so the whitespace around it is content too:
15090
+ #
15091
+ # <%= greeting %>
15092
+ # <%= name %>
15093
+ #
15094
+ # # => "Hi\nAda\n" — both newlines survive
15095
+ #
13830
15096
  # ## Frontmatter
13831
15097
  #
13832
15098
  # A template file may open with a `---` line, generic `key: value` tags up to
@@ -13877,10 +15143,13 @@ type Node = Text(String)
13877
15143
  type Tag = Scalar(String)
13878
15144
  | Tags([String])
13879
15145
 
13880
- # Why a template's text could not be scanned, and where.
15146
+ # Why a template's text could not be scanned or rendered, and where (or,
15147
+ # for `render`/`renderParsed`, what stopped it).
13881
15148
  type TemplateError = UnterminatedTag(Integer)
13882
15149
  | UnterminatedFrontmatter
13883
15150
  | MalformedFrontmatterLine(String)
15151
+ | UndefinedVariable(String)
15152
+ | UnsupportedControl(String)
13884
15153
 
13885
15154
  # One entry from a `params: [...]` frontmatter list: a name, and its
13886
15155
  # optional `: Type` annotation. `type` is raw text: `""` for a bare name,
@@ -13936,6 +15205,14 @@ end
13936
15205
  # @example
13937
15206
  # Template.scan("Hi <%= name %>!").map(~nodes)
13938
15207
  # # => Ok([Text("Hi "), Interpolate("name"), Text("!")])
15208
+ #
15209
+ # @example A control tag on a line of its own leaves no blank line behind
15210
+ # Template.scan("<% if admin %>\nWelcome back.\n<% end %>\n").try.nodes
15211
+ # # => [Control("if admin"), Text("Welcome back.\n"), Control("end")]
15212
+ #
15213
+ # @example Where the template broke, as a position in the source
15214
+ # Template.scan("Hi <%= name")
15215
+ # # => Error(UnterminatedTag(6))
13939
15216
  let scan(source: String) -> Result<Parsed, TemplateError> do
13940
15217
  let cursor = Input { input: source }
13941
15218
  let (frontmatter, afterFrontmatter) = scanFrontmatter(cursor).try
@@ -13943,6 +15220,107 @@ let scan(source: String) -> Result<Parsed, TemplateError> do
13943
15220
  Ok(Parsed { frontmatter: frontmatter, nodes: nodes })
13944
15221
  end
13945
15222
 
15223
+ # Escapes the five characters HTML gives special meaning: what `Template.html`
15224
+ # (kexhq/kex#171 M3) wraps every `<%= %>` hole in, so an interpolated value
15225
+ # can never inject markup or break out of an attribute. `<%== %>` opts out.
15226
+ #
15227
+ # `&` first, deliberately: escaping it after `<`/`>` would re-escape the
15228
+ # `&` those just introduced (`&lt;` -> `&amp;lt;`).
15229
+ #
15230
+ # @param text [String] the text to escape
15231
+ # @return [String] the same text, HTML-safe
15232
+ #
15233
+ # @example
15234
+ # Template.escapeHtml("<b>Tom & Jerry</b>")
15235
+ # # => "&lt;b&gt;Tom &amp; Jerry&lt;/b&gt;"
15236
+ let escapeHtml(text: String) -> String do
15237
+ text.replace("&", "&amp;")
15238
+ .replace("<", "&lt;")
15239
+ .replace(">", "&gt;")
15240
+ .replace("\"", "&quot;")
15241
+ .replace("'", "&#39;")
15242
+ end
15243
+
15244
+ # Renders an already-scanned template's holes from a runtime `context`,
15245
+ # `<%= %>` HTML-escaped and `<%== %>` raw, same as `Template.html`/
15246
+ # `Template.text` do at compile time — but there is no runtime evaluator for
15247
+ # `<% ... %>` CONTROL regions here. Evaluating a `<% if … %>`/`<% match … %>`/
15248
+ # a block loop chosen at run time means evaluating arbitrary Kex source
15249
+ # picked at run time, which is its own design decision (kexhq/kex#335) and
15250
+ # not what this covers: a template using one reports `UnsupportedControl`
15251
+ # with the region's text rather than silently doing nothing with it, so the
15252
+ # gap is loud, not a template that quietly renders wrong.
15253
+ #
15254
+ # This is for what `Template.html(Kex.embed(path))` cannot do at all — a
15255
+ # template file chosen while the program is running, not baked in at compile
15256
+ # time — for the shape of template that does not need control flow: a
15257
+ # subject line, a notification body, a plain-text substitution. A template
15258
+ # with real control flow still needs compiling in (`Kex.embed`), or a hole
15259
+ # it does not have: turning `Parsed#nodes` into a fuller runtime evaluator is
15260
+ # further work this only lays the groundwork for.
15261
+ #
15262
+ # `<%= %>`/`<%== %>` names are looked up VERBATIM (trimmed of surrounding
15263
+ # whitespace) in `context` — `dep.name` in a template needs a `"dep.name"`
15264
+ # key, not field access into a `dep` key's value. Splitting a dotted hole
15265
+ # into a real field path is, again, further work.
15266
+ #
15267
+ # @param parsed [Parsed] a template already scanned by `Template.scan`
15268
+ # @param context [{String: String}] a value for every `<%= %>`/`<%== %>`
15269
+ # hole the template uses, keyed by the hole's exact (trimmed) text
15270
+ # @return [Result<String, TemplateError>] the rendered text, or why not
15271
+ #
15272
+ # @example
15273
+ # let parsed = Template.scan("Hi <%= name %>!").try
15274
+ # Template.renderParsed(parsed, { "name": "<Ada>" })
15275
+ # # => Ok("Hi &lt;Ada&gt;!")
15276
+ #
15277
+ # @example A hole `context` does not cover
15278
+ # Template.renderParsed(Template.scan("<%= missing %>").try, {})
15279
+ # # => Error(UndefinedVariable("missing"))
15280
+ #
15281
+ # @example Control flow is refused, not silently skipped
15282
+ # Template.renderParsed(Template.scan("<% if x %>y<% end %>").try, {})
15283
+ # # => Error(UnsupportedControl("if x"))
15284
+ let renderParsed(parsed: Parsed, context: {String: String}) -> Result<String, TemplateError> do
15285
+ var out = ""
15286
+ var i = 0
15287
+ loop do
15288
+ break if i >= parsed.nodes.count
15289
+ match parsed.nodes.at(i).or(Text("")) do
15290
+ Text(text) => out = "${out}${text}"
15291
+ Interpolate(name) => do
15292
+ let value = context.get(name.trim)
15293
+ return Error(UndefinedVariable(name.trim)) if value == None
15294
+ out = "${out}${escapeHtml(value.or(""))}"
15295
+ end
15296
+ InterpolateRaw(name) => do
15297
+ let value = context.get(name.trim)
15298
+ return Error(UndefinedVariable(name.trim)) if value == None
15299
+ out = "${out}${value.or("")}"
15300
+ end
15301
+ Comment(_) => ()
15302
+ Control(body) => return Error(UnsupportedControl(body))
15303
+ end
15304
+ i = i + 1
15305
+ end
15306
+ Ok(out)
15307
+ end
15308
+
15309
+ # `Template.scan(source).try` then `renderParsed` — see its doc comment for
15310
+ # what this does and, as importantly, what it refuses to do.
15311
+ #
15312
+ # @param source [String] the template's full text, scanned fresh
15313
+ # @param context [{String: String}] see `renderParsed`
15314
+ # @return [Result<String, TemplateError>] the rendered text, or why not
15315
+ #
15316
+ # @example
15317
+ # Template.render("Hi <%= name %>!", { "name": "Ada" })
15318
+ # # => Ok("Hi Ada!")
15319
+ let render(source: String, context: {String: String}) -> Result<String, TemplateError> do
15320
+ let parsed = scan(source).try
15321
+ renderParsed(parsed, context)
15322
+ end
15323
+
13946
15324
  private do
13947
15325
  # True when `cursor` sits at the very start of the input and that line is
13948
15326
  # exactly `---`: the only place a frontmatter block may open.
@@ -13972,17 +15350,21 @@ private do
13972
15350
  # A cursor advanced to the next `\n` (not past it), and the text skipped.
13973
15351
  #
13974
15352
  # Hand-rolled rather than `Input#takeWhile`: see `scanText`'s comment on why.
15353
+ #
15354
+ # Tracks the run's start position and slices once at the end, rather than
15355
+ # building it one `Char` at a time with `push!`: pushing to a `var` list
15356
+ # rebinds it to a freshly copied list every call (kexhq/kex#379), so a
15357
+ # character-at-a-time accumulation of an N-character line cost O(N^2), not
15358
+ # O(N). One slice is O(N) total regardless of how many lines are scanned.
13975
15359
  let scanLine(cursor: Input) -> (String, Input) do
13976
15360
  var cur = cursor
13977
- var chars: [Char] = []
15361
+ let start = cur.pos
13978
15362
  loop do
13979
15363
  break if cur.peek == None
13980
15364
  break if cur.peek == Just('\n')
13981
- let Just(ch) = cur.peek
13982
- chars.push!(ch)
13983
15365
  cur.advance!
13984
15366
  end
13985
- (chars.join(""), cur)
15367
+ (cur.input.chars.drop(start).take(cur.pos - start).join(""), cur)
13986
15368
  end
13987
15369
 
13988
15370
  # A line's text with any trailing `\r` dropped, for a source that mixes
@@ -14113,23 +15495,31 @@ private do
14113
15495
  # collides with `List#takeWhile`, and calling it through an `Input` receiver
14114
15496
  # sends overload resolution down the wrong path (`json.kex`'s own scanners
14115
15497
  # hit the same thing and avoid it the same way).
15498
+ #
15499
+ # Tracks each run's start position and slices it out whole, rather than
15500
+ # pushing one `Char` at a time: see `scanLine`'s comment on why that was
15501
+ # quadratic (kexhq/kex#379). `<%%` still needs a per-occurrence split (it
15502
+ # folds three input characters into two output ones), but that split is as
15503
+ # rare as `<%%` itself, not once per character of ordinary text.
14116
15504
  let scanText(cursor: Input) -> (String, Input) do
14117
15505
  var cur = cursor
14118
- var chars: [Char] = []
15506
+ var pieces: [String] = []
15507
+ var runStart = cur.pos
14119
15508
  loop do
14120
15509
  break if cur.peek == None
14121
15510
  break if atTagOpen?(cur)
14122
15511
  if cur.peek == Just('<') && cur.peekAt(1) == Just('%') && cur.peekAt(2) == Just('%')
14123
- chars.push!('<')
14124
- chars.push!('%')
15512
+ pieces.push!(cur.input.chars.drop(runStart).take(cur.pos - runStart).join(""))
15513
+ pieces.push!("<%")
14125
15514
  cur.advanceBy!(3)
15515
+ runStart = cur.pos
14126
15516
  else
14127
- let Just(ch) = cur.peek
14128
- chars.push!(ch)
14129
15517
  cur.advance!
15518
+ ()
14130
15519
  end
14131
15520
  end
14132
- (chars.join(""), cur)
15521
+ pieces.push!(cur.input.chars.drop(runStart).take(cur.pos - runStart).join(""))
15522
+ (pieces.join(""), cur)
14133
15523
  end
14134
15524
 
14135
15525
  # Strips trailing spaces and tabs (not newlines): the indentation a `<%-`
@@ -14145,6 +15535,58 @@ private do
14145
15535
  chars.join("")
14146
15536
  end
14147
15537
 
15538
+ # A tag that emits nothing of its own. Only these can have their whole
15539
+ # line disappear: a tag that produces output stands in real content, so
15540
+ # the whitespace around it is content too.
15541
+ let silent?(node: Node) -> Bool do
15542
+ match node do
15543
+ Control(_) => true
15544
+ Comment(_) => true
15545
+ _ => false
15546
+ end
15547
+ end
15548
+
15549
+ # Whether the text scanned before a tag leaves the cursor at the start of
15550
+ # a line — nothing but spaces and tabs since the last newline. `prior`
15551
+ # answers it for text that holds no newline at all, carrying forward what
15552
+ # was true before that text: two tags separated by spaces alone are both
15553
+ # still at the start of their line.
15554
+ let atLineStart?(text: String, prior: Bool) -> Bool do
15555
+ let after = text.split("\n").last
15556
+ match after do
15557
+ Just(tail) => text.contains?("\n") then blank?(tail) else prior && blank?(text)
15558
+ None => prior
15559
+ end
15560
+ end
15561
+
15562
+ # True for text of spaces and tabs alone, the empty string included.
15563
+ let blank?(text: String) -> Bool do
15564
+ text.chars.all? { |ch| ch == ' ' || ch == '\t' }
15565
+ end
15566
+
15567
+ # True when nothing but spaces and tabs stands between `cursor` and the end
15568
+ # of its line. The right half of what makes a tag stand alone; end of input
15569
+ # counts, since there is no trailing content there either.
15570
+ let blankToLineEnd?(cursor: Input) -> Bool do
15571
+ var cur = cursor
15572
+ loop do
15573
+ break if cur.peek != Just(' ') && cur.peek != Just('\t')
15574
+ cur.advance!
15575
+ end
15576
+ cur.peek == None || cur.peek == Just('\n') || cur.peek == Just('\r')
15577
+ end
15578
+
15579
+ # A cursor advanced past the rest of a blank line, its newline included.
15580
+ # What a standalone tag's own line needs once the tag itself is scanned.
15581
+ let skipBlankLineRest(cursor: Input) -> Input do
15582
+ var cur = cursor
15583
+ loop do
15584
+ break if cur.peek != Just(' ') && cur.peek != Just('\t')
15585
+ cur.advance!
15586
+ end
15587
+ skipOneNewline(cur)
15588
+ end
15589
+
14148
15590
  # `<%=`, `<%==`, `<%#`, or plain `<%`, which kind of region this is, and
14149
15591
  # the cursor past the marker.
14150
15592
  let classifyTag(cursor: Input) -> (String, Input) do
@@ -14161,16 +15603,17 @@ private do
14161
15603
 
14162
15604
  # Reads a tag's body up to its closer, `%>` or `-%>`. Answers the raw text,
14163
15605
  # whether the closer trims the following newline, and the cursor past it.
15606
+ #
15607
+ # Slices the body out once at the closer, rather than pushing one `Char` at
15608
+ # a time — see `scanLine`'s comment on why that was quadratic
15609
+ # (kexhq/kex#379).
14164
15610
  let scanTagBody(cursor: Input) -> Result<(String, Bool, Input), TemplateError> do
14165
15611
  let start = cursor.pos
14166
15612
  var cur = cursor
14167
- var chars: [Char] = []
14168
15613
  loop do
14169
15614
  return Error(UnterminatedTag(start)) if cur.peek == None
14170
- return Ok((chars.join(""), true, cur.advanceBy(3))) if cur.peek == Just('-') && cur.peekAt(1) == Just('%') && cur.peekAt(2) == Just('>')
14171
- return Ok((chars.join(""), false, cur.advanceBy(2))) if cur.peek == Just('%') && cur.peekAt(1) == Just('>')
14172
- let Just(ch) = cur.peek
14173
- chars.push!(ch)
15615
+ return Ok((cur.input.chars.drop(start).take(cur.pos - start).join(""), true, cur.advanceBy(3))) if cur.peek == Just('-') && cur.peekAt(1) == Just('%') && cur.peekAt(2) == Just('>')
15616
+ return Ok((cur.input.chars.drop(start).take(cur.pos - start).join(""), false, cur.advanceBy(2))) if cur.peek == Just('%') && cur.peekAt(1) == Just('>')
14174
15617
  cur.advance!
14175
15618
  end
14176
15619
  end
@@ -14200,9 +15643,16 @@ private do
14200
15643
  end
14201
15644
 
14202
15645
  # Scans the template body (everything after any frontmatter) into nodes.
15646
+ #
15647
+ # A tag that emits nothing and stands alone on its line takes that line
15648
+ # with it, without being asked: `<% end %>` on a line of its own leaves no
15649
+ # blank line behind, the same as `<%- end -%>`. That is what a template
15650
+ # author means every time, so the trim markers are for the other case — a
15651
+ # tag with real content beside it on the line.
14203
15652
  let scanBody(cursor: Input) -> Result<([Node], Input), TemplateError> do
14204
15653
  var cur = cursor
14205
15654
  var nodes: [Node] = []
15655
+ var lineStart = true
14206
15656
  loop do
14207
15657
  let (text, afterText) = scanText(cur)
14208
15658
  if afterText.peek == None
@@ -14212,12 +15662,20 @@ private do
14212
15662
  return Ok((nodes, afterText))
14213
15663
  end
14214
15664
  let (tag, afterTag) = scanTag(afterText).try
14215
- let keptText = tag.leftTrim then trimTrailingIndent(text) else text
15665
+ let alone = silent?(tag.node) && atLineStart?(text, lineStart) && blankToLineEnd?(afterTag)
15666
+ let keptText = (tag.leftTrim || alone) then trimTrailingIndent(text) else text
14216
15667
  if !keptText.empty?
14217
15668
  nodes.push!(Text(keptText))
14218
15669
  end
14219
15670
  nodes.push!(tag.node)
14220
- cur = tag.rightTrim then skipOneNewline(afterTag) else afterTag
15671
+ cur = if alone
15672
+ skipBlankLineRest(afterTag)
15673
+ elif tag.rightTrim
15674
+ skipOneNewline(afterTag)
15675
+ else
15676
+ afterTag
15677
+ end
15678
+ lineStart = alone || tag.rightTrim
14221
15679
  end
14222
15680
  end
14223
15681
  end
@@ -16842,6 +18300,24 @@ make Bool, implement: Truthyable do
16842
18300
  truthy? :> Bool
16843
18301
  let truthy? = this
16844
18302
 
18303
+ # Returns the negation of this boolean.
18304
+ #
18305
+ # +!flag+ says the same thing, and is the spelling to reach for when the
18306
+ # value is already to hand. This one exists for the position +!+ cannot
18307
+ # take: the end of a chain, where what is being negated is whatever the
18308
+ # chain just produced. +falsy?+ answers the same question for any
18309
+ # +Truthyable+ value; +not+ is the one that both takes and answers a +Bool+.
18310
+ #
18311
+ # @return [Bool] +false+ for +true+, and +true+ for +false+
18312
+ #
18313
+ # @example
18314
+ # true.not # => false
18315
+ # false.not # => true
18316
+ #
18317
+ # @example At the end of a chain
18318
+ # book.borrowed?.not
18319
+ not :> Bool
18320
+ let not = !this
16845
18321
  end
16846
18322
 
16847
18323
  make Integer, implement: Truthyable do
@@ -18502,6 +19978,21 @@ module Query do
18502
19978
  from : [(String, String?)] -> Query
18503
19979
  let from(entries) = Query { entries: entries }
18504
19980
 
19981
+ # Builds a query from unordered key/value pairs, for the common case where
19982
+ # every key is used once. A `Map` cannot hold "tag" twice, so — unlike the
19983
+ # list form above — repeating a key here does not add a second field; it
19984
+ # silently keeps only one value for it. Reach for the ordered list form
19985
+ # instead of this one for anything that legitimately repeats a key, such
19986
+ # as `?tag=kex&tag=beam`.
19987
+ #
19988
+ # @param entries [Map<String, String?>] decoded key/value pairs, one per key
19989
+ # @return [Query] the query
19990
+ #
19991
+ # @example Building filters that each use a distinct key
19992
+ # Query.from({ "sort": Just("name"), "debug": None })
19993
+ from : Map<String, String?> -> Query
19994
+ let from(entries: Map<String, String?>) = Query { entries: entries.entries }
19995
+
18505
19996
  # Parses generic URI query encoding; ++ remains a literal plus.
18506
19997
  #
18507
19998
  # @param text [String] encoded query text without the leading question mark
@@ -18524,6 +20015,21 @@ module Form do
18524
20015
  from : [(String, String)] -> Form
18525
20016
  let from(entries) = Kex.Intrinsic.URI.formFrom(entries)
18526
20017
 
20018
+ # Builds a form from unordered key/value pairs, for the common case where
20019
+ # every field name is used once. A `Map` cannot hold a name twice, so —
20020
+ # unlike the list form above — a repeated field name here does not add a
20021
+ # second entry; it silently keeps only one value for it. Reach for the
20022
+ # ordered list form instead of this one for anything that legitimately
20023
+ # repeats a field name, such as several same-named checkboxes.
20024
+ #
20025
+ # @param entries [Map<String, String>] decoded form fields, one per name
20026
+ # @return [Form] the form
20027
+ #
20028
+ # @example Preparing a login request body
20029
+ # Form.from({ "email": email, "password": password }).encode
20030
+ from : Map<String, String> -> Form
20031
+ let from(entries: Map<String, String>) = Kex.Intrinsic.URI.formFrom(entries.entries)
20032
+
18527
20033
  # Parses form encoding where ++ represents a space.
18528
20034
  #
18529
20035
  # @param text [String] an +application/x-www-form-urlencoded+ body