@kexhq/kex 0.4.0-beta.2 → 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.
@@ -1108,232 +1108,346 @@ module Console do
1108
1108
  enabled? : Bool
1109
1109
  let enabled? = Kex.Intrinsic.Console.enabled?
1110
1110
  end
1111
- # Bounded retries for operations whose failures can be classified by the
1112
- # application.
1111
+ # Runs bounded retries with a reusable schedule.
1113
1112
  #
1114
- # A retry is never automatically safe just because an error was temporary.
1115
- # The caller owns the operation and, where necessary, a predicate that excludes
1116
- # permanent failures and non-idempotent work. Policies bound attempts, delay,
1117
- # 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.
1118
1116
  #
1117
+ # @example Retry a request and handle the final result
1119
1118
  # using Control.Retry
1119
+ # using Net.HTTP
1120
1120
  #
1121
- # let policy = Retry.exponential(4, 100.milliseconds, 2.seconds)
1122
- # .withJitter(0.2)
1123
- # Retry.run(policy, ~retryable?) do
1124
- # 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)
1125
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.
1126
1135
  module Control.Retry
1127
1136
 
1128
- # A bounded retry schedule. Attempts includes the initial call.
1129
- # Delays are immutable +Duration+ values and never exceed +maximumDelay+.
1130
- record Policy do
1131
- maximumAttempts : Integer
1132
- initialDelay : Duration
1133
- multiplier : Float
1134
- maximumDelay : Duration
1135
- maximumElapsed : Duration? = None
1136
- jitterFraction : Float = 0.0
1137
- end
1138
-
1139
- # Decides whether an application error is eligible for another attempt.
1140
- type Predicate<E> = E -> Bool
1141
-
1142
- # Performs one scheduled delay. Supplying this callback makes retry tests
1143
- # deterministic without sleeping.
1144
- type Sleeper = Duration -> Void
1145
-
1146
- # Builds a constant-delay retry policy.
1147
- #
1148
- # Fixed delays are predictable and useful for a local resource expected to
1149
- # become ready shortly. For many clients sharing a remote dependency, prefer
1150
- # exponential backoff with jitter to avoid synchronized retry bursts.
1151
- #
1152
- # @param maximumAttempts [Integer] total calls including the first
1153
- # @param delay [Duration] delay before each subsequent call
1154
- # @return [Policy] the retry schedule
1155
- #
1156
- # @example Waiting briefly for a local test server to start
1157
- # let policy = Retry.fixed(5, 50.milliseconds)
1158
- fixed : Integer -> Duration -> Policy
1159
- let fixed(maximumAttempts, delay) = Policy {
1160
- maximumAttempts: maximumAttempts,
1161
- initialDelay: delay,
1162
- multiplier: 1.0,
1163
- maximumDelay: delay,
1164
- maximumElapsed: None,
1165
- jitterFraction: 0.0
1166
- }
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
1167
1213
 
1168
- # Builds a doubling backoff capped at +maximumDelay+.
1169
- #
1170
- # The first retry waits +initialDelay+; later delays double until they reach
1171
- # the cap. Add jitter for production network traffic.
1172
- #
1173
- # @param maximumAttempts [Integer] total calls including the first
1174
- # @param initialDelay [Duration] delay after the first failure
1175
- # @param maximumDelay [Duration] upper bound for each delay
1176
- # @return [Policy] the retry schedule
1177
- #
1178
- # @example Backing off calls to a busy upstream service
1179
- # Retry.exponential(5, 200.milliseconds, 5.seconds).withJitter(0.25)
1180
- exponential : Integer -> Duration -> Duration -> Policy
1181
- let exponential(maximumAttempts, initialDelay, maximumDelay) = Policy {
1182
- maximumAttempts: maximumAttempts,
1183
- initialDelay: initialDelay,
1184
- multiplier: 2.0,
1185
- maximumDelay: maximumDelay,
1186
- maximumElapsed: None,
1187
- jitterFraction: 0.0
1188
- }
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
1189
1268
 
1190
- make Policy do
1191
- # Returns the same schedule with a bound on total scheduled sleep time.
1192
- #
1193
- # The operation's own execution time is not counted; use operation-specific
1194
- # deadlines for that. A retry whose next delay would exceed this bound is
1195
- # not started.
1196
- #
1197
- # @param maximumElapsed [Duration] maximum cumulative scheduled delay
1198
- # @return [Policy] a copied policy with the elapsed bound
1199
- # @example +Retry.fixed(5, 1.seconds).withMaximumElapsed(2.seconds)+.
1200
- let withMaximumElapsed(maximumElapsed: Duration) -> Policy = Policy {
1201
- maximumAttempts: @maximumAttempts,
1202
- initialDelay: @initialDelay,
1203
- multiplier: @multiplier,
1204
- maximumDelay: @maximumDelay,
1205
- maximumElapsed: Just(maximumElapsed),
1206
- jitterFraction: @jitterFraction
1207
- }
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
1208
1390
 
1209
- # Returns the same schedule with symmetric bounded jitter. A fraction of
1210
- # +0.25+ selects each actual delay from 75% through 125% of its scheduled
1211
- # value. Fractions are clamped to +0.0..1.0+.
1212
- #
1213
- # Jitter prevents many workers that failed together from retrying together.
1214
- # It changes delay timing, never the number of attempts or the backoff cap.
1215
- #
1216
- # @param fraction [Float] maximum proportional variation on either side
1217
- # @return [Policy] a copied policy with bounded jitter
1218
- #
1219
- # @example Spreading retries by up to 20 percent
1220
- # Retry.exponential(4, 1.seconds, 10.seconds).withJitter(0.2)
1221
- let withJitter(fraction: Float) -> Policy = Policy {
1222
- maximumAttempts: @maximumAttempts,
1223
- initialDelay: @initialDelay,
1224
- multiplier: @multiplier,
1225
- maximumDelay: @maximumDelay,
1226
- maximumElapsed: @maximumElapsed,
1227
- jitterFraction: if fraction < 0.0 then 0.0 else if fraction > 1.0 then 1.0 else fraction end end
1228
- }
1229
- 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
1230
1408
 
1231
- # Runs +operation+ until it succeeds or exhausts the policy.
1232
- #
1233
- # The last application error is returned unchanged. This helper performs no
1234
- # network-specific classification: callers decide what operation to wrap.
1235
- #
1236
- # @param policy [Policy] the bounded schedule
1237
- # @param operation [Block<Result<X,E>>] a fresh attempt
1238
- # @return [Result<X,E>] the first success or final failure
1239
- #
1240
- # @example Retrying an idempotent health check
1241
- # Retry.run(Retry.fixed(3, 250.milliseconds)) do
1242
- # HTTP.get("https://service.example.com/health")
1243
- # end
1244
- run : Policy -> Block<Result<X, E>> -> Result<X, E>
1245
- 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
1246
1417
 
1247
- # Runs with an application-specific error predicate.
1248
- #
1249
- # A false predicate returns that error immediately. The predicate is evaluated
1250
- # only when another attempt would otherwise be possible.
1251
- #
1252
- # @param policy [Policy] the bounded schedule
1253
- # @param predicate [Predicate<E>] whether an error may be retried
1254
- # @param operation [Block<Result<X,E>>] a fresh attempt
1255
- # @return [Result<X,E>] the first success or final/non-retryable failure
1256
- #
1257
- # @example Retrying timeouts but returning parse failures immediately
1258
- # Retry.run(policy, { |error| error.kind == Timeout }) do
1259
- # client.get(url)
1260
- # end
1261
- run : Policy -> Predicate<E> -> Block<Result<X, E>> -> Result<X, E>
1262
- foul run(policy, predicate, operation) = runWithRandom(policy, predicate, { |delay| Task.sleep(delay) }, { Kex.Intrinsic.Retry.randomUnit() }, operation)
1263
-
1264
- # Runs with injected error classification and sleeping.
1265
- #
1266
- # This is the deterministic testing seam: a fake sleeper can record durations
1267
- # or advance a virtual clock. Production callers normally use +Retry.run+.
1268
- #
1269
- # @param policy [Policy] the bounded schedule
1270
- # @param predicate [Predicate<E>] whether an error may be retried
1271
- # @param sleeper [Sleeper] performs or records each scheduled delay
1272
- # @param operation [Block<Result<X,E>>] a fresh attempt
1273
- # @return [Result<X,E>] the first success or final failure
1274
- runWith : Policy -> Predicate<E> -> Sleeper -> Block<Result<X, E>> -> Result<X, E>
1275
- foul runWith(policy, predicate, sleeper, operation) = runWithRandom(policy, predicate, sleeper, { 0.5 }, operation)
1276
-
1277
- # Runs with injected sleeping and a random source returning a value in
1278
- # +0.0..1.0+. Out-of-range test values are clamped. Production +run+ uses a
1279
- # cryptographically secure backend source; this overload makes jitter specs
1280
- # deterministic.
1281
- runWithRandom : Policy -> Predicate<E> -> Sleeper -> Block<Float> -> Block<Result<X, E>> -> Result<X, E>
1282
- foul runWithRandom(policy, predicate, sleeper, random, operation) = attempt(policy, predicate, sleeper, random, operation, 1, policy.initialDelay, Duration.seconds(0))
1283
-
1284
- 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
1285
- let result = operation()
1286
- return match result do
1287
- Ok(value) => Ok(value)
1288
- Error(error) => do
1289
- if number >= policy.maximumAttempts || !predicate(error)
1290
- 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
1291
1423
  end
1292
- let sample = random()
1293
- let boundedSample = if sample < 0.0 then 0.0 else if sample > 1.0 then 1.0 else sample end end
1294
- let jittered = Duration { seconds: delay.seconds * (1.0 + ((boundedSample * 2.0 - 1.0) * policy.jitterFraction)) }
1295
- let nextElapsed = Duration { seconds: elapsed.seconds + jittered.seconds }
1296
- 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
1297
1429
  None => true
1298
- Just(maximum) => nextElapsed.seconds <= maximum.seconds
1430
+ Just(limit) => nextElapsed <= (if limit.seconds < 0.0 then 0.0 else limit.seconds end)
1299
1431
  end
1300
- if !withinElapsed
1301
- return Error(error)
1432
+ if !allowed
1433
+ return result
1302
1434
  end
1303
- sleeper(jittered)
1304
- let nextSeconds = delay.seconds * policy.multiplier
1305
- let bounded = if nextSeconds > policy.maximumDelay.seconds then policy.maximumDelay else Duration { seconds: nextSeconds } end
1306
- 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)
1307
1448
  end
1308
1449
  end
1309
1450
  end
1310
-
1311
- # The imported public namespace: `using Control.Retry` then `Retry.run(...)`.
1312
- module Retry do
1313
- # Public imported alias of +Control.Retry.fixed+.
1314
- fixed : Integer -> Duration -> Policy
1315
- let fixed(maximumAttempts, delay) = Control.Retry.fixed(maximumAttempts, delay)
1316
-
1317
- # Public imported alias of +Control.Retry.exponential+.
1318
- exponential : Integer -> Duration -> Duration -> Policy
1319
- let exponential(maximumAttempts, initialDelay, maximumDelay) = Control.Retry.exponential(maximumAttempts, initialDelay, maximumDelay)
1320
-
1321
- # Public imported alias of +Control.Retry.run+.
1322
- run : Policy -> Block<Result<X, E>> -> Result<X, E>
1323
- foul run(policy, operation) = Control.Retry.run(policy, operation)
1324
-
1325
- # Retries only errors accepted by +predicate+.
1326
- run : Policy -> Predicate<E> -> Block<Result<X, E>> -> Result<X, E>
1327
- foul run(policy, predicate, operation) = Control.Retry.run(policy, predicate, operation)
1328
-
1329
- # Deterministic seam with an injected sleeper, primarily for specifications.
1330
- runWith : Policy -> Predicate<E> -> Sleeper -> Block<Result<X, E>> -> Result<X, E>
1331
- foul runWith(policy, predicate, sleeper, operation) = Control.Retry.runWith(policy, predicate, sleeper, operation)
1332
-
1333
- # Deterministic seam with injected sleeping and random sampling.
1334
- runWithRandom : Policy -> Predicate<E> -> Sleeper -> Block<Float> -> Block<Result<X, E>> -> Result<X, E>
1335
- foul runWithRandom(policy, predicate, sleeper, random, operation) = Control.Retry.runWithRandom(policy, predicate, sleeper, random, operation)
1336
- end
1337
1451
  # A first-in-first-out queue.
1338
1452
  #
1339
1453
  # Opt-in: nothing here is in scope until `using Data.Queue`.
@@ -2481,6 +2595,89 @@ module Digest do
2481
2595
  fileSha256 : String -> String?
2482
2596
  let fileSha256(path) = Kex.Intrinsic.Digest.fileSha256(path)
2483
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
2484
2681
  # Traversal operations that every foldable collection gets for free.
2485
2682
  #
2486
2683
  # A type becomes +Foldable+ by implementing one method, +reduce+; the rest:
@@ -4073,6 +4270,33 @@ module FS do
4073
4270
  # because it is not lexical: it asks the process where it is.
4074
4271
  absolute : FilePath -> String?
4075
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)
4076
4300
  end
4077
4301
 
4078
4302
  # Path arithmetic: joining, splitting, normalising and comparing paths.
@@ -4919,6 +5143,14 @@ private do
4919
5143
  end
4920
5144
 
4921
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
4922
5154
  let cursor = skipIgnored(Input { input: text }, allowComments).try
4923
5155
  let (value, afterValue) = parseValue(cursor, allowComments).try
4924
5156
  let rest = skipIgnored(afterValue, allowComments).try
@@ -4953,25 +5185,40 @@ end
4953
5185
  # JSON.parse(JSON.stringify({ a: 1 })) # => Ok({ a: 1 })
4954
5186
  stringify : Any -> String
4955
5187
  let stringify(value: Any) -> String do
4956
- match Kex.Intrinsic.Kex.kind(value) do
4957
- :none => return "null"
4958
- :bool => return value then "true" else "false"
4959
- :integer => return "${value}"
4960
- :float => return "${value}"
4961
- :string => return encodeString(value)
4962
- :list => return "[${value.map(~stringify).join(",")}]"
4963
- :map => do
4964
- let fields = Kex.Intrinsic.Map.entries(value).map do |entry|
4965
- let (key, item) = entry
4966
- "${encodeString(objectKey(key))}:${stringify(item)}"
4967
- end
4968
- return "{${fields.join(",")}}"
4969
- end
4970
- _ => 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
4971
5192
  end
5193
+ return stringifyValue(value)
4972
5194
  end
4973
5195
 
4974
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
+
4975
5222
  # A JSON object key is a string, but a Kex map literal is written with ATOM
4976
5223
  # keys (`{ n: 1 }`): the shape most values being stringified actually have.
4977
5224
  # Rendering one gives `:n`, so drop the leading colon; anything else goes
@@ -5750,6 +5997,144 @@ module Kex.AST do
5750
5997
  parseExpression : String -> Result<Expression, ParseError>
5751
5998
  let parseExpression(source) = Kex.Intrinsic.AST.parseExpression(source)
5752
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
+
5753
6138
  # A type as it was written in source.
5754
6139
  #
5755
6140
  # +typeRefText+ renders one back to the source spelling.
@@ -6310,6 +6695,17 @@ make [Number] do
6310
6695
  let max = Kex.Intrinsic.List.max(this)
6311
6696
  end
6312
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
+
6313
6709
  make [X], implement: Enumerable, Foldable do
6314
6710
  # Returns the first element wrapped in +Just+, or +None+ if the list is empty.
6315
6711
  #
@@ -6702,15 +7098,6 @@ make [X], implement: Enumerable, Foldable do
6702
7098
  zip :> [Y] -> [(X, Y)]
6703
7099
  let zip(other) = Kex.Intrinsic.List.zip(this, other)
6704
7100
 
6705
- # Flattens exactly one level of nesting. Only valid on lists of lists.
6706
- #
6707
- # @return [[Y]]
6708
- #
6709
- # @example
6710
- # [[1, 2], [3, 4]].flatten # => [1, 2, 3, 4]
6711
- flatten :> [X]
6712
- let flatten = Kex.Intrinsic.List.flatten(this)
6713
-
6714
7101
  # Returns the elements sorted in ascending natural order.
6715
7102
  #
6716
7103
  # @return [[X]]
@@ -7894,6 +8281,11 @@ module Mock do
7894
8281
  foul file?(path) = this.cannedRead(path) != None
7895
8282
  foul directory?(path) = false
7896
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
7897
8289
 
7898
8290
  # A fake is a value, so there is nowhere for a write to go. Refusing is
7899
8291
  # the honest answer and the useful one: a test that did not expect a
@@ -8442,6 +8834,22 @@ module Headers do
8442
8834
  from : [(String, String)] -> Result<Headers, NetError>
8443
8835
  let from(entries) = Kex.Intrinsic.NetHTTP.headers(entries)
8444
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
+
8445
8853
  # Parses CRLF- or LF-separated header fields.
8446
8854
  #
8447
8855
  # Use this at a protocol boundary when headers arrive as text. Application
@@ -8760,6 +9168,8 @@ end
8760
9168
  # socket.close
8761
9169
  module Net.HTTP.WebSocket
8762
9170
 
9171
+ using Net.HTTP
9172
+
8763
9173
  # A complete high-level WebSocket message. Fragmentation and ping/pong control
8764
9174
  # frames are handled by the connection runtime.
8765
9175
  #
@@ -8783,10 +9193,31 @@ record Session do
8783
9193
  subprotocol : String?
8784
9194
  end
8785
9195
 
8786
- # 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.
8787
9198
  type Connection
8788
9199
 
8789
- # 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.
8790
9221
  module WebSocket do
8791
9222
  # Opens a +ws:+ or verified +wss:+ connection with default options.
8792
9223
  #
@@ -8811,6 +9242,32 @@ module WebSocket do
8811
9242
  # maximumMessageBytes: 1024 * 1024
8812
9243
  # }).try
8813
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)
8814
9271
  end
8815
9272
 
8816
9273
  make Connection do
@@ -8839,6 +9296,32 @@ make Connection do
8839
9296
  # end
8840
9297
  # end
8841
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)
8842
9325
  # Returns handshake details negotiated with the server.
8843
9326
  #
8844
9327
  # @return [Session] the selected subprotocol, if any
@@ -9564,7 +10047,13 @@ make Integer do
9564
10047
  #
9565
10048
  # @example Repeating an action
9566
10049
  # retries.times { |_| attemptConnection }
10050
+ #
10051
+ # @example Repeating an action that needs no index
10052
+ # 3.times do
10053
+ # IO.printLine("hi")
10054
+ # end
9567
10055
  times :> (Integer -> Void) -> Void
10056
+ times :> Block<Void> -> Void
9568
10057
  let times(block) = Kex.Intrinsic.Integer.times(this, block)
9569
10058
 
9570
10059
  # Returns the integer unchanged. Present so that code written against
@@ -11935,6 +12424,409 @@ make Reference do
11935
12424
  demonitor :> Void
11936
12425
  let demonitor = Kex.Intrinsic.Process.demonitor(this)
11937
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
11938
12830
  # A span between two bounds, written +(1..10)+ or +('a'..'z')+.
11939
12831
  #
11940
12832
  # A range stores only its two endpoints and computes everything else from
@@ -14251,10 +15143,13 @@ type Node = Text(String)
14251
15143
  type Tag = Scalar(String)
14252
15144
  | Tags([String])
14253
15145
 
14254
- # 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).
14255
15148
  type TemplateError = UnterminatedTag(Integer)
14256
15149
  | UnterminatedFrontmatter
14257
15150
  | MalformedFrontmatterLine(String)
15151
+ | UndefinedVariable(String)
15152
+ | UnsupportedControl(String)
14258
15153
 
14259
15154
  # One entry from a `params: [...]` frontmatter list: a name, and its
14260
15155
  # optional `: Type` annotation. `type` is raw text: `""` for a bare name,
@@ -14346,6 +15241,86 @@ let escapeHtml(text: String) -> String do
14346
15241
  .replace("'", "&#39;")
14347
15242
  end
14348
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
+
14349
15324
  private do
14350
15325
  # True when `cursor` sits at the very start of the input and that line is
14351
15326
  # exactly `---`: the only place a frontmatter block may open.
@@ -14375,17 +15350,21 @@ private do
14375
15350
  # A cursor advanced to the next `\n` (not past it), and the text skipped.
14376
15351
  #
14377
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.
14378
15359
  let scanLine(cursor: Input) -> (String, Input) do
14379
15360
  var cur = cursor
14380
- var chars: [Char] = []
15361
+ let start = cur.pos
14381
15362
  loop do
14382
15363
  break if cur.peek == None
14383
15364
  break if cur.peek == Just('\n')
14384
- let Just(ch) = cur.peek
14385
- chars.push!(ch)
14386
15365
  cur.advance!
14387
15366
  end
14388
- (chars.join(""), cur)
15367
+ (cur.input.chars.drop(start).take(cur.pos - start).join(""), cur)
14389
15368
  end
14390
15369
 
14391
15370
  # A line's text with any trailing `\r` dropped, for a source that mixes
@@ -14516,23 +15495,31 @@ private do
14516
15495
  # collides with `List#takeWhile`, and calling it through an `Input` receiver
14517
15496
  # sends overload resolution down the wrong path (`json.kex`'s own scanners
14518
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.
14519
15504
  let scanText(cursor: Input) -> (String, Input) do
14520
15505
  var cur = cursor
14521
- var chars: [Char] = []
15506
+ var pieces: [String] = []
15507
+ var runStart = cur.pos
14522
15508
  loop do
14523
15509
  break if cur.peek == None
14524
15510
  break if atTagOpen?(cur)
14525
15511
  if cur.peek == Just('<') && cur.peekAt(1) == Just('%') && cur.peekAt(2) == Just('%')
14526
- chars.push!('<')
14527
- chars.push!('%')
15512
+ pieces.push!(cur.input.chars.drop(runStart).take(cur.pos - runStart).join(""))
15513
+ pieces.push!("<%")
14528
15514
  cur.advanceBy!(3)
15515
+ runStart = cur.pos
14529
15516
  else
14530
- let Just(ch) = cur.peek
14531
- chars.push!(ch)
14532
15517
  cur.advance!
15518
+ ()
14533
15519
  end
14534
15520
  end
14535
- (chars.join(""), cur)
15521
+ pieces.push!(cur.input.chars.drop(runStart).take(cur.pos - runStart).join(""))
15522
+ (pieces.join(""), cur)
14536
15523
  end
14537
15524
 
14538
15525
  # Strips trailing spaces and tabs (not newlines): the indentation a `<%-`
@@ -14616,16 +15603,17 @@ private do
14616
15603
 
14617
15604
  # Reads a tag's body up to its closer, `%>` or `-%>`. Answers the raw text,
14618
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).
14619
15610
  let scanTagBody(cursor: Input) -> Result<(String, Bool, Input), TemplateError> do
14620
15611
  let start = cursor.pos
14621
15612
  var cur = cursor
14622
- var chars: [Char] = []
14623
15613
  loop do
14624
15614
  return Error(UnterminatedTag(start)) if cur.peek == None
14625
- return Ok((chars.join(""), true, cur.advanceBy(3))) if cur.peek == Just('-') && cur.peekAt(1) == Just('%') && cur.peekAt(2) == Just('>')
14626
- return Ok((chars.join(""), false, cur.advanceBy(2))) if cur.peek == Just('%') && cur.peekAt(1) == Just('>')
14627
- let Just(ch) = cur.peek
14628
- 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('>')
14629
15617
  cur.advance!
14630
15618
  end
14631
15619
  end
@@ -18990,6 +19978,21 @@ module Query do
18990
19978
  from : [(String, String?)] -> Query
18991
19979
  let from(entries) = Query { entries: entries }
18992
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
+
18993
19996
  # Parses generic URI query encoding; ++ remains a literal plus.
18994
19997
  #
18995
19998
  # @param text [String] encoded query text without the leading question mark
@@ -19012,6 +20015,21 @@ module Form do
19012
20015
  from : [(String, String)] -> Form
19013
20016
  let from(entries) = Kex.Intrinsic.URI.formFrom(entries)
19014
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
+
19015
20033
  # Parses form encoding where ++ represents a space.
19016
20034
  #
19017
20035
  # @param text [String] an +application/x-www-form-urlencoded+ body