@kexhq/kex 0.4.0-beta.2 → 0.4.0-beta.4

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.
@@ -171,6 +171,50 @@ make [A], implement: Monoid do
171
171
  # groups.reduce([]) { |acc, g| acc.combine(g) }
172
172
  let combine(other: This) -> This = this + other
173
173
  end
174
+ # Atoms: the +:name+ values. An atom is its own name — two atoms with the same
175
+ # text are the same value, and comparing them is as cheap as comparing numbers.
176
+ #
177
+ # :ok
178
+ # :b@localhost # an interior `@` is part of a bare atom
179
+ # :"b@host.example.com" # the quoted form spells any text
180
+ #
181
+ # Most atoms are written in the source. From text:
182
+ #
183
+ # "hello".as(Atom) # at compile time, from a literal
184
+ # Atom.from(text) # at run time, making the atom — a node name, say
185
+ # text.to(Atom) # at run time, only an atom that already exists: Atom?
186
+ #
187
+ # and +string+ gives the text back.
188
+ module Atom do
189
+ # Returns the atom whose name is +text+.
190
+ #
191
+ # On the BEAM atoms are never freed, and a node holds at most about a
192
+ # million. Build atoms from a bounded set of names — node names, config
193
+ # keys — never from untrusted input; +text.to(Atom)+ is the safe form for
194
+ # that, since it only finds atoms that already exist.
195
+ #
196
+ # @param text [String] the atom's name
197
+ # @return [Atom] the atom
198
+ #
199
+ # @example
200
+ # Atom.from("ok") == :ok # => true
201
+ # Atom.from("app@${host}") # => :"app@myhost"
202
+ from : String -> Atom
203
+ let from(text) = Kex.Intrinsic.Atom.from(text)
204
+ end
205
+
206
+ make Atom do
207
+ # Returns the atom's name as text, without the leading colon. Total, like
208
+ # +Char.string+: every atom has a name.
209
+ #
210
+ # @return [String] the name
211
+ #
212
+ # @example
213
+ # :ok.string # => "ok"
214
+ # :"b@host.example.com".string # => "b@host.example.com"
215
+ string :> String
216
+ let string = Kex.Intrinsic.Atom.name(this)
217
+ end
174
218
  # An opaque, immutable sequence of bytes. A +Binary+ never implicitly becomes
175
219
  # text.
176
220
  #
@@ -312,6 +356,31 @@ make Binary, implement: Showable, Inspectable do
312
356
  # data.at(-1) # => None
313
357
  let at(index: Integer) -> Byte? = Kex.Intrinsic.Binary.at(this, index)
314
358
 
359
+ # The byte at +index+, counting from zero. The same as +at+, matching the
360
+ # +get+ that +List+ and +String+ carry.
361
+ #
362
+ # @param index [Integer] the position to read
363
+ # @return [Byte?] the byte, or +None+ when the index is outside the binary
364
+ #
365
+ # @example
366
+ # Binary.fromBytes([104, 105]).get(1) # => Just(105)
367
+ # Binary.fromBytes([104, 105]).get(9) # => None
368
+ get :> Integer -> Byte?
369
+ let get(index) = this.at(index)
370
+
371
+ # The byte at +index+, or +default+ when the index is outside the binary.
372
+ # The total form of +get+: the byte comes back unwrapped.
373
+ #
374
+ # @param index [Integer] the position to read
375
+ # @param default [Byte] the value to use when out of range
376
+ # @return [Byte] the byte, or +default+
377
+ #
378
+ # @example
379
+ # Binary.fromBytes([104, 105]).get(0, 0) # => 104
380
+ # Binary.fromBytes([104, 105]).get(9, 0) # => 0
381
+ get :> Integer -> Byte -> Byte
382
+ let get(index, default) = this.at(index).or(default)
383
+
315
384
  # The first +count+ bytes.
316
385
  #
317
386
  # Clamped at both ends, like the list operations: a +count+ of zero or less
@@ -1108,231 +1177,338 @@ module Console do
1108
1177
  enabled? : Bool
1109
1178
  let enabled? = Kex.Intrinsic.Console.enabled?
1110
1179
  end
1111
- # Bounded retries for operations whose failures can be classified by the
1112
- # application.
1113
- #
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.
1180
+ # Retry an operation after it returns an error.
1118
1181
  #
1182
+ # @example Retry a request and handle the final result
1119
1183
  # using Control.Retry
1184
+ # using Net.HTTP
1120
1185
  #
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")
1186
+ # let result = Retry.run(attempts: 5) do
1187
+ # HTTP.get("https://api.example.com/inventory")
1188
+ # end
1189
+ # match result do
1190
+ # Ok(response) => IO.printLine(response.status.code)
1191
+ # Error(error) => IO.printLine(error.message)
1125
1192
  # end
1126
- module Control.Retry
1127
-
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
- }
1167
-
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
- }
1189
-
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
- }
1208
-
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
1230
-
1231
- # Runs +operation+ until it succeeds or exhausts the policy.
1232
1193
  #
1233
- # The last application error is returned unchanged. This helper performs no
1234
- # network-specific classification: callers decide what operation to wrap.
1194
+ # @example Fixed delays: three waits of 250 milliseconds
1195
+ # let schedule = Retry.Schedule {
1196
+ # attempts: 4, delay: 250.milliseconds, backoff: 1.0,
1197
+ # maximumDelay: 250.milliseconds, jitter: 0.0
1198
+ # }
1199
+ # Retry.run(schedule: schedule) do
1200
+ # Error("not ready")
1201
+ # end
1202
+ # # => Error("not ready"), after four calls and 750 ms of sleep
1203
+ #
1204
+ # @example Exponential delays: 200, 400, 800, 1600 milliseconds
1205
+ # let schedule = Retry.Schedule {
1206
+ # attempts: 5, delay: 200.milliseconds, backoff: 2.0,
1207
+ # maximumDelay: 5.seconds, jitter: 0.0
1208
+ # }
1209
+ # Retry.run(schedule: schedule) do
1210
+ # Error("busy")
1211
+ # end
1212
+ # # => Error("busy"), after five calls and three seconds of sleep
1235
1213
  #
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
1214
+ # @example Capped delays: 500 ms, 1 s, 2 s, 2 s, 2 s
1215
+ # Retry.Schedule {
1216
+ # attempts: 6, delay: 500.milliseconds, backoff: 2.0,
1217
+ # maximumDelay: 2.seconds, jitter: 0.0
1218
+ # }
1239
1219
  #
1240
- # @example Retrying an idempotent health check
1241
- # Retry.run(Retry.fixed(3, 250.milliseconds)) do
1242
- # HTTP.get("https://service.example.com/health")
1220
+ # @example Use one schedule for two requests
1221
+ # using Net.HTTP
1222
+ # let schedule = Retry.Schedule { attempts: 5 }
1223
+ # let inventory = Retry.run(schedule: schedule) do
1224
+ # HTTP.get("https://api.example.com/inventory")
1243
1225
  # 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)
1246
-
1247
- # Runs with an application-specific error predicate.
1226
+ # let orders = Retry.run(schedule: schedule) do
1227
+ # HTTP.get("https://api.example.com/orders")
1228
+ # end
1229
+ # # Each request gets up to five attempts, independently.
1248
1230
  #
1249
- # A false predicate returns that error immediately. The predicate is evaluated
1250
- # only when another attempt would otherwise be possible.
1231
+ # @example Set retry options directly
1232
+ # Retry.run(attempts: 5, delay: 200.milliseconds, jitter: 0.0) do
1233
+ # Ok("ready")
1234
+ # end
1235
+ # # => Ok("ready")
1236
+ #
1237
+ # @example Limit the time spent sleeping
1238
+ # Retry.run(
1239
+ # attempts: 5, delay: 1.seconds, backoff: 2.0,
1240
+ # jitter: 0.0, maximumTotalDelay: Just(2.seconds)
1241
+ # ) do
1242
+ # Error("busy")
1243
+ # end
1244
+ # # => Error("busy"), two calls and one second of sleep
1245
+ #
1246
+ # @example Test jitter without waiting
1247
+ # Retry.run(
1248
+ # attempts: 2, delay: 4.seconds, jitter: 0.25,
1249
+ # sleeper: { |wait| Assert.equal(wait, 3.seconds) },
1250
+ # random: { 0.0 }
1251
+ # ) do
1252
+ # Error("temporary")
1253
+ # end
1254
+ # # => Error("temporary"), two calls and one fake sleep
1251
1255
  #
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
+ # @example Report progress
1257
+ # using Net.HTTP
1258
+ # Retry.run(attempts: 5, onRetry: { |info|
1259
+ # let progress = "retrying ${info.nextAttempt}/${info.maximumAttempts}"
1260
+ # IO.printLine("${progress} in ${info.delay.seconds} seconds")
1261
+ # }) do
1262
+ # HTTP.get("https://api.example.com/inventory")
1263
+ # end
1256
1264
  #
1257
- # @example Retrying timeouts but returning parse failures immediately
1258
- # Retry.run(policy, { |error| error.kind == Timeout }) do
1259
- # client.get(url)
1265
+ # @example Retry temporary HTTP failures and selected statuses
1266
+ # using Net
1267
+ # using Net.HTTP
1268
+ # let schedule = Retry.Schedule { attempts: 5 }
1269
+ # let outcome = Retry.run(schedule: schedule) do
1270
+ # let result = HTTP.get("https://api.example.com/inventory")
1271
+ # match result do
1272
+ # Error(error) => if error.kind == Timeout || error.kind == Connect
1273
+ # Retry.again(result)
1274
+ # else
1275
+ # Retry.done(result)
1276
+ # end
1277
+ # Ok(response) => if [429, 502, 503, 504].contains?(response.status.code)
1278
+ # Retry.again(result)
1279
+ # else
1280
+ # Retry.done(result)
1281
+ # end
1282
+ # end
1260
1283
  # 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)
1291
- 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
1297
- None => true
1298
- Just(maximum) => nextElapsed.seconds <= maximum.seconds
1299
- end
1300
- if !withinElapsed
1301
- return Error(error)
1302
- 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)
1307
- end
1308
- end
1309
- end
1284
+ # match outcome do
1285
+ # Done(result) => IO.printLine(result)
1286
+ # Again(last) => IO.printLine("retry budget exhausted: ${last}")
1287
+ # end
1288
+ module Control.Retry
1310
1289
 
1311
- # The imported public namespace: `using Control.Retry` then `Retry.run(...)`.
1290
+ # +run+ calls the block immediately. +Ok+ stops; +Error+ waits and retries.
1291
+ # When the schedule runs out, it returns the last error unchanged.
1292
+ # Use +again+ and +done+ when your application needs to choose what to retry.
1293
+ #
1294
+ # HTTP 429 and 503 responses are +Ok(response)+, so retrying those statuses
1295
+ # requires an explicit decision. For writes, make sure repeating the request
1296
+ # is safe, for example by using an idempotency key. Set request timeouts
1297
+ # separately: the schedule limits sleep, not time spent inside the operation.
1312
1298
  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)
1299
+ # Timing and attempt limits for +run+.
1300
+ #
1301
+ # Defaults: three attempts, starting with a 100 ms wait, doubling up to
1302
+ # 5 seconds, with 20% jitter. All fields are optional.
1303
+ # A schedule holds settings. Reusing it starts each run from attempt one.
1304
+ #
1305
+ # * +attempts+: maximum executions, including the first; default 3.
1306
+ # * +delay+: initial base wait; default 100 milliseconds.
1307
+ # * +backoff+: base-delay multiplier; default 2.0, or 1.0 for fixed waits.
1308
+ # * +maximumDelay+: cap on each actual wait, including jitter; default 5 seconds.
1309
+ # * +jitter+: symmetric proportional variation; default 0.2, or 0.0 to disable.
1310
+ # * +maximumTotalDelay+: cumulative sleep allowance; default +None+.
1311
+ # Operation execution time is excluded. A wait that exceeds the remaining
1312
+ # allowance ends the run without sleeping or calling the operation again.
1313
+ #
1314
+ record Schedule do
1315
+ # Maximum executions including the initial call. Default: 3.
1316
+ # Values below 1 are treated as 1: the initial call always happens.
1317
+ attempts : Integer = 3
1318
+ # Base wait before the second attempt. Default: 100 milliseconds.
1319
+ # Negative durations are treated as zero; the delay cap applies here too.
1320
+ delay : Duration = Duration.milliseconds(100)
1321
+ # Base-delay multiplier. Default: 2.0. Values below 1 become 1.
1322
+ # Set to 1.0 for fixed delays. Jitter never changes the next base delay.
1323
+ backoff : Float = 2.0
1324
+ # Maximum actual wait, including jitter. Default: 5 seconds.
1325
+ # Negative durations become zero. Hitting this cap does not stop retries.
1326
+ maximumDelay : Duration = Duration.seconds(5)
1327
+ # Symmetric proportional variation. Default: 0.2; clamped to 0..1.
1328
+ # For a 200 ms base, 0.2 samples 160..240 ms, then applies the cap.
1329
+ # Set to 0.0 for exact waits. Samples are independent between runs.
1330
+ jitter : Float = 0.2
1331
+ # Optional bound on cumulative actual sleep. Default: None.
1332
+ # A wait exceeding the remaining allowance stops retries before sleeping.
1333
+ # Negative bounds become zero. Operation execution time is not counted.
1334
+ maximumTotalDelay : Duration? = None
1335
+ end
1316
1336
 
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)
1337
+ # Whether to retry, together with the last result.
1338
+ # +Done(value)+ means the block stopped deliberately. +Again(value)+
1339
+ # returned by +run+ means the schedule was exhausted before completion.
1340
+ # The value inside can be any application result, including an +Error+.
1341
+ type Decision<X> = Again(X) | Done(X)
1342
+
1343
+ # Retry this operation if the schedule has room for another attempt.
1344
+ #
1345
+ # @param value [X] the last outcome, retained if the schedule is exhausted
1346
+ # @return [Decision<X>] +Again(value)+
1347
+ again : X -> Decision<X>
1348
+ let again(value) = Again(value)
1349
+
1350
+ # Stop now. The supplied value may be a success or a permanent failure.
1351
+ #
1352
+ # @param value [X] the final application outcome
1353
+ # @return [Decision<X>] +Done(value)+
1354
+ done : X -> Decision<X>
1355
+ let done(value) = Done(value)
1356
+
1357
+ # Progress passed to +onRetry+ before the next wait.
1358
+ #
1359
+ # The callback runs only when another attempt is allowed. It does not run
1360
+ # for the first call, a successful result, +done+, or exhaustion.
1361
+ # Durations count scheduled sleep; they exclude time spent in the operation.
1362
+ #
1363
+ # * +attempt+: the just-completed attempt, starting at 1.
1364
+ # * +nextAttempt+: the attempt that follows the upcoming wait.
1365
+ # * +maximumAttempts+: total permitted executions, including the first.
1366
+ # * +remainingAttempts+: executions remaining, including the upcoming one.
1367
+ # * +delay+: actual upcoming wait, after jitter and the delay cap.
1368
+ # * +totalDelay+: sleep already performed, excluding the upcoming wait.
1369
+ # * +result+: the last +Error+ or +Again+, including its application payload.
1370
+ record Info<X> do
1371
+ # The attempt that just finished, starting at 1.
1372
+ attempt : Integer
1373
+ # The attempt that will run after the upcoming delay.
1374
+ nextAttempt : Integer
1375
+ # Total allowed executions, normalized to at least 1.
1376
+ maximumAttempts : Integer
1377
+ # Executions still allowed, including the upcoming attempt.
1378
+ remainingAttempts : Integer
1379
+ # The actual upcoming wait, after jitter and the delay cap.
1380
+ delay : Duration
1381
+ # Sleep already performed in this run, excluding the upcoming wait.
1382
+ totalDelay : Duration
1383
+ # The last Error or Again value, including the application's payload.
1384
+ result : X
1385
+ end
1320
1386
 
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)
1387
+ # Call the block until it succeeds, returns +done+, or runs out of attempts or sleep time.
1388
+ #
1389
+ # The first attempt is immediate. Every later attempt follows one sleep.
1390
+ # There is no sleep after success or the final error. Named timing options
1391
+ # override the corresponding schedule field for this execution only.
1392
+ #
1393
+ # Return a +Result+ from the block for automatic retries: +Ok+ stops and
1394
+ # +Error+ retries. If no more retries are allowed, +run+ returns the last error.
1395
+ #
1396
+ # Return +done(value)+ or +again(value)+ to make the decision yourself.
1397
+ # The final +Done+ means the block chose to stop; +Again+ means it wanted
1398
+ # another try but the schedule ran out. Use one return form throughout the
1399
+ # block. Runtime faults propagate; they are not retried.
1400
+ #
1401
+ # Out-of-range settings are adjusted: attempts below one become one, negative
1402
+ # durations become zero, backoff below one becomes one, jitter and random
1403
+ # samples are clamped to 0..1. Non-finite Float settings are unsupported.
1404
+ #
1405
+ # @param operation [Block<X>] called again for each attempt; returns Result or Decision
1406
+ # @param schedule [Schedule] reusable settings; defaults to +Schedule {}+
1407
+ # @param attempts [Integer] total attempts; defaults to the schedule field
1408
+ # @param delay [Duration] initial wait; defaults to the schedule field
1409
+ # @param backoff [Float] delay multiplier; defaults to the schedule field
1410
+ # @param maximumDelay [Duration] sleep cap; defaults to the schedule field
1411
+ # @param jitter [Float] variation fraction; defaults to the schedule field
1412
+ # @param maximumTotalDelay [Duration?] sleep budget; defaults to the field
1413
+ # @param sleeper [Duration -> Void] defaults to real +Task.sleep+
1414
+ # @param random [Block<Float>] defaults to secure backend sampling in 0..1
1415
+ # @param onRetry [Info<X> -> Void] reports an allowed retry; defaults to no action
1416
+ # @return [X] first Ok/Done, or last Error/Again when retries run out
1417
+ #
1418
+ # +Done(Error(...))+ means the application stopped on a permanent error.
1419
+ # +Again(Ok(response))+ means an unacceptable HTTP status persisted until
1420
+ # exhaustion. HTTP status classification and Retry-After handling are the
1421
+ # application's responsibility; +run+ has no HTTP-specific behavior.
1422
+ #
1423
+ # The testing hooks are independent: replacing sleep keeps normal random
1424
+ # sampling, and replacing randomness keeps real sleep. A random sample of
1425
+ # 0 selects the lower jitter bound, 0.5 the base, and 1 the upper bound.
1426
+ # With zero jitter no random sample is needed.
1427
+ # lint:allow parameter-count — named options make each setting independent
1428
+ run : Block<X> -> Schedule -> Integer -> Duration -> Float -> Duration -> Float -> Duration? -> (Duration -> Void) -> Block<Float> -> (Info<X> -> Void) -> X
1429
+ foul run(
1430
+ operation,
1431
+ schedule = Schedule {},
1432
+ attempts = schedule.attempts,
1433
+ delay = schedule.delay,
1434
+ backoff = schedule.backoff,
1435
+ maximumDelay = schedule.maximumDelay,
1436
+ jitter = schedule.jitter,
1437
+ maximumTotalDelay = schedule.maximumTotalDelay,
1438
+ sleeper = { |wait| Task.sleep(wait) },
1439
+ random = { Kex.Intrinsic.Retry.randomUnit() },
1440
+ onRetry = { |_info| () }
1441
+ ) do
1442
+ let settings = Schedule {
1443
+ attempts: attempts,
1444
+ delay: delay,
1445
+ backoff: backoff,
1446
+ maximumDelay: maximumDelay,
1447
+ jitter: jitter,
1448
+ maximumTotalDelay: maximumTotalDelay
1449
+ }
1450
+ attempt(settings, sleeper, random, onRetry, operation, 1, boundedDelay(settings, delay.seconds), 0.0)
1451
+ end
1324
1452
 
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)
1453
+ private do
1454
+ retry? : Result<X, E> -> Bool
1455
+ let retry?(Ok(_)) = false
1456
+ let retry?(Error(_)) = true
1457
+ retry? : Decision<X> -> Bool
1458
+ let retry?(Done(_)) = false
1459
+ let retry?(Again(_)) = true
1460
+
1461
+ let clamp(value: Float, low: Float, high: Float) -> Float do
1462
+ if value < low
1463
+ low
1464
+ elif value > high
1465
+ high
1466
+ else
1467
+ value
1468
+ end
1469
+ end
1328
1470
 
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)
1471
+ let boundedDelay(schedule: Schedule, waitSeconds: Float) -> Float do
1472
+ let cap = if schedule.maximumDelay.seconds < 0.0
1473
+ 0.0
1474
+ else
1475
+ schedule.maximumDelay.seconds
1476
+ end
1477
+ clamp(waitSeconds, 0.0, cap)
1478
+ end
1332
1479
 
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)
1480
+ attempt : Schedule -> (Duration -> Void) -> Block<Float> -> (Info<X> -> Void) -> Block<X> -> Integer -> Float -> Float -> X
1481
+ foul attempt(schedule, sleeper, random, onRetry, operation, number, base, elapsed) do
1482
+ let result = operation()
1483
+ if number >= schedule.attempts || !retry?(result)
1484
+ return result
1485
+ end
1486
+ let fraction = clamp(schedule.jitter, 0.0, 1.0)
1487
+ let sample = fraction == 0.0 then 0.5 else clamp(random(), 0.0, 1.0)
1488
+ let sleepSeconds = boundedDelay(schedule, base * (1.0 + (sample * 2.0 - 1.0) * fraction))
1489
+ let nextElapsed = elapsed + sleepSeconds
1490
+ let allowed = match schedule.maximumTotalDelay do
1491
+ None => true
1492
+ Just(limit) => nextElapsed <= (limit.seconds < 0.0 then 0.0 else limit.seconds)
1493
+ end
1494
+ if !allowed
1495
+ return result
1496
+ end
1497
+ onRetry(Info {
1498
+ attempt: number,
1499
+ nextAttempt: number + 1,
1500
+ maximumAttempts: schedule.attempts,
1501
+ remainingAttempts: schedule.attempts - number,
1502
+ delay: Duration { seconds: sleepSeconds },
1503
+ totalDelay: Duration { seconds: elapsed },
1504
+ result: result
1505
+ })
1506
+ sleeper(Duration { seconds: sleepSeconds })
1507
+ let factor = schedule.backoff < 1.0 then 1.0 else schedule.backoff
1508
+ let nextBase = boundedDelay(schedule, base * factor)
1509
+ attempt(schedule, sleeper, random, onRetry, operation, number + 1, nextBase, nextElapsed)
1510
+ end
1511
+ end
1336
1512
  end
1337
1513
  # A first-in-first-out queue.
1338
1514
  #
@@ -2481,6 +2657,89 @@ module Digest do
2481
2657
  fileSha256 : String -> String?
2482
2658
  let fileSha256(path) = Kex.Intrinsic.Digest.fileSha256(path)
2483
2659
  end
2660
+ # Dimension algebra, implemented with ordinary Kex maps and integers.
2661
+ #
2662
+ # A base dimension is identified by the type of a marker value. Reuse that
2663
+ # marker type to share a dimension across modules. Its display name and the
2664
+ # marker's field values do not participate in dimensional arithmetic.
2665
+ #
2666
+ # Multiplication adds exponents, division subtracts them, and integer powers
2667
+ # multiply them. Zero exponents are removed so cancellation is structural.
2668
+ module Dimensions
2669
+
2670
+ # A normalized map from base identities to integer exponents.
2671
+ #
2672
+ # Construct dimensions with +Dimensions.base+ and compose them with arithmetic.
2673
+ # If importing a map, use +Dimensions.fromPowers+ to remove zero exponents.
2674
+ # This runtime representation does not itself provide static measure typing.
2675
+ record Dimension do
2676
+ powers : {Type: Integer}
2677
+ end
2678
+
2679
+ # The dimension with no remaining base factors.
2680
+ #
2681
+ # @return [Dimension] the multiplicative identity
2682
+ let one -> Dimension = Dimension { powers: {} }
2683
+
2684
+ # Defines a base dimension using the nominal type of a marker value.
2685
+ #
2686
+ # @param marker [A] a value of a dedicated marker type
2687
+ # @return [Dimension] that base dimension, raised to the first power
2688
+ let base(marker: A) -> Dimension = Dimension {
2689
+ powers: {}.put(Type.of(marker), 1)
2690
+ }
2691
+
2692
+ # Normalizes a map of base identities and powers.
2693
+ #
2694
+ # @param powers [Map<Type, Integer>] the dimension's exponents
2695
+ # @return [Dimension] the same dimension with zero exponents removed
2696
+ let fromPowers(powers: {Type: Integer}) -> Dimension = Dimension {
2697
+ powers: powers.filter { |_, exponent| exponent != 0 }
2698
+ }
2699
+
2700
+ make Dimension do
2701
+ # Whether every base factor has cancelled.
2702
+ #
2703
+ # @return [Bool] true for a dimensionless quantity
2704
+ dimensionless? :> Bool
2705
+ let dimensionless? = @powers.values.all? { |exponent| exponent == 0 }
2706
+
2707
+ # The exponent of the supplied marker's dimension, or zero if absent.
2708
+ #
2709
+ # @param marker [A] a value of the base marker type
2710
+ # @return [Integer] the base's exponent
2711
+ exponentOf :> A -> Integer
2712
+ let exponentOf(marker) = @powers.get(Type.of(marker), 0)
2713
+
2714
+ # Composes dimensions by adding their base exponents.
2715
+ #
2716
+ # @param other [Dimension] the other factor
2717
+ # @return [Dimension] the normalized product
2718
+ * :> Dimension -> Dimension
2719
+ let *(other) do
2720
+ let powers = other.powers.entries.reduce(@powers) do |result, entry|
2721
+ let (base, exponent) = entry
2722
+ result.put(base, result.get(base, 0) + exponent)
2723
+ end
2724
+ Dimensions.fromPowers(powers)
2725
+ end
2726
+
2727
+ # Composes dimensions by subtracting the denominator's exponents.
2728
+ #
2729
+ # @param other [Dimension] the denominator's dimension
2730
+ # @return [Dimension] the normalized quotient
2731
+ / :> Dimension -> Dimension
2732
+ let /(other) = this * (other ^ -1)
2733
+
2734
+ # Raises a dimension to an integer power, including zero and negatives.
2735
+ #
2736
+ # @param exponent [Integer] the power
2737
+ # @return [Dimension] the normalized powered dimension
2738
+ ^ :> Integer -> Dimension
2739
+ let ^(exponent) = Dimensions.fromPowers(
2740
+ @powers.mapValues { |power| power * exponent }
2741
+ )
2742
+ end
2484
2743
  # Traversal operations that every foldable collection gets for free.
2485
2744
  #
2486
2745
  # A type becomes +Foldable+ by implementing one method, +reduce+; the rest:
@@ -3413,7 +3672,7 @@ make FileHandle<CanRead, W>, implement: Readable do
3413
3672
  # condition. The same operation as +readLine+, under the name +IO.getLine+
3414
3673
  # uses.
3415
3674
  #
3416
- # @return [String?] the next line, or +None+ at end of file
3675
+ # @return [Result<String?, ReadError>] the next line, or +None+ at end of file
3417
3676
  #
3418
3677
  # @example Walking a file line by line
3419
3678
  # foul echo(handle: FileHandle<CanRead, W>) -> Void do
@@ -3432,7 +3691,7 @@ make FileHandle<CanRead, W>, implement: Readable do
3432
3691
  #
3433
3692
  # Answers +None+ at end of file.
3434
3693
  #
3435
- # @return [String?] the next character, or +None+ at end of file
3694
+ # @return [Result<String?, ReadError>] the next character, or +None+ at end of file
3436
3695
  #
3437
3696
  # @example
3438
3697
  # let firstChar = handle.get.or("")
@@ -3442,7 +3701,7 @@ make FileHandle<CanRead, W>, implement: Readable do
3442
3701
  # Reads the next line from the handle, without its newline. The same as
3443
3702
  # +getLine+, named for reading from a file rather than from a console.
3444
3703
  #
3445
- # @return [String?] the next line, or +None+ at end of file
3704
+ # @return [Result<String?, ReadError>] the next line, or +None+ at end of file
3446
3705
  #
3447
3706
  # @example
3448
3707
  # let header = handle.readLine.or("")
@@ -3457,7 +3716,7 @@ make FileHandle<CanRead, W>, implement: Readable do
3457
3716
  # Reads from the current position, so calling it after a +readLine+ gives
3458
3717
  # the rest of the file rather than the whole of it.
3459
3718
  #
3460
- # @return [String?] the remaining contents, or +None+
3719
+ # @return [Result<String, ReadError>] the remaining contents
3461
3720
  #
3462
3721
  # @example
3463
3722
  # let body = handle.read.or("")
@@ -3473,7 +3732,7 @@ make FileHandle<CanRead, W>, implement: Readable do
3473
3732
  # Unlike +read+, this accepts arbitrary binary data and cannot fail because
3474
3733
  # the input is not valid UTF-8. It starts at the handle's current position.
3475
3734
  #
3476
- # @return [Binary] the remaining bytes
3735
+ # @return [Result<Binary, ReadError>] the remaining bytes
3477
3736
  #
3478
3737
  # @example Reading a file with an unknown encoding
3479
3738
  # let payload = handle.readBytes.try
@@ -3687,6 +3946,24 @@ module FS do
3687
3946
  | ReadFailed(FilePath)
3688
3947
  | InvalidUtf8(FilePath, Integer)
3689
3948
 
3949
+ # What a path names, as +FS.File.info+ reports it: a regular +:file+, a
3950
+ # +:directory+, a +:symlink+ (the link itself, never what it points at), or
3951
+ # +:other+ for devices, sockets and pipes.
3952
+ type FileKind = :file | :directory | :symlink | :other
3953
+
3954
+ # What one +stat+ of a path says about it.
3955
+ record FileInfo do
3956
+ # Which kind of entry the path names.
3957
+ kind : FileKind
3958
+
3959
+ # The size in bytes. For a symlink, the length of the path it holds.
3960
+ size : Integer
3961
+
3962
+ # When the contents last changed, at UTC, to the whole second: the
3963
+ # precision the BEAM reports.
3964
+ modified : DateTime
3965
+ end
3966
+
3690
3967
  # Reading and writing files.
3691
3968
  #
3692
3969
  # A capability: every member reaches the real filesystem, so a test can
@@ -3709,7 +3986,7 @@ module FS do
3709
3986
  #
3710
3987
  # @param path [FilePath] the file to open
3711
3988
  # @param mode [FileModes] +Read+, +Write+, +Append+ or +ReadWrite+
3712
- # @return [Result<FileHandle, FileError>] the handle, or +OpenFailed+
3989
+ # @return [Result<FileHandle<CanRead, CannotWrite>, FileError>] the handle, or +OpenFailed+
3713
3990
  #
3714
3991
  # @example Reading a file line by line
3715
3992
  # match FS.File.open("log.txt", Read) do
@@ -4073,6 +4350,59 @@ module FS do
4073
4350
  # because it is not lexical: it asks the process where it is.
4074
4351
  absolute : FilePath -> String?
4075
4352
  foul absolute(path) = Kex.Intrinsic.File.absolute(path)
4353
+
4354
+ # The canonical form of +path+: absolute, with every +.+, +..+ and
4355
+ # symlink resolved, like +realpath(3)+.
4356
+ #
4357
+ # Unlike +absolute+ this reads the filesystem, so a path that does not
4358
+ # exist (or a symlink loop) is an error. Use it to keep reads and writes
4359
+ # inside a directory: compare the real path's prefix, and neither +../+
4360
+ # nor a symlink can escape.
4361
+ #
4362
+ # @param path [FilePath] the path to resolve
4363
+ # @return [Result<String, FileError>] the canonical path, or +ReadFailed+
4364
+ #
4365
+ # @example
4366
+ # FS.File.canonical("/tmp/../etc") # => Ok("/private/etc") on macOS
4367
+ canonical : FilePath -> Result<String, FileError>
4368
+ foul canonical(path) = Kex.Intrinsic.File.canonical(path)
4369
+
4370
+ # Whether +path+ is itself a symlink, without following it. A dangling
4371
+ # link is still a symlink.
4372
+ #
4373
+ # @param path [FilePath] the path to inspect
4374
+ # @return [Bool] +true+ for a symlink
4375
+ #
4376
+ # @example
4377
+ # FS.File.symlink?("current") # => true
4378
+ symlink? : FilePath -> Bool
4379
+ foul symlink?(path) = Kex.Intrinsic.File.symlink?(path)
4380
+
4381
+ # The kind, size and modification time of +path+, read in one +lstat+.
4382
+ #
4383
+ # One call answers what +file?+, +directory?+, +symlink?+ and +size+
4384
+ # would otherwise ask separately, which is what walking a tree or
4385
+ # fingerprinting it for changes wants. A symlink is described as itself,
4386
+ # not followed.
4387
+ #
4388
+ # @param path [FilePath] the path to inspect
4389
+ # @return [Result<FileInfo, FileError>] what the path names, or
4390
+ # +ReadFailed+ when it cannot be read
4391
+ #
4392
+ # @example
4393
+ # FS.File.info("hello.txt").try.size # => 6
4394
+ # FS.File.info("src").try.kind # => :directory
4395
+ # FS.File.info("nowhere") # => Error(ReadFailed("nowhere"))
4396
+ #
4397
+ # @example Noticing that a file changed
4398
+ # let before = FS.File.info(path).try
4399
+ # ...
4400
+ # let changed = FS.File.info(path).try != before
4401
+ info : FilePath -> Result<FileInfo, FileError>
4402
+ foul info(path) = match Kex.Intrinsic.File.info(path) do
4403
+ Just((kind, size, modified)) => Ok(FileInfo { kind: kind, size: size, modified: DateTime.fromEpochSeconds(modified) })
4404
+ None => Error(ReadFailed(path))
4405
+ end
4076
4406
  end
4077
4407
 
4078
4408
  # Path arithmetic: joining, splitting, normalising and comparing paths.
@@ -4919,6 +5249,14 @@ private do
4919
5249
  end
4920
5250
 
4921
5251
  let allowComments = Kex.Intrinsic.Map.getWithDefault(options, allowCommentsKey(), false)
5252
+ # On BEAM, OTP's own decoder builds the same value in a fraction of the
5253
+ # time (kexhq/kex#333), JSONC included once its comments are blanked out
5254
+ # (kexhq/kex#405). It answers None for anything it will not vouch for —
5255
+ # invalid input, the tree-walker — and this parser runs.
5256
+ let fast = allowComments then Kex.Intrinsic.Json.decodeCommented(text) else Kex.Intrinsic.Json.decode(text)
5257
+ if let Just(value) = fast
5258
+ return Ok(value)
5259
+ end
4922
5260
  let cursor = skipIgnored(Input { input: text }, allowComments).try
4923
5261
  let (value, afterValue) = parseValue(cursor, allowComments).try
4924
5262
  let rest = skipIgnored(afterValue, allowComments).try
@@ -4953,25 +5291,40 @@ end
4953
5291
  # JSON.parse(JSON.stringify({ a: 1 })) # => Ok({ a: 1 })
4954
5292
  stringify : Any -> String
4955
5293
  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"
5294
+ # The BEAM encoder follows `stringifyValue` rule for rule (kexhq/kex#333);
5295
+ # the tree-walker has none and answers None.
5296
+ if let Just(text) = Kex.Intrinsic.Json.encode(value)
5297
+ return text
4971
5298
  end
5299
+ return stringifyValue(value)
4972
5300
  end
4973
5301
 
4974
5302
  private do
5303
+ let stringifyValue(value: Any) -> String do
5304
+ # An optional is JSON's nullable: `Just(x)` is `x`, as `None` is `null`.
5305
+ # Without this a caller had to unwrap first, and the only spelling that
5306
+ # type-checked was the meaningless `.or(None)`.
5307
+ if let Just(inner) = value
5308
+ return stringifyValue(inner)
5309
+ end
5310
+ match Kex.Intrinsic.Kex.kind(value) do
5311
+ :none => return "null"
5312
+ :bool => return value then "true" else "false"
5313
+ :integer => return "${value}"
5314
+ :float => return "${value}"
5315
+ :string => return encodeString(value)
5316
+ :list => return "[${value.map(~stringifyValue).join(",")}]"
5317
+ :map => do
5318
+ let fields = Kex.Intrinsic.Map.entries(value).map do |entry|
5319
+ let (key, item) = entry
5320
+ "${encodeString(objectKey(key))}:${stringifyValue(item)}"
5321
+ end
5322
+ return "{${fields.join(",")}}"
5323
+ end
5324
+ _ => return "null"
5325
+ end
5326
+ end
5327
+
4975
5328
  # A JSON object key is a string, but a Kex map literal is written with ATOM
4976
5329
  # keys (`{ n: 1 }`): the shape most values being stringified actually have.
4977
5330
  # Rendering one gives `:n`, so drop the leading colon; anything else goes
@@ -5386,20 +5739,58 @@ end
5386
5739
  # The running toolchain, which backend, which version, which features.
5387
5740
  #
5388
5741
  # Kex.BACKEND # => Interpreter
5389
- # Kex.Kernel.VERSION.release # => "0.4.0"
5390
- # Kex.Feature.has?(Kex.FS) # => true
5742
+ # Kex.BACKEND.compiled? # => false
5743
+ # Kex.VERSION.release # => "0.4.0"
5744
+ # Kex.Feature.has?(Kex.FileSystem) # => true
5391
5745
  module Kex do
5392
5746
  # Which backend is executing the program: the tree-walking +Interpreter+, or
5393
5747
  # the +Beam+ virtual machine.
5394
5748
  type Backend = Interpreter | Beam
5395
5749
 
5750
+ make Backend do
5751
+ # Whether this is the tree-walking interpreter.
5752
+ #
5753
+ # @return [Bool] +true+ for +Interpreter+
5754
+ #
5755
+ # @example
5756
+ # Kex.BACKEND.interpreted? # => true under `kex -R file.kex`
5757
+ interpreted? :> Bool
5758
+ let interpreted? = this == Interpreter
5759
+
5760
+ # Whether this backend runs compiled code: today, the BEAM.
5761
+ #
5762
+ # @return [Bool] +true+ for every backend but the interpreter
5763
+ #
5764
+ # @example
5765
+ # Kex.BACKEND.compiled? # => true under `kex file.kex`
5766
+ compiled? :> Bool
5767
+ let compiled? = this != Interpreter
5768
+
5769
+ # Whether this is the BEAM virtual machine. Processes, the web server
5770
+ # and clustering need it.
5771
+ #
5772
+ # @return [Bool] +true+ for +Beam+
5773
+ #
5774
+ # @example
5775
+ # Kex.BACKEND.beam? # => true under `kex file.kex`
5776
+ beam? :> Bool
5777
+ let beam? = this == Beam
5778
+ end
5779
+
5396
5780
  # An optional capability a build may or may not include. Ask about one with
5397
5781
  # +Kex.Feature.has?+ before relying on it.
5398
- type Feature = FS | Process
5782
+ #
5783
+ # +FileSystem+: the program can read and write the host's files (+FS+).
5784
+ # +ExternalPrograms+: it can run other programs (+Process.run+,
5785
+ # +Process.stream+), which the browser build cannot.
5786
+ #
5787
+ # Kex's own processes (+spawn+, +receive+) are not optional: every backend
5788
+ # has them. Networking has its finer-grained report, +Net.Support.current+.
5789
+ type Feature = FileSystem | ExternalPrograms
5399
5790
 
5400
5791
  # Which backend is executing this program.
5401
5792
  #
5402
- # +interpreted?+ and +underBeam?+ below are the readable way to ask.
5793
+ # Its +interpreted?+, +compiled?+ and +beam?+ are the readable way to ask.
5403
5794
  #
5404
5795
  # @return [Backend] the running backend
5405
5796
  #
@@ -5408,131 +5799,194 @@ module Kex do
5408
5799
  BACKEND : Backend
5409
5800
  let BACKEND = Kex.Intrinsic.Kex.backend()
5410
5801
 
5411
- # Returns +true+ when running on the tree-walking interpreter.
5802
+ # Build identity for the compiler and runtime executing this program:
5803
+ # +Version+ and +VERSION+ below.
5412
5804
  #
5413
- # @return [Bool] +true+ under the interpreter
5414
- #
5415
- # @example
5416
- # Kex.interpreted? # => true under `kex file.kex`
5417
- let interpreted? = Kex.BACKEND == Kex.Interpreter
5418
-
5419
- # Returns +true+ when running on the BEAM.
5420
- #
5421
- # The backend a program is on decides what is available: processes and the
5422
- # web server need the BEAM (+kex -R file.kex+).
5805
+ # Useful in bug reports, generated artifacts, and compatibility checks where
5806
+ # +Kex.BACKEND+ alone is not enough to identify the toolchain.
5807
+ # The toolchain a program is running on. `kex --version` and the REPL
5808
+ # banner report the same numbers.
5423
5809
  #
5424
- # @return [Bool] +true+ under the BEAM
5810
+ # `revision` is the git commit the compiler was built from: `None` when
5811
+ # it was built from a source archive rather than a checkout, which is why
5812
+ # it is an Optional rather than a String.
5425
5813
  #
5426
5814
  # @example
5427
- # Kex.underBeam? # => true under `kex -R file.kex`
5428
- let underBeam? = Kex.BACKEND == Kex.Beam
5815
+ # Kex.VERSION.major # => 0
5816
+ # Kex.VERSION.revision # => Just("a1b2c3d")
5817
+ # Kex.VERSION.to(String) # => "0.3.0 (a1b2c3d)"
5818
+ record Version do
5819
+ # The major version number.
5820
+ major : Integer
5429
5821
 
5430
- # Build identity for the compiler and runtime executing this program.
5431
- #
5432
- # Useful in bug reports, generated artifacts, and compatibility checks where
5433
- # +Kex.BACKEND+ alone is not enough to identify the toolchain.
5434
- module Kernel do
5435
- # The toolchain a program is running on. `kex --version` and the REPL
5436
- # banner report the same numbers.
5822
+ # The minor version number.
5823
+ minor : Integer
5824
+
5825
+ # The patch version number.
5826
+ patch : Integer
5827
+
5828
+ # The git commit the compiler was built from, or +None+ when it was
5829
+ # built from a source archive rather than a checkout.
5830
+ revision : String?
5831
+
5832
+ # The release channel: "" for a stable build, otherwise "rc.1",
5833
+ # "beta.2", "prealpha.1": the part of VERSION after the dash.
5834
+ preRelease : String = ""
5835
+ end
5836
+
5837
+ make Version do
5838
+ # The version's four values as a tuple, for destructuring.
5839
+ #
5840
+ # A tuple cannot carry accessors of its own: there is no named type for
5841
+ # a `make` block to target, so the record is the value and this is the
5842
+ # view.
5437
5843
  #
5438
- # `revision` is the git commit the compiler was built from: `None` when
5439
- # it was built from a source archive rather than a checkout, which is why
5440
- # it is an Optional rather than a String.
5844
+ # @return [(Integer, Integer, Integer, String?)] major, minor, patch, revision
5441
5845
  #
5442
5846
  # @example
5443
- # Kex.Kernel.VERSION.major # => 0
5444
- # Kex.Kernel.VERSION.revision # => Just("a1b2c3d")
5445
- # Kex.Kernel.VERSION.to(String) # => "0.3.0 (a1b2c3d)"
5446
- record Version do
5447
- # The major version number.
5448
- major : Integer
5449
-
5450
- # The minor version number.
5451
- minor : Integer
5452
-
5453
- # The patch version number.
5454
- patch : Integer
5455
-
5456
- # The git commit the compiler was built from, or +None+ when it was
5457
- # built from a source archive rather than a checkout.
5458
- revision : String?
5459
-
5460
- # The release channel: "" for a stable build, otherwise "rc.1",
5461
- # "beta.2", "prealpha.1": the part of VERSION after the dash.
5462
- preRelease : String = ""
5463
- end
5847
+ # let (major, minor, patch, revision) = Kex.VERSION.tuple
5848
+ # major # => 0
5849
+ tuple : (Integer, Integer, Integer, String?)
5850
+ let tuple = (this.major, this.minor, this.patch, this.revision)
5464
5851
 
5465
- make Version do
5466
- # The version's four values as a tuple, for destructuring.
5467
- #
5468
- # A tuple cannot carry accessors of its own: there is no named type for
5469
- # a `make` block to target, so the record is the value and this is the
5470
- # view.
5471
- #
5472
- # @return [(Integer, Integer, Integer, String?)] major, minor, patch, revision
5473
- #
5474
- # @example
5475
- # let (major, minor, patch, revision) = Kex.Kernel.VERSION.tuple
5476
- # major # => 0
5477
- tuple : (Integer, Integer, Integer, String?)
5478
- let tuple = (this.major, this.minor, this.patch, this.revision)
5479
-
5480
- # The three numbers, plus the pre-release channel when there is one.
5481
- #
5482
- # `0.4.0`, or `0.4.0-rc.1` on a pre-release build. What a version RANGE
5483
- # is matched against, so the channel has to be in it.
5484
- #
5485
- # @return [String] the release string
5486
- #
5487
- # @example
5488
- # Kex.Kernel.VERSION.release # => "0.4.0-alpha.2"
5489
- release : String
5490
- let release = @preRelease.empty? then "${this.major}.${this.minor}.${this.patch}" else "${this.major}.${this.minor}.${this.patch}-${@preRelease}"
5491
-
5492
- # The release string with the build revision after it, when there is one.
5493
- #
5494
- # This is what +kex --version+ and the REPL banner print.
5495
- #
5496
- # `to(String)`: the language's conversion protocol, and what this
5497
- # should really be: is deliberately NOT defined here: a second
5498
- # `to(String)` implementation anywhere in the prelude breaks
5499
- # type-directed `to` dispatch for every prelude type on BEAM, so adding
5500
- # one here silently broke `3.kilo.watt.to(String)`. Pinned by
5501
- # spec/prelude_to_string_dispatch.kex; restore this as `to(String)` once
5502
- # that dispatcher is fixed.
5503
- #
5504
- # @return [String] the full version string
5505
- #
5506
- # @example
5507
- # Kex.Kernel.VERSION.number # => "0.4.0-alpha.2 (219e625)"
5508
- #
5509
- # @example Reporting the toolchain in a tool's output
5510
- # IO.printLine("built with Kex ${Kex.Kernel.VERSION.number}")
5511
- number : String
5512
- let number = match this.revision do
5513
- Just(hash) => "${this.release} (${hash})"
5514
- None => this.release
5515
- end
5516
- end
5852
+ # The three numbers, plus the pre-release channel when there is one.
5853
+ #
5854
+ # `0.4.0`, or `0.4.0-rc.1` on a pre-release build. What a version RANGE
5855
+ # is matched against, so the channel has to be in it.
5856
+ #
5857
+ # @return [String] the release string
5858
+ #
5859
+ # @example
5860
+ # Kex.VERSION.release # => "0.4.0-alpha.2"
5861
+ release : String
5862
+ let release = @preRelease.empty? then "${this.major}.${this.minor}.${this.patch}" else "${this.major}.${this.minor}.${this.patch}-${@preRelease}"
5517
5863
 
5518
- # This build's version.
5864
+ # The release string with the build revision after it, when there is one.
5865
+ #
5866
+ # This is what +kex --version+ and the REPL banner print.
5867
+ #
5868
+ # `to(String)`: the language's conversion protocol, and what this
5869
+ # should really be: is deliberately NOT defined here: a second
5870
+ # `to(String)` implementation anywhere in the prelude breaks
5871
+ # type-directed `to` dispatch for every prelude type on BEAM, so adding
5872
+ # one here silently broke `3.kilo.watt.to(String)`. Pinned by
5873
+ # spec/prelude_to_string_dispatch.kex; restore this as `to(String)` once
5874
+ # that dispatcher is fixed.
5519
5875
  #
5520
- # @return [Version] the running toolchain's version
5876
+ # @return [String] the full version string
5521
5877
  #
5522
5878
  # @example
5523
- # Kex.Kernel.VERSION.major # => 0
5524
- # Kex.Kernel.VERSION.release # => "0.4.0-alpha.2"
5525
- #
5526
- # @example Reporting the version in a tool's output
5527
- # IO.printLine("built with Kex ${Kex.Kernel.VERSION.number}")
5528
- VERSION : Version
5529
- let VERSION = match Kex.Intrinsic.Kex.version() do
5530
- (major, minor, patch, revision) =>
5531
- Version { major: major, minor: minor, patch: patch, revision: revision,
5532
- preRelease: Kex.Intrinsic.Kex.versionPreRelease() }
5879
+ # Kex.VERSION.number # => "0.4.0-alpha.2 (219e625)"
5880
+ #
5881
+ # @example Reporting the toolchain in a tool's output
5882
+ # IO.printLine("built with Kex ${Kex.VERSION.number}")
5883
+ number : String
5884
+ let number = match this.revision do
5885
+ Just(hash) => "${this.release} (${hash})"
5886
+ None => this.release
5533
5887
  end
5534
5888
  end
5535
5889
 
5890
+ # This build's version.
5891
+ #
5892
+ # @return [Version] the running toolchain's version
5893
+ #
5894
+ # @example
5895
+ # Kex.VERSION.major # => 0
5896
+ # Kex.VERSION.release # => "0.4.0-alpha.2"
5897
+ #
5898
+ # @example Reporting the version in a tool's output
5899
+ # IO.printLine("built with Kex ${Kex.VERSION.number}")
5900
+ VERSION : Version
5901
+ let VERSION = match Kex.Intrinsic.Kex.version() do
5902
+ (major, minor, patch, revision) =>
5903
+ Version { major: major, minor: minor, patch: patch, revision: revision,
5904
+ preRelease: Kex.Intrinsic.Kex.versionPreRelease() }
5905
+ end
5906
+
5907
+ # A compiled module loaded into the running program by +Kex.load+.
5908
+ #
5909
+ # For a program that decides at run time what code it needs: a site
5910
+ # generator rendering a theme's templates, a plugin host. Compile the source
5911
+ # with `kex --compile -o <dir>` once, then load the `.beam` and call it as
5912
+ # often as needed, without starting another VM for every call
5913
+ # (kexhq/kex#399).
5914
+ #
5915
+ # Every entry module is named after its file (`kex_<stem>.beam` for
5916
+ # `<stem>.kex`), and so is everything declared at the top level of that
5917
+ # file. A `let render = Template.html(Kex.embed(path))` written outside any
5918
+ # `module` therefore lands in `kex_<stem>`, not in a module the file names;
5919
+ # give each generated file a distinct name, or put the declaration inside a
5920
+ # `module`, whose functions compile into `Kex.<Name>`.
5921
+ #
5922
+ # The BEAM backend only: under the interpreter, +Kex.load+ answers an error.
5923
+ #
5924
+ # let theme = Kex.load("cache/kex_theme_3f2a.beam").try
5925
+ # let html = theme.call(:render, [context]).try
5926
+ record LoadedModule do
5927
+ # The module's BEAM name, e.g. +:kex_theme+ or +:"Kex.Theme"+.
5928
+ name : Atom
5929
+ end
5930
+
5931
+ # Loads the compiled module at +path+, replacing an older version of it
5932
+ # that is already loaded. See +Kex.LoadedModule+.
5933
+ #
5934
+ # @param path [String] a `.beam` file written by `kex --compile`
5935
+ # @return [Result<LoadedModule, String>] the module, or why it could not be
5936
+ # loaded
5937
+ #
5938
+ # @example
5939
+ # let theme = Kex.load("build/kex_theme.beam").try
5940
+ load : String -> Result<LoadedModule, String>
5941
+ foul load(path) = Kex.Intrinsic.Code.load(path).map { |name| LoadedModule { name: name } }
5942
+
5943
+ # The module already loaded under +name+, or +None+ when there is none.
5944
+ #
5945
+ # @param name [Atom] the module's BEAM name
5946
+ # @return [LoadedModule?] the module
5947
+ #
5948
+ # @example Loading only once
5949
+ # let theme = Kex.loaded(:kex_theme).or(Kex.load(path).try)
5950
+ loaded : Atom -> LoadedModule?
5951
+ foul loaded(name) = Kex.Intrinsic.Code.loaded?(name) then Just(LoadedModule { name: name }) else None
5952
+
5953
+ make LoadedModule do
5954
+ # Calls the module's function +function+ with +arguments+.
5955
+ #
5956
+ # A failure inside the call is an +Error+ describing it, not a crash of
5957
+ # the caller, and so is a function the module does not export with that
5958
+ # many arguments. A `foul` function takes one argument more than it
5959
+ # declares, its capabilities, so call pure functions this way.
5960
+ #
5961
+ # @param function [Atom] the function's name
5962
+ # @param arguments [[Any]] its arguments, in order
5963
+ # @return [Result<Any, String>] what the function returned, or why the
5964
+ # call failed
5965
+ #
5966
+ # @example
5967
+ # theme.call(:render, [context]) # => Ok("<html>...")
5968
+ call :> Atom -> [Any] -> Result<Any, String>
5969
+ foul call(function, arguments) = Kex.Intrinsic.Code.call(@name, function, arguments)
5970
+ end
5971
+
5972
+ # A non-cryptographic hash of any value: a non-negative +Integer+ below
5973
+ # 2^32, the same for equal values.
5974
+ #
5975
+ # Cheap to compute over a whole structure, with no string built along the
5976
+ # way, which suits fingerprinting data to notice when it changed. It is
5977
+ # stable only within one running program: the number differs between the
5978
+ # interpreter and the BEAM, and may change between Kex versions, so never
5979
+ # store it or send it anywhere. Use +Digest+ for that, or for anything an
5980
+ # adversary could choose collisions for.
5981
+ #
5982
+ # @param value [Any] the value to hash
5983
+ # @return [Integer] the hash, from 0 up to 2^32 - 1
5984
+ #
5985
+ # @example Noticing that a project's files changed
5986
+ # let stamp = Kex.hash(paths.map { |path| (path, FS.File.info(path).try) })
5987
+ hash : Any -> Integer
5988
+ let hash(value) = Kex.Intrinsic.Kex.hash(value)
5989
+
5536
5990
  # Which optional capabilities this build includes.
5537
5991
  #
5538
5992
  # Optional non-network capabilities in this build. Networking has its own
@@ -5544,7 +5998,7 @@ module Kex do
5544
5998
  # @return [Bool] +true+ when it is available
5545
5999
  #
5546
6000
  # @example
5547
- # Kex.Feature.has?(Kex.FS) # => true
6001
+ # Kex.Feature.has?(Kex.ExternalPrograms) # => true, except in a browser
5548
6002
  #
5549
6003
  has? : Feature -> Bool
5550
6004
  let has?(f) = Kex.Intrinsic.Kex.featureHas?(f)
@@ -5554,7 +6008,7 @@ module Kex do
5554
6008
  # @return [[Feature]] the available capabilities
5555
6009
  #
5556
6010
  # @example
5557
- # Kex.Feature.list # => [FS]
6011
+ # Kex.Feature.list # => [FileSystem, ExternalPrograms]
5558
6012
  list : [Feature]
5559
6013
  let list = Kex.Intrinsic.Kex.featureList()
5560
6014
  end
@@ -5568,7 +6022,7 @@ module Kex.Interface do
5568
6022
  #
5569
6023
  # The term is an ordinary tree of tuples, lists, atoms, integers and
5570
6024
  # strings, so it is walked with normal pattern matching and `Tuple.items`.
5571
- # This exists so that reading it needs no `Erlang.*` interop: it is the one
6025
+ # This exists so that reading it needs no `BEAM.*` interop: it is the one
5572
6026
  # intentional entry point rather than a general term decoder.
5573
6027
  #
5574
6028
  # @param path [FS.FilePath] the compiled .beam file to read
@@ -5750,6 +6204,144 @@ module Kex.AST do
5750
6204
  parseExpression : String -> Result<Expression, ParseError>
5751
6205
  let parseExpression(source) = Kex.Intrinsic.AST.parseExpression(source)
5752
6206
 
6207
+ # One token of a lossless syntax tree, exactly as written.
6208
+ #
6209
+ # Where +parse+ gives a program's meaning, +parseSyntax+ gives its text:
6210
+ # nothing is normalised or dropped, so +toSource+ reprints the file byte for
6211
+ # byte. It is the tree a formatter or a linter works on (kexhq/kex#136).
6212
+ record SyntaxToken do
6213
+ # The lexer's name for the token: +LowerIdent+, +Newline+, +Eof+, ...
6214
+ kind : String
6215
+
6216
+ # The token exactly as written, quotes, escapes and underscores included.
6217
+ text : String
6218
+
6219
+ # Everything between the previous token and this one: spaces, tabs and
6220
+ # comments. A comment on a line of its own sits in front of the +Newline+
6221
+ # that ends that line; whatever follows the last token belongs to +Eof+.
6222
+ trivia : String
6223
+ end
6224
+
6225
+ # One child of a +SyntaxNode+: a token, or a nested node.
6226
+ type SyntaxElement = TokenElement(SyntaxToken) | NodeElement(SyntaxNode)
6227
+
6228
+ # A declaration, expression, pattern or type, holding its own tokens and
6229
+ # nested nodes in source order.
6230
+ #
6231
+ # let tree = Kex.AST.parseSyntax("# the answer\nlet x = 42\n").try
6232
+ # tree.kind # => "Program"
6233
+ # Kex.AST.toSource(tree) # => "# the answer\nlet x = 42\n"
6234
+ record SyntaxNode do
6235
+ # What the parser built there: +FunctionDef+, +MethodCall+, +ListPattern+,
6236
+ # ... The root is +Program+.
6237
+ kind : String
6238
+
6239
+ # The node's tokens and nested nodes, in source order.
6240
+ children : [SyntaxElement]
6241
+ end
6242
+
6243
+ # Parses Kex source into its lossless syntax tree.
6244
+ #
6245
+ # @param source [String] the Kex source text
6246
+ # @return [Result<SyntaxNode, ParseError>] the tree rooted at +Program+, or why it failed
6247
+ #
6248
+ # @example
6249
+ # Kex.AST.parseSyntax("let x = 1\n").map { |tree| tree.kind } # => Ok("Program")
6250
+ # Kex.AST.parseSyntax("let x =").error? # => true
6251
+ parseSyntax : String -> Result<SyntaxNode, ParseError>
6252
+ let parseSyntax(source) = Kex.Intrinsic.AST.parseSyntax(source)
6253
+
6254
+ # Reprints a syntax tree as the source it was parsed from, byte for byte.
6255
+ #
6256
+ # Every node prints its own tokens and its nested nodes in order, never a
6257
+ # slice of the original text, so a rearranged tree prints the rearranged
6258
+ # program, its comments moving with it.
6259
+ #
6260
+ # @param node [SyntaxNode] a tree, or any node in one
6261
+ # @return [String] the source the node covers, trivia included
6262
+ #
6263
+ # @example
6264
+ # let source = "let x = 1 # one\n"
6265
+ # Kex.AST.parseSyntax(source).map { |tree| Kex.AST.toSource(tree) } # => Ok(source)
6266
+ toSource : SyntaxNode -> String
6267
+ let toSource(node) = node.children.map { |child| Kex.AST.elementSource(child) }.join("")
6268
+
6269
+ # The source of one child of a node: a token's trivia and text, or a nested
6270
+ # node reprinted.
6271
+ #
6272
+ # @param element [SyntaxElement] the child
6273
+ # @return [String] its source text
6274
+ elementSource : SyntaxElement -> String
6275
+ let elementSource(element) = match element do
6276
+ TokenElement(token) => token.trivia + token.text
6277
+ NodeElement(child) => Kex.AST.toSource(child)
6278
+ end
6279
+
6280
+ # The comments written on their own lines directly above the child at
6281
+ # +index+: the ones that belong to it, move with it when a formatter moves
6282
+ # it, and hold a `# kex:disable-next-line` meant for it.
6283
+ #
6284
+ # A comment at the end of the previous line trails that line instead, and is
6285
+ # not included.
6286
+ #
6287
+ # @param parent [SyntaxNode] the node holding the child
6288
+ # @param index [Integer] the child's position in +parent.children+
6289
+ # @return [[String]] the comment lines, top to bottom, each starting with +#+
6290
+ #
6291
+ # @example
6292
+ # let tree = Kex.AST.parseSyntax("let x = 1 # x\n# about y\nlet y = 2\n").try
6293
+ # Kex.AST.commentsBefore(tree, 3) # => ["# about y"]
6294
+ commentsBefore : SyntaxNode -> Integer -> [String]
6295
+ let commentsBefore(parent, index) do
6296
+ var comments: [String] = []
6297
+ Kex.AST.linesBefore(parent, index).each do |line|
6298
+ let text = line.trim
6299
+ comments = comments + [text] if text.startsWith?("#")
6300
+ end
6301
+ return comments
6302
+ end
6303
+
6304
+ # How many blank lines separate the child at +index+ from what precedes it.
6305
+ #
6306
+ # Kept as a count, not a flag: a formatter preserves one blank line between
6307
+ # declarations and collapses longer runs, which needs to know how many there
6308
+ # were.
6309
+ #
6310
+ # @param parent [SyntaxNode] the node holding the child
6311
+ # @param index [Integer] the child's position in +parent.children+
6312
+ # @return [Integer] the number of blank lines directly above the child
6313
+ #
6314
+ # @example
6315
+ # let tree = Kex.AST.parseSyntax("let x = 1\n\n\nlet y = 2\n").try
6316
+ # Kex.AST.blankLinesBefore(tree, 4) # => 2
6317
+ blankLinesBefore : SyntaxNode -> Integer -> Integer
6318
+ let blankLinesBefore(parent, index) = Kex.AST.linesBefore(parent, index).filter { |line| line.trim.empty? }.count
6319
+
6320
+ # The whole lines between the child at +index+ and the code before it, as
6321
+ # the trivia of the +Newline+ tokens that end them. The newline that ends the
6322
+ # previous line of code is not one of them: what sits in front of it trails
6323
+ # that code.
6324
+ #
6325
+ # @param parent [SyntaxNode] the node holding the child
6326
+ # @param index [Integer] the child's position in +parent.children+
6327
+ # @return [[String]] the lines, top to bottom, without their newlines
6328
+ linesBefore : SyntaxNode -> Integer -> [String]
6329
+ let linesBefore(parent, index) do
6330
+ var position = index - 1
6331
+ var lines: [String] = []
6332
+ var scanning = true
6333
+ while scanning && position >= 0 do
6334
+ match parent.children.at(position) do
6335
+ Just(TokenElement(token)) when token.kind == "Newline" => do
6336
+ lines = [token.trivia] + lines
6337
+ position = position - 1
6338
+ end
6339
+ _ => scanning = false
6340
+ end
6341
+ end
6342
+ return position >= 0 then lines.drop(1) else lines
6343
+ end
6344
+
5753
6345
  # A type as it was written in source.
5754
6346
  #
5755
6347
  # +typeRefText+ renders one back to the source spelling.
@@ -6310,6 +6902,17 @@ make [Number] do
6310
6902
  let max = Kex.Intrinsic.List.max(this)
6311
6903
  end
6312
6904
 
6905
+ make [[Y]] do
6906
+ # Flattens exactly one level of nesting.
6907
+ #
6908
+ # @return [[Y]]
6909
+ #
6910
+ # @example
6911
+ # [[1, 2], [3, 4]].flatten # => [1, 2, 3, 4]
6912
+ flatten :> [Y]
6913
+ let flatten = Kex.Intrinsic.List.flatten(this)
6914
+ end
6915
+
6313
6916
  make [X], implement: Enumerable, Foldable do
6314
6917
  # Returns the first element wrapped in +Just+, or +None+ if the list is empty.
6315
6918
  #
@@ -6481,9 +7084,9 @@ make [X], implement: Enumerable, Foldable do
6481
7084
  # Folds the list from the left, starting with +acc+ and combining each
6482
7085
  # element via +f+.
6483
7086
  #
6484
- # @param acc [Acc] initial accumulator value
6485
- # @param f [Acc -> X -> Acc]
6486
- # @return [Acc]
7087
+ # @param acc [A] initial accumulator value
7088
+ # @param f [A -> X -> A]
7089
+ # @return [A]
6487
7090
  #
6488
7091
  # @example
6489
7092
  # [1, 2, 3].reduce(0) { |acc, x| acc + x } # => 6
@@ -6702,15 +7305,6 @@ make [X], implement: Enumerable, Foldable do
6702
7305
  zip :> [Y] -> [(X, Y)]
6703
7306
  let zip(other) = Kex.Intrinsic.List.zip(this, other)
6704
7307
 
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
7308
  # Returns the elements sorted in ascending natural order.
6715
7309
  #
6716
7310
  # @return [[X]]
@@ -7894,6 +8488,15 @@ module Mock do
7894
8488
  foul file?(path) = this.cannedRead(path) != None
7895
8489
  foul directory?(path) = false
7896
8490
  foul absolute(path) = Just(path)
8491
+ foul canonical(path) = match this.cannedRead(path) do
8492
+ Just(_) => Ok(path)
8493
+ None => Error(ReadFailed(path))
8494
+ end
8495
+ foul symlink?(path) = false
8496
+ foul info(path) = match this.cannedRead(path) do
8497
+ Just(content) => Ok(FileInfo { kind: :file, size: content.bytes.count, modified: DateTime.fromEpochSeconds(0) })
8498
+ None => Error(ReadFailed(path))
8499
+ end
7897
8500
 
7898
8501
  # A fake is a value, so there is nowhere for a write to go. Refusing is
7899
8502
  # the honest answer and the useful one: a test that did not expect a
@@ -8442,6 +9045,22 @@ module Headers do
8442
9045
  from : [(String, String)] -> Result<Headers, NetError>
8443
9046
  let from(entries) = Kex.Intrinsic.NetHTTP.headers(entries)
8444
9047
 
9048
+ # Validates header names and values from unordered pairs, for the common
9049
+ # case where every name is used once. Do not use this for `Set-Cookie` or
9050
+ # any other field that legitimately repeats — a `Map` cannot hold a name
9051
+ # twice, so unlike the list form above, a second value for the same name
9052
+ # here does not add a second field; it silently replaces the first (see
9053
+ # this module's own +Headers+ record doc for why that specific field
9054
+ # cannot be folded into one entry). Reach for the ordered list form for
9055
+ # anything that might repeat a name.
9056
+ #
9057
+ # @return [Result<Headers, NetError>] validated fields, or +Parse+
9058
+ #
9059
+ # @example Forwarding a set of request headers that are each sent once
9060
+ # Headers.from({ "Accept": "application/json", "X-Request-ID": requestId }).try
9061
+ from : Map<String, String> -> Result<Headers, NetError>
9062
+ let from(entries: Map<String, String>) = Kex.Intrinsic.NetHTTP.headers(entries.entries)
9063
+
8445
9064
  # Parses CRLF- or LF-separated header fields.
8446
9065
  #
8447
9066
  # Use this at a protocol boundary when headers arrive as text. Application
@@ -8492,6 +9111,7 @@ end
8492
9111
  module Router do
8493
9112
  # @return [Router] a router with no routes
8494
9113
  # @example +Router.build.get("/health", { |request, context| Response.text(200, "ok") })+
9114
+ build : Router
8495
9115
  let build = Router { routes: [] }
8496
9116
  end
8497
9117
 
@@ -8760,6 +9380,8 @@ end
8760
9380
  # socket.close
8761
9381
  module Net.HTTP.WebSocket
8762
9382
 
9383
+ using Net.HTTP
9384
+
8763
9385
  # A complete high-level WebSocket message. Fragmentation and ping/pong control
8764
9386
  # frames are handled by the connection runtime.
8765
9387
  #
@@ -8783,10 +9405,31 @@ record Session do
8783
9405
  subprotocol : String?
8784
9406
  end
8785
9407
 
8786
- # An opaque RFC 6455 client connection. It does not reconnect automatically.
9408
+ # An opaque RFC 6455 connection, client- or server-side. It does not
9409
+ # reconnect automatically.
8787
9410
  type Connection
8788
9411
 
8789
- # Constructors for high-level WebSocket client connections.
9412
+ # Subprotocols offered by an incoming upgrade request, in the order the peer
9413
+ # listed them.
9414
+ record Handshake do
9415
+ subprotocols : [String] = []
9416
+ end
9417
+
9418
+ # A server's decision after inspecting a +Handshake+.
9419
+ #
9420
+ # +Accept+ takes over the connection once the 101 response is sent: +handler+
9421
+ # runs with the negotiated server +Connection+, and its return value is
9422
+ # discarded. +headers+ are added to the 101 response; a name that manages the
9423
+ # handshake itself (+Upgrade+, +Connection+, +Sec-WebSocket-Accept+,
9424
+ # +Sec-WebSocket-Protocol+) is dropped rather than overridden. +subprotocol+
9425
+ # must be one +handshake.subprotocols+ actually offered, or +None+.
9426
+ #
9427
+ # +Reject+ answers with an ordinary buffered response instead, leaving the
9428
+ # connection as plain HTTP — a client requesting an unsupported subprotocol
9429
+ # might get +Response.text(426, "chat.v2 required")+, for instance.
9430
+ type Upgrade = Accept(Connection -> Void, Headers, String?) | Reject(Response<Binary>)
9431
+
9432
+ # Constructors for high-level WebSocket connections, client- and server-side.
8790
9433
  module WebSocket do
8791
9434
  # Opens a +ws:+ or verified +wss:+ connection with default options.
8792
9435
  #
@@ -8811,6 +9454,32 @@ module WebSocket do
8811
9454
  # maximumMessageBytes: 1024 * 1024
8812
9455
  # }).try
8813
9456
  foul connect(url: String, options: ClientOptions) -> Result<Connection, NetError> = Kex.Intrinsic.NetWebSocket.connect(url, options)
9457
+
9458
+ # Decides whether to accept an incoming +Net.HTTP.Server+ upgrade request.
9459
+ #
9460
+ # Call from a route handler and return the result directly — it types as
9461
+ # an ordinary +Response<Binary>+, and +Net.HTTP.Server+ recognizes what it
9462
+ # actually is: a request that isn't a syntactically valid WebSocket
9463
+ # handshake at all (wrong method, missing +Sec-WebSocket-Key+, unsupported
9464
+ # +Sec-WebSocket-Version+) is answered automatically without calling
9465
+ # +decide+; a valid one reaches +decide+ for an application decision.
9466
+ #
9467
+ # @param request [Request<Binary>] the route handler's own request
9468
+ # @param decide [Handshake -> Upgrade] the accept/reject decision
9469
+ # @return [Response<Binary>] the handler's response — an upgrade in
9470
+ # disguise on +Accept+, sent as given on +Reject+
9471
+ #
9472
+ # @example An authenticated, subprotocol-gated chat route
9473
+ # foul socketRoute(request: Request<Binary>, context: Context) -> Response<Binary> = WebSocket.upgrade(request) do |handshake|
9474
+ # if handshake.subprotocols.contains?("chat.v2")
9475
+ # let handler : Connection -> Void = { |socket| serveChat(socket) }
9476
+ # Accept(handler, Headers.empty, Just("chat.v2"))
9477
+ # else
9478
+ # Reject(Response.text(426, "chat.v2 required"))
9479
+ # end
9480
+ # end
9481
+ # let router = Router.build.get("/socket", ~socketRoute)
9482
+ foul upgrade(request: Request<Binary>, decide: Handshake -> Upgrade) -> Response<Binary> = Kex.Intrinsic.NetWebSocket.upgrade(request, decide)
8814
9483
  end
8815
9484
 
8816
9485
  make Connection do
@@ -8839,6 +9508,32 @@ make Connection do
8839
9508
  # end
8840
9509
  # end
8841
9510
  foul receiveMessage() -> Result<Message, NetError> = Kex.Intrinsic.NetWebSocket.receiveMessage(this)
9511
+ # `receiveMessage`, but the deadline is explicit rather than implicit in
9512
+ # whether you passed an argument at all: +None+ waits exactly as long as
9513
+ # a bare +receiveMessage+ does (as long as the peer stays connected —
9514
+ # routinely indefinitely, for a WebSocket left open with nothing to say),
9515
+ # and +Just(duration)+ gives up and answers +Timeout+ once +duration+
9516
+ # elapses without a message.
9517
+ #
9518
+ # A real disconnect is still reported as +Closed+, not +Timeout+: the two
9519
+ # are distinguishable, unlike a bare +receiveMessage+ that assumes a
9520
+ # failed read always means the peer is gone (kexhq/kex#381).
9521
+ #
9522
+ # @param timeout [Duration?] how long to wait before giving up, or +None+
9523
+ # to wait as long as the peer stays connected
9524
+ # @return [Result<Message, NetError>] the next data or close message, or
9525
+ # +Timeout+ if none arrived in time
9526
+ #
9527
+ # @example Prompting an otherwise-quiet client every 30 seconds
9528
+ # match connection.receiveMessage(timeout: Just(30.seconds)).try do
9529
+ # Text(text) => handleEvent(text)
9530
+ # _ => sendPing(connection)
9531
+ # end
9532
+ #
9533
+ # @example Being explicit that a wait has no deadline, rather than relying
9534
+ # on the zero-argument form's default
9535
+ # connection.receiveMessage(timeout: None)
9536
+ foul receiveMessage(timeout: Duration?) -> Result<Message, NetError> = Kex.Intrinsic.NetWebSocket.receiveMessageWithin(this, timeout)
8842
9537
  # Returns handshake details negotiated with the server.
8843
9538
  #
8844
9539
  # @return [Session] the selected subprotocol, if any
@@ -9450,6 +10145,146 @@ module TLS do
9450
10145
  foul closed?(connection: TLSConnection) -> Bool = Kex.Intrinsic.NetTLS.closed?(connection)
9451
10146
 
9452
10147
  end
10148
+ # Distributed Kex: this BEAM node, and the other nodes it is connected to.
10149
+ #
10150
+ # A node is one running BEAM VM with a name. Once two nodes share a cookie and
10151
+ # are connected, a +Pid+ from one works on the other: +send+, +link+ and
10152
+ # +monitor+ cross the network unchanged, and so do typed +Process+ and
10153
+ # +Server+ handles.
10154
+ #
10155
+ # Name a node when starting it (`kex --sname a --cookie secret app.kex`), or
10156
+ # from inside the program with +Node.start+:
10157
+ #
10158
+ # using Node
10159
+ #
10160
+ # main do
10161
+ # Node.connect(:b@myhost)
10162
+ # Node.send(:b@myhost, :logger, (:hello, Node.self))
10163
+ # end
10164
+ #
10165
+ # Node names are atoms: `:b@myhost` for a short name, the quoted
10166
+ # `:"b@host.example.com"` for one with dots, or `Atom.from(text)` for one
10167
+ # built at runtime.
10168
+ #
10169
+ # Both nodes need the same compiled code for anything that carries a function,
10170
+ # like a lambda or a record whose methods the receiver calls; +Node.spawn+
10171
+ # sends its block's code along. Plain data — numbers, strings, lists, tuples,
10172
+ # records — needs nothing.
10173
+ #
10174
+ # Backed by Kex.Intrinsic.Node on the BEAM. The tree-walk interpreter is always
10175
+ # a single, unnamed node.
10176
+ module Node do
10177
+ # This node's name, or +:nonode@nohost+ when it is not distributed.
10178
+ #
10179
+ # @return [Atom] the node name
10180
+ #
10181
+ # @example
10182
+ # Node.self # => :a@myhost
10183
+ self : Atom
10184
+ foul self = Kex.Intrinsic.Node.self
10185
+
10186
+ # Whether this node is distributed — started with a name, so other nodes
10187
+ # can connect to it.
10188
+ #
10189
+ # @return [Bool] true once the node has a name
10190
+ #
10191
+ # @example
10192
+ # Node.alive? # => false, unless started with --sname or Node.start
10193
+ alive? : Bool
10194
+ foul alive? = Kex.Intrinsic.Node.alive?
10195
+
10196
+ # The nodes this one is currently connected to, not including itself.
10197
+ #
10198
+ # @return [[Atom]] the connected node names
10199
+ #
10200
+ # @example
10201
+ # Node.list # => [:b@myhost]
10202
+ list : [Atom]
10203
+ foul list = Kex.Intrinsic.Node.list
10204
+
10205
+ # Makes this node distributed under +name+, as `--sname`/`--name` would at
10206
+ # startup. A +name+ whose host has a dot (`:"app@host.example.com"`) uses
10207
+ # long names; anything else uses short names.
10208
+ #
10209
+ # @param name [Atom] the node name, like +:app+ or +:app@myhost+
10210
+ # @return [Result<Atom, String>] the full node name, or why it could not start
10211
+ #
10212
+ # @example
10213
+ # Node.start(:app) # => Ok(:app@myhost)
10214
+ start : Atom -> Result<Atom, String>
10215
+ foul start(name) = Kex.Intrinsic.Node.start(name)
10216
+
10217
+ # Stops distribution: the node drops its name and every connection.
10218
+ #
10219
+ # @return [Bool] whether it was distributed and has now stopped
10220
+ stop : Bool
10221
+ foul stop = Kex.Intrinsic.Node.stop
10222
+
10223
+ # Connects to the node named +node+. Both nodes must share a cookie.
10224
+ #
10225
+ # @param node [Atom] the node to connect to
10226
+ # @return [Bool] whether the connection is up
10227
+ #
10228
+ # @example
10229
+ # Node.connect(:b@myhost) # => true
10230
+ connect : Atom -> Bool
10231
+ foul connect(node) = Kex.Intrinsic.Node.connect(node)
10232
+
10233
+ # Drops the connection to +node+.
10234
+ #
10235
+ # @param node [Atom] the node to disconnect from
10236
+ # @return [Bool] whether a connection was dropped
10237
+ disconnect : Atom -> Bool
10238
+ foul disconnect(node) = Kex.Intrinsic.Node.disconnect(node)
10239
+
10240
+ # Sets the cookie this node presents when connecting. Nodes connect only
10241
+ # when their cookies match.
10242
+ #
10243
+ # @param cookie [String] the shared secret
10244
+ # @return [Void]
10245
+ setCookie : String -> Void
10246
+ foul setCookie(cookie) = Kex.Intrinsic.Node.setCookie(cookie)
10247
+
10248
+ # Sends +message+ to the process registered as +name+ on +node+, unchanged.
10249
+ # Like +Pid.send+, it never blocks and never fails.
10250
+ #
10251
+ # @param node [Atom] the node the process runs on
10252
+ # @param name [Atom] the name it was registered under
10253
+ # @param message [X] the message
10254
+ # @return [Void]
10255
+ #
10256
+ # @example
10257
+ # Node.send(:b@myhost, :logger, (:info, "started"))
10258
+ send : Atom -> Atom -> X -> Void
10259
+ foul send(node, name, message) = Kex.Intrinsic.Node.send(node, name, message)
10260
+
10261
+ # The +Pid+ registered as +name+ on +node+, or +None+.
10262
+ #
10263
+ # @param node [Atom] the node to ask
10264
+ # @param name [Atom] the registered name
10265
+ # @return [Pid?] the process, or +None+
10266
+ #
10267
+ # @example
10268
+ # Node.whereIs(:b@myhost, :logger).map { |pid| pid.send(:flush) }
10269
+ whereIs : Atom -> Atom -> Pid?
10270
+ foul whereIs(node, name) = Kex.Intrinsic.Node.whereIs(node, name)
10271
+
10272
+ # Runs +block+ in a new process on +node+ and returns its +Pid+.
10273
+ #
10274
+ # The block is a function of THIS program. When +node+ has not loaded the
10275
+ # module it belongs to, that module's compiled code is sent over and loaded
10276
+ # first. A module +node+ already has is left as it is.
10277
+ #
10278
+ # @param node [Atom] where to run it
10279
+ # @param block [Block<X>] the work
10280
+ # @return [Pid] the new process
10281
+ #
10282
+ # @example
10283
+ # let me = Process.self
10284
+ # Node.spawn(:b@myhost) do me.send(Node.self) end
10285
+ spawn : Atom -> Block<X> -> Pid
10286
+ foul spawn(node, block) = Kex.Intrinsic.Node.spawn(node, block)
10287
+ end
9453
10288
  # Whole numbers, of arbitrary size.
9454
10289
  #
9455
10290
  # +Integer+ has no width limit: factorials and cryptographic moduli are
@@ -9564,7 +10399,13 @@ make Integer do
9564
10399
  #
9565
10400
  # @example Repeating an action
9566
10401
  # retries.times { |_| attemptConnection }
10402
+ #
10403
+ # @example Repeating an action that needs no index
10404
+ # 3.times do
10405
+ # IO.printLine("hi")
10406
+ # end
9567
10407
  times :> (Integer -> Void) -> Void
10408
+ times :> Block<Void> -> Void
9568
10409
  let times(block) = Kex.Intrinsic.Integer.times(this, block)
9569
10410
 
9570
10411
  # Returns the integer unchanged. Present so that code written against
@@ -9810,6 +10651,151 @@ module Float do
9810
10651
  # Float.parsePrefix("12.5kg") # => Just((12.5, "kg"))
9811
10652
  parsePrefix : String -> (Float, String)?
9812
10653
  let parsePrefix(s) = Kex.Intrinsic.Float.parsePrefix(s)
10654
+
10655
+ # The largest finite +Float+.
10656
+ #
10657
+ # A Kex +Float+ is always finite, so there is no infinity to start from:
10658
+ # this is the bound to use instead, say as the first "smallest so far".
10659
+ #
10660
+ # @return [Float] 1.7976931348623157e308
10661
+ #
10662
+ # @example
10663
+ # let smallest = readings.reduce(Float.MAX) { |low, x| x < low then x else low }
10664
+ MAX : Float
10665
+ let MAX = 1.7976931348623157e308
10666
+
10667
+ # The most negative finite +Float+: +-Float.MAX+. Not the smallest positive
10668
+ # one, which some languages call MIN.
10669
+ #
10670
+ # @return [Float] -1.7976931348623157e308
10671
+ MIN : Float
10672
+ let MIN = -1.7976931348623157e308
10673
+ end
10674
+
10675
+ # The bounds of the sized numeric types: +Int8.MAX+, +UInt64.MAX+,
10676
+ # +Float32.MIN+ and so on. +Integer+ has none: it is arbitrary-precision.
10677
+ # For a float type, +MIN+ is the most negative finite value, not the smallest
10678
+ # positive one.
10679
+
10680
+ # The range of a 32-bit float.
10681
+ module Float32 do
10682
+ # The largest Float32: 3.4028234663852886e38.
10683
+ MAX : Float32
10684
+ let MAX = 3.4028234663852886e38
10685
+
10686
+ # The most negative finite Float32: -3.4028234663852886e38.
10687
+ MIN : Float32
10688
+ let MIN = -3.4028234663852886e38
10689
+ end
10690
+
10691
+ # The range of a 64-bit float, the same as a +Float+.
10692
+ module Float64 do
10693
+ # The largest Float64: 1.7976931348623157e308.
10694
+ MAX : Float64
10695
+ let MAX = 1.7976931348623157e308
10696
+
10697
+ # The most negative finite Float64: -1.7976931348623157e308.
10698
+ MIN : Float64
10699
+ let MIN = -1.7976931348623157e308
10700
+ end
10701
+
10702
+ # The range of a signed 8-bit integer.
10703
+ module Int8 do
10704
+ # The largest Int8: 127.
10705
+ MAX : Int8
10706
+ let MAX = 127
10707
+
10708
+ # The smallest Int8: -128.
10709
+ MIN : Int8
10710
+ let MIN = -128
10711
+ end
10712
+
10713
+ # The range of a signed 16-bit integer.
10714
+ module Int16 do
10715
+ # The largest Int16: 32767.
10716
+ MAX : Int16
10717
+ let MAX = 32767
10718
+
10719
+ # The smallest Int16: -32768.
10720
+ MIN : Int16
10721
+ let MIN = -32768
10722
+ end
10723
+
10724
+ # The range of a signed 32-bit integer.
10725
+ module Int32 do
10726
+ # The largest Int32: 2147483647.
10727
+ MAX : Int32
10728
+ let MAX = 2147483647
10729
+
10730
+ # The smallest Int32: -2147483648.
10731
+ MIN : Int32
10732
+ let MIN = -2147483648
10733
+ end
10734
+
10735
+ # The range of a signed 64-bit integer.
10736
+ module Int64 do
10737
+ # The largest Int64: 9223372036854775807.
10738
+ MAX : Int64
10739
+ let MAX = 9223372036854775807
10740
+
10741
+ # The smallest Int64: -9223372036854775808.
10742
+ MIN : Int64
10743
+ let MIN = -9223372036854775808
10744
+ end
10745
+
10746
+ # The range of an unsigned 8-bit integer: +UInt8+ is another name for +Byte+.
10747
+ module UInt8 do
10748
+ # The largest UInt8: 255.
10749
+ MAX : UInt8
10750
+ let MAX = 255
10751
+
10752
+ # The smallest UInt8: 0.
10753
+ MIN : UInt8
10754
+ let MIN = 0
10755
+ end
10756
+
10757
+ # The range of an unsigned 16-bit integer.
10758
+ module UInt16 do
10759
+ # The largest UInt16: 65535.
10760
+ MAX : UInt16
10761
+ let MAX = 65535
10762
+
10763
+ # The smallest UInt16: 0.
10764
+ MIN : UInt16
10765
+ let MIN = 0
10766
+ end
10767
+
10768
+ # The range of an unsigned 32-bit integer.
10769
+ module UInt32 do
10770
+ # The largest UInt32: 4294967295.
10771
+ MAX : UInt32
10772
+ let MAX = 4294967295
10773
+
10774
+ # The smallest UInt32: 0.
10775
+ MIN : UInt32
10776
+ let MIN = 0
10777
+ end
10778
+
10779
+ # The range of an unsigned 64-bit integer.
10780
+ module UInt64 do
10781
+ # The largest UInt64: 18446744073709551615.
10782
+ MAX : UInt64
10783
+ let MAX = 18446744073709551615
10784
+
10785
+ # The smallest UInt64: 0.
10786
+ MIN : UInt64
10787
+ let MIN = 0
10788
+ end
10789
+
10790
+ # The range of a +Byte+, Kex's unsigned 8-bit integer (also called +UInt8+).
10791
+ module Byte do
10792
+ # The largest Byte: 255.
10793
+ MAX : Byte
10794
+ let MAX = 255
10795
+
10796
+ # The smallest Byte: 0.
10797
+ MIN : Byte
10798
+ let MIN = 0
9813
10799
  end
9814
10800
 
9815
10801
  # Reading a number out of text without deciding in advance which half of the
@@ -11202,7 +12188,7 @@ make Input do
11202
12188
  # use it where the grammar requires something to be there.
11203
12189
  #
11204
12190
  # @param f [Input -> Result<(T, Input), ParseError>] the parser to repeat
11205
- # @return [([T], Input)] the results and the cursor, or the first failure
12191
+ # @return [Result<([T], Input), ParseError>] the results and the cursor, or the first failure
11206
12192
  #
11207
12193
  # @example
11208
12194
  # Input { input: "abc" }.some { |p| p.charWhen(~alpha?) }
@@ -11223,7 +12209,7 @@ make Input do
11223
12209
  # the caret.
11224
12210
  #
11225
12211
  # @param expected [String] the literal that must be next
11226
- # @return [(String, Input)] the literal and the cursor, or an +Expected+ error
12212
+ # @return [Result<(String, Input), ParseError>] the literal and the cursor, or an +Expected+ error
11227
12213
  #
11228
12214
  # @example
11229
12215
  # Input { input: "abc" }.string("abc") # => Ok(("abc", cursor at 3))
@@ -11279,7 +12265,7 @@ make Input do
11279
12265
  # they all started from.
11280
12266
  #
11281
12267
  # @param alts [[Input -> Result<(T, Input), ParseError>]] the parsers to try, in order
11282
- # @return [(T, Input)] the first success, or +NoMatch+
12268
+ # @return [Result<(T, Input), ParseError>] the first success, or +NoMatch+
11283
12269
  #
11284
12270
  # @example
11285
12271
  # cursor.choice([
@@ -11333,6 +12319,7 @@ end
11333
12319
  # declaration in that source file, not here.
11334
12320
 
11335
12321
  using Algebra
12322
+ using Atom
11336
12323
  using Binary
11337
12324
  using Blankable
11338
12325
  using Comparable
@@ -11398,7 +12385,7 @@ using Units
11398
12385
 
11399
12386
  # An opaque BEAM process identifier.
11400
12387
  #
11401
- # Obtained from +Process.self+ or +Process.whereis+. Send it messages, link to
12388
+ # Obtained from +Process.self+ or +Process.whereIs+. Send it messages, link to
11402
12389
  # it, monitor it, or ask whether it is still alive.
11403
12390
  type Pid
11404
12391
 
@@ -11627,19 +12614,91 @@ module Process do
11627
12614
  register : Pid -> Atom -> Void
11628
12615
  foul register(pid, name) = Kex.Intrinsic.Process.register(pid, name)
11629
12616
 
12617
+ # Registers the typed process +process+ under the atom +name+.
12618
+ #
12619
+ # @param process [Process<X>] the process to register
12620
+ # @param name [Atom] the name to register it under
12621
+ # @return [Void]
12622
+ #
12623
+ # @example
12624
+ # let logger = spawn do logLoop() end
12625
+ # Process.register(logger, :logger)
12626
+ register : Process<X> -> Atom -> Void
12627
+ foul register(process, name) = Kex.Intrinsic.Process.register(process, name)
12628
+
11630
12629
  # Returns the +Pid+ registered under +name+, or +None+ when nothing is.
11631
12630
  #
11632
12631
  # @param name [Atom] the registered name
11633
12632
  # @return [Pid?] the process, or +None+
11634
12633
  #
11635
12634
  # @example
11636
- # Process.whereis(:main) # => Just(pid)
11637
- # Process.whereis(:not_there) # => None
12635
+ # Process.whereIs(:main) # => Just(pid)
12636
+ # Process.whereIs(:not_there) # => None
11638
12637
  #
11639
12638
  # @example Sending to a named process if it is there
11640
- # Process.whereis(:logger).map { |pid| pid.send(message) }
11641
- whereis : Atom -> Pid?
11642
- foul whereis(name) = Kex.Intrinsic.Process.whereis(name)
12639
+ # Process.whereIs(:logger).map { |pid| pid.send(message) }
12640
+ whereIs : Atom -> Pid?
12641
+ foul whereIs(name) = Kex.Intrinsic.Process.whereIs(name)
12642
+
12643
+ # A named, typed slot of state that every process can read without asking
12644
+ # another process for it.
12645
+ #
12646
+ # State kept in a +serving+ process is copied into the caller on every
12647
+ # read, which is costly for a large value read by every request. A
12648
+ # +Shared+ value is stored once for the whole node (on the BEAM, in
12649
+ # +persistent_term+): reading it copies nothing, however large it is.
12650
+ #
12651
+ # The price is on the other side: replacing a value makes the runtime scan
12652
+ # every process for references to the old one. So it suits state that is
12653
+ # read constantly and written rarely, such as configuration or a loaded
12654
+ # site, and not a counter.
12655
+ #
12656
+ # Get a handle with +Process.Shared.named+. The slot is identified by its
12657
+ # name alone, so two handles with the same name are the same slot; the type
12658
+ # parameter is what a handle promises to store and read back, so give every
12659
+ # handle on one name the same one.
12660
+ #
12661
+ # @example Loading once, reading from every request handler
12662
+ # let site : Process.Shared<Site> = Process.Shared.named("site")
12663
+ # site.put(loadSite(root))
12664
+ # ...
12665
+ # let current = site.get.or(emptySite)
12666
+ type Shared<X>
12667
+
12668
+ module Shared do
12669
+ # The handle on the slot called +name+. Nothing is stored or read until
12670
+ # +put+ or +get+.
12671
+ #
12672
+ # @param name [String] the slot's name
12673
+ # @return [Process.Shared<X>] a handle on the slot
12674
+ named : String -> Process.Shared<X>
12675
+ let named(name) = Kex.Intrinsic.Shared.named(name)
12676
+ end
12677
+
12678
+ make Shared<X> do
12679
+ # Stores +value+, replacing whatever the slot held.
12680
+ #
12681
+ # Expensive: see +Process.Shared+.
12682
+ #
12683
+ # @param value [X] the value to store
12684
+ # @return [Void]
12685
+ put :> X -> Void
12686
+ foul put(value) = Kex.Intrinsic.Shared.put(this, value)
12687
+
12688
+ # The stored value, or +None+ when nothing has been stored yet.
12689
+ #
12690
+ # Cheap, whatever the value's size.
12691
+ #
12692
+ # @return [X?] the stored value
12693
+ get :> X?
12694
+ foul get = Kex.Intrinsic.Shared.get(this)
12695
+
12696
+ # Empties the slot.
12697
+ #
12698
+ # @return [Bool] +true+ when it held a value
12699
+ delete :> Bool
12700
+ foul delete = Kex.Intrinsic.Shared.delete(this)
12701
+ end
11643
12702
  end
11644
12703
 
11645
12704
  # What an external command left behind: its exit status and its output.
@@ -11935,10 +12994,278 @@ make Reference do
11935
12994
  demonitor :> Void
11936
12995
  let demonitor = Kex.Intrinsic.Process.demonitor(this)
11937
12996
  end
12997
+ # Random values for simulations, sampling, games, and reproducible tests.
12998
+ #
12999
+ # Import with +using Random+. Ordinary calls return a value directly;
13000
+ # a +Random.Generator+ returns +(value, nextGenerator)+. Reusing the same
13001
+ # generator replays the same draw. Pass its returned state to continue.
13002
+ #
13003
+ # @example Roll a die or initialize a weight
13004
+ # using Random
13005
+ # let roll = Random.integer(1..6)
13006
+ # let weight = Random.float(-1.0..1.0)
13007
+ #
13008
+ # @example Reproduce a sequence
13009
+ # let generator = Random.seeded(42)
13010
+ # let (first, afterFirst) = generator.integer(1..6)
13011
+ # let (second, afterSecond) = afterFirst.integer(1..6)
13012
+ #
13013
+ # These generators are not cryptographic; do not use their output for secrets.
13014
+ # +Random.secureBytes+ and +Random.token+ are, and are what secrets come from.
13015
+ module Random do
13016
+ # Immutable state of a deterministic random sequence.
13017
+ #
13018
+ # Construct with +Random.seeded+ or +Random.fresh+. Each draw returns its
13019
+ # value first and the advanced generator second. Equal seeds reproduce equal
13020
+ # sequences for the same operations on both backends.
13021
+ record Generator do
13022
+ state : Integer = 0
13023
+ end
13024
+
13025
+ # Starts a deterministic sequence. Any Integer seed is reduced modulo 2^64.
13026
+ # @param seed [Integer] reproducible seed, including negative or large values
13027
+ # @return [Generator] initial state; no draw has been consumed
13028
+ let seeded(seed: Integer) -> Generator = Generator {
13029
+ state: seed.modulo(18446744073709551616)
13030
+ }
13031
+
13032
+ # Starts a generator using fresh host entropy.
13033
+ # @return [Generator] initial state for a sequence that varies between runs
13034
+ foul fresh -> Generator do
13035
+ let high = Math.floor(Kex.Intrinsic.Retry.randomUnit() * 4294967296.0)
13036
+ let low = Math.floor(Kex.Intrinsic.Retry.randomUnit() * 4294967296.0)
13037
+ seeded(high * 4294967296 + low)
13038
+ end
13039
+
13040
+ module Generator do
13041
+ make Random.Generator do
13042
+ # Draws an integer between both endpoints, with the advanced generator.
13043
+ # The range must be ascending and contain at most 2^64 integers.
13044
+ # A singleton range is valid and still consumes a draw.
13045
+ # @param range [Range<Integer>] inclusive integer bounds
13046
+ # @return [(Integer, Generator)] drawn integer and advanced state
13047
+ # @example A reproducible die roll
13048
+ # let (roll, advanced) = Random.seeded(42).integer(1..6)
13049
+ let integer(range: Range<Integer>) -> (Integer, Generator) = drawInteger(this, range)
13050
+
13051
+ # Draws a float from the lower bound inclusive to the upper bound exclusive.
13052
+ # Defaults to +0.0..1.0+. Bounds must be finite and strictly ascending.
13053
+ # @param range [Range<Float>] half-open floating-point bounds
13054
+ # @return [(Float, Generator)] drawn float and advanced state
13055
+ # @example Initialize a model weight
13056
+ # let (weight, advanced) = Random.seeded(42).float(-1.0..1.0)
13057
+ let float(range: Range<Float>) -> (Float, Generator) = drawFloat(this, range)
13058
+ let float -> (Float, Generator) = drawFloat(this, 0.0..1.0)
13059
+
13060
+ # Draws a fair Boolean and returns the advanced generator.
13061
+ # @return [(Bool, Generator)] coin flip and advanced state
13062
+ let boolean -> (Bool, Generator) = drawBoolean(this)
13063
+
13064
+ # Draws true with the supplied probability. Consumes one draw even at 0 or 1.
13065
+ # @param probability [Float] probability from 0.0 through 1.0
13066
+ # @return [(Bool, Generator)] outcome and advanced state
13067
+ let chance?(probability: Float) -> (Bool, Generator) = drawChance(this, probability)
13068
+
13069
+ # Chooses an input position uniformly. Empty input returns None without a draw.
13070
+ # @param items [[X]] values to choose from
13071
+ # @return [(X?, Generator)] optional value and advanced state
13072
+ let choice(items: [X]) -> (X?, Generator) = drawChoice(this, items)
13073
+
13074
+ # Selects input positions without replacement, in random order.
13075
+ # Duplicate input values may appear more than once in the result.
13076
+ # A count larger than the input selects every position; zero consumes no draws.
13077
+ # @param items [[X]] population; unchanged by sampling
13078
+ # @param count [Integer] nonnegative number of positions to select
13079
+ # @return [([X], Generator)] selected values and advanced state
13080
+ # @example Deal five cards reproducibly
13081
+ # let (hand, advanced) = Random.seeded(42).sample(cards, count: 5)
13082
+ let sample(items: [X], count: Integer) -> ([X], Generator) = drawSample(this, items, count)
13083
+
13084
+ # Returns a random permutation, preserving duplicates and the input itself.
13085
+ # Empty input consumes no draws.
13086
+ # @param items [[X]] values to shuffle
13087
+ # @return [([X], Generator)] permutation and advanced state
13088
+ let shuffle(items: [X]) -> ([X], Generator) = drawSample(this, items, items.count)
13089
+ end
13090
+ end
13091
+
13092
+ # Draws an integer from an inclusive range using fresh entropy.
13093
+ # @param range [Range<Integer>] ascending inclusive bounds, span at most 2^64
13094
+ # @return [Integer] drawn value
13095
+ foul integer(range: Range<Integer>) -> Integer do
13096
+ let (value, _) = drawInteger(fresh, range)
13097
+ value
13098
+ end
13099
+
13100
+ # Draws a float; defaults to [0.0, 1.0). The upper bound is excluded.
13101
+ # @param range [Range<Float>] finite, strictly ascending bounds
13102
+ # @return [Float] drawn value
13103
+ foul float(range: Range<Float> = 0.0..1.0) -> Float do
13104
+ let (value, _) = drawFloat(fresh, range)
13105
+ value
13106
+ end
13107
+
13108
+ # Draws a fair coin flip using fresh entropy.
13109
+ # @return [Bool] true or false with equal probability
13110
+ foul boolean -> Bool do
13111
+ let (value, _) = drawBoolean(fresh)
13112
+ value
13113
+ end
13114
+
13115
+ # Returns true with the supplied probability using fresh entropy.
13116
+ # @param probability [Float] probability from 0.0 through 1.0
13117
+ # @return [Bool] sampled outcome
13118
+ foul chance?(probability: Float) -> Bool do
13119
+ let (value, _) = drawChance(fresh, probability)
13120
+ value
13121
+ end
13122
+
13123
+ # Chooses a position uniformly, returning None for an empty input.
13124
+ # @param items [[X]] values to choose from
13125
+ # @return [X?] chosen value, if any
13126
+ foul choice(items: [X]) -> X? do
13127
+ let (value, _) = drawChoice(fresh, items)
13128
+ value
13129
+ end
13130
+
13131
+ # Samples positions without replacement; duplicate values remain possible.
13132
+ # @param items [[X]] population
13133
+ # @param count [Integer] nonnegative count; capped at the input size
13134
+ # @return [[X]] values selected in random order
13135
+ foul sample(items: [X], count: Integer) -> [X] do
13136
+ let (value, _) = drawSample(fresh, items, count)
13137
+ value
13138
+ end
13139
+
13140
+ # Returns a random permutation using fresh entropy.
13141
+ # @param items [[X]] values to shuffle
13142
+ # @return [[X]] shuffled copy
13143
+ foul shuffle(items: [X]) -> [X] do
13144
+ let (value, _) = drawSample(fresh, items, items.count)
13145
+ value
13146
+ end
13147
+
13148
+ # Returns +count+ bytes from the operating system's cryptographically
13149
+ # secure generator: the source for keys, session tokens and unguessable
13150
+ # identifiers, which the generators above must never be used for.
13151
+ #
13152
+ # Nothing about it is reproducible, and there is no +Generator+ form.
13153
+ #
13154
+ # @param count [Integer] how many bytes; zero or less gives an empty binary
13155
+ # @return [Binary] the random bytes
13156
+ #
13157
+ # @example A 256-bit key
13158
+ # let key = Random.secureBytes(32)
13159
+ foul secureBytes(count: Integer) -> Binary = Kex.Intrinsic.Random.secureBytes(count)
13160
+
13161
+ # Returns an unguessable token: +bytes+ secure random bytes, as lowercase
13162
+ # hex, so the text is twice as long as +bytes+.
13163
+ #
13164
+ # @param bytes [Integer] how many random bytes the token carries
13165
+ # @return [String] the token
13166
+ #
13167
+ # @example A session identifier
13168
+ # let session = Random.token(32) # => "9f86d081884c7d65..." (64 characters)
13169
+ foul token(bytes: Integer = 32) -> String = secureBytes(bytes).hex
13170
+
13171
+ private do
13172
+ let drawInteger(source: Generator, range: Range<Integer>) -> (Integer, Generator) do
13173
+ let (low, high) = integerBounds(range)
13174
+ integerBetween(source, low, high)
13175
+ end
13176
+
13177
+ let drawFloat(source: Generator, range: Range<Float>) -> (Float, Generator) do
13178
+ let (low, high) = floatBounds(range)
13179
+ if !(low < high)
13180
+ die("Random.float: bounds must be finite and strictly ascending")
13181
+ end
13182
+ let (word, advanced) = nextWord(source)
13183
+ let unit = (Kex.Intrinsic.Bits.shiftRight(word, 11) * 1.0) / 9007199254740992.0
13184
+ (Kex.Intrinsic.Float.scaleUnit(low, high, unit), advanced)
13185
+ end
13186
+
13187
+ let drawBoolean(source: Generator) -> (Bool, Generator) do
13188
+ let (word, advanced) = nextWord(source)
13189
+ (Kex.Intrinsic.Bits.shiftRight(word, 63) == 1, advanced)
13190
+ end
13191
+
13192
+ let drawChance(source: Generator, probability: Float) -> (Bool, Generator) do
13193
+ if !(probability >= 0.0 && probability <= 1.0)
13194
+ die("Random.chance?: probability must be between 0.0 and 1.0")
13195
+ end
13196
+ let (value, advanced) = drawFloat(source, 0.0..1.0)
13197
+ (value < probability, advanced)
13198
+ end
13199
+
13200
+ let drawChoice(source: Generator, items: [X]) -> (X?, Generator) do
13201
+ return (None, source) if items.empty?
13202
+ let (index, advanced) = below(source, items.count)
13203
+ (items.at(index), advanced)
13204
+ end
13205
+
13206
+ let drawSample(source: Generator, items: [X], count: Integer) -> ([X], Generator) do
13207
+ if count < 0
13208
+ die("Random.sample: count must not be negative")
13209
+ end
13210
+ var pool = items
13211
+ var selected: [X] = []
13212
+ var generator = source
13213
+ while !pool.empty? && selected.count < count do
13214
+ let (index, advanced) = below(generator, pool.count)
13215
+ generator = advanced
13216
+ selected.push!(pool.at(index).try)
13217
+ pool = pool.take(index) + pool.drop(index + 1)
13218
+ end
13219
+ (selected, generator)
13220
+ end
13221
+
13222
+ integerBetween : Generator -> Integer -> Integer -> (Integer, Generator)
13223
+ let integerBetween(generator, low, high) do
13224
+ if low > high
13225
+ die("Random.integer: lower bound must not exceed upper bound")
13226
+ end
13227
+ let (value, advanced) = below(generator, high - low + 1)
13228
+ (low + value, advanced)
13229
+ end
13230
+
13231
+ integerBounds : Range<Integer> -> (Integer, Integer)
13232
+ let integerBounds(range) = Kex.Intrinsic.Range.bounds(range)
13233
+
13234
+ floatBounds : Range<Float> -> (Float, Float)
13235
+ let floatBounds(range) = Kex.Intrinsic.Range.bounds(range)
13236
+
13237
+ # SplitMix64's arithmetic stays private; public operations expose values.
13238
+ nextWord : Generator -> (Integer, Generator)
13239
+ let nextWord(generator: Generator) -> (Integer, Generator) do
13240
+ let mask = 18446744073709551615
13241
+ let advanced = Kex.Intrinsic.Bits.and(generator.state + 0x9e3779b97f4a7c15, mask)
13242
+ let spread1 = Kex.Intrinsic.Bits.xor(advanced, Kex.Intrinsic.Bits.shiftRight(advanced, 30))
13243
+ let mixed1 = Kex.Intrinsic.Bits.and(spread1 * 0xbf58476d1ce4e5b9, mask)
13244
+ let spread2 = Kex.Intrinsic.Bits.xor(mixed1, Kex.Intrinsic.Bits.shiftRight(mixed1, 27))
13245
+ let mixed2 = Kex.Intrinsic.Bits.and(spread2 * 0x94d049bb133111eb, mask)
13246
+ let word = Kex.Intrinsic.Bits.xor(mixed2, Kex.Intrinsic.Bits.shiftRight(mixed2, 31))
13247
+ (word, Generator { state: advanced })
13248
+ end
13249
+
13250
+ below : Generator -> Integer -> (Integer, Generator)
13251
+ let below(generator: Generator, bound: Integer) -> (Integer, Generator) do
13252
+ let maximum = 18446744073709551616
13253
+ if bound <= 0 || bound > maximum
13254
+ die("Random.integer: range must contain between 1 and 2^64 integers")
13255
+ end
13256
+ let (word, advanced) = nextWord(generator)
13257
+ let limit = maximum - maximum.modulo(bound)
13258
+ return (word.modulo(bound), advanced) if word < limit
13259
+ below(advanced, bound)
13260
+ end
13261
+ end
13262
+ end
11938
13263
  # A span between two bounds, written +(1..10)+ or +('a'..'z')+.
11939
13264
  #
11940
- # A range stores only its two endpoints and computes everything else from
11941
- # them, so +(1..1000000)+ costs nothing to make. Both ends are included.
13265
+ # A range stores its endpoints on both backends without building a list.
13266
+ # Integer and character ranges can be enumerated with both ends included.
13267
+ # Float ranges describe continuous bounds, for example +(-1.0..1.0)+ for
13268
+ # random sampling; they cannot be enumerated without an explicit step.
11942
13269
  #
11943
13270
  # (1..5).items # => [1, 2, 3, 4, 5]
11944
13271
  # (1..5).sum # => 15
@@ -12022,11 +13349,8 @@ make Range, implement: Enumerable, Foldable do
12022
13349
  # @example
12023
13350
  # (3..7).first # => Just(3)
12024
13351
  # The list operations, over the materialized items. A range is an ordered
12025
- # collection, so `(1..5).first` and `(1..5).length` are questions it can
12026
- # answer, and on the BEAM backend, where a range IS its item list, they
12027
- # already worked. Only the walker rejected them ("Undefined method: first
12028
- # for Range"), so every one of these was a backend divergence rather than a
12029
- # deliberate restriction.
13352
+ # collection, so +(1..5).first+ and +(1..5).length+ operate on its
13353
+ # materialized elements. Floating bounds do not define such a collection.
12030
13354
  first :> A?
12031
13355
  let first = this.items.first
12032
13356
 
@@ -14251,10 +15575,13 @@ type Node = Text(String)
14251
15575
  type Tag = Scalar(String)
14252
15576
  | Tags([String])
14253
15577
 
14254
- # Why a template's text could not be scanned, and where.
15578
+ # Why a template's text could not be scanned or rendered, and where (or,
15579
+ # for `render`/`renderParsed`, what stopped it).
14255
15580
  type TemplateError = UnterminatedTag(Integer)
14256
15581
  | UnterminatedFrontmatter
14257
15582
  | MalformedFrontmatterLine(String)
15583
+ | UndefinedVariable(String)
15584
+ | UnsupportedControl(String)
14258
15585
 
14259
15586
  # One entry from a `params: [...]` frontmatter list: a name, and its
14260
15587
  # optional `: Type` annotation. `type` is raw text: `""` for a bare name,
@@ -14346,6 +15673,86 @@ let escapeHtml(text: String) -> String do
14346
15673
  .replace("'", "&#39;")
14347
15674
  end
14348
15675
 
15676
+ # Renders an already-scanned template's holes from a runtime `context`,
15677
+ # `<%= %>` HTML-escaped and `<%== %>` raw, same as `Template.html`/
15678
+ # `Template.text` do at compile time — but there is no runtime evaluator for
15679
+ # `<% ... %>` CONTROL regions here. Evaluating a `<% if … %>`/`<% match … %>`/
15680
+ # a block loop chosen at run time means evaluating arbitrary Kex source
15681
+ # picked at run time, which is its own design decision (kexhq/kex#335) and
15682
+ # not what this covers: a template using one reports `UnsupportedControl`
15683
+ # with the region's text rather than silently doing nothing with it, so the
15684
+ # gap is loud, not a template that quietly renders wrong.
15685
+ #
15686
+ # This is for what `Template.html(Kex.embed(path))` cannot do at all — a
15687
+ # template file chosen while the program is running, not baked in at compile
15688
+ # time — for the shape of template that does not need control flow: a
15689
+ # subject line, a notification body, a plain-text substitution. A template
15690
+ # with real control flow still needs compiling in (`Kex.embed`), or a hole
15691
+ # it does not have: turning `Parsed#nodes` into a fuller runtime evaluator is
15692
+ # further work this only lays the groundwork for.
15693
+ #
15694
+ # `<%= %>`/`<%== %>` names are looked up VERBATIM (trimmed of surrounding
15695
+ # whitespace) in `context` — `dep.name` in a template needs a `"dep.name"`
15696
+ # key, not field access into a `dep` key's value. Splitting a dotted hole
15697
+ # into a real field path is, again, further work.
15698
+ #
15699
+ # @param parsed [Parsed] a template already scanned by `Template.scan`
15700
+ # @param context [{String: String}] a value for every `<%= %>`/`<%== %>`
15701
+ # hole the template uses, keyed by the hole's exact (trimmed) text
15702
+ # @return [Result<String, TemplateError>] the rendered text, or why not
15703
+ #
15704
+ # @example
15705
+ # let parsed = Template.scan("Hi <%= name %>!").try
15706
+ # Template.renderParsed(parsed, { "name": "<Ada>" })
15707
+ # # => Ok("Hi &lt;Ada&gt;!")
15708
+ #
15709
+ # @example A hole `context` does not cover
15710
+ # Template.renderParsed(Template.scan("<%= missing %>").try, {})
15711
+ # # => Error(UndefinedVariable("missing"))
15712
+ #
15713
+ # @example Control flow is refused, not silently skipped
15714
+ # Template.renderParsed(Template.scan("<% if x %>y<% end %>").try, {})
15715
+ # # => Error(UnsupportedControl("if x"))
15716
+ let renderParsed(parsed: Parsed, context: {String: String}) -> Result<String, TemplateError> do
15717
+ var out = ""
15718
+ var i = 0
15719
+ loop do
15720
+ break if i >= parsed.nodes.count
15721
+ match parsed.nodes.at(i).or(Text("")) do
15722
+ Text(text) => out = "${out}${text}"
15723
+ Interpolate(name) => do
15724
+ let value = context.get(name.trim)
15725
+ return Error(UndefinedVariable(name.trim)) if value == None
15726
+ out = "${out}${escapeHtml(value.or(""))}"
15727
+ end
15728
+ InterpolateRaw(name) => do
15729
+ let value = context.get(name.trim)
15730
+ return Error(UndefinedVariable(name.trim)) if value == None
15731
+ out = "${out}${value.or("")}"
15732
+ end
15733
+ Comment(_) => ()
15734
+ Control(body) => return Error(UnsupportedControl(body))
15735
+ end
15736
+ i = i + 1
15737
+ end
15738
+ Ok(out)
15739
+ end
15740
+
15741
+ # `Template.scan(source).try` then `renderParsed` — see its doc comment for
15742
+ # what this does and, as importantly, what it refuses to do.
15743
+ #
15744
+ # @param source [String] the template's full text, scanned fresh
15745
+ # @param context [{String: String}] see `renderParsed`
15746
+ # @return [Result<String, TemplateError>] the rendered text, or why not
15747
+ #
15748
+ # @example
15749
+ # Template.render("Hi <%= name %>!", { "name": "Ada" })
15750
+ # # => Ok("Hi Ada!")
15751
+ let render(source: String, context: {String: String}) -> Result<String, TemplateError> do
15752
+ let parsed = scan(source).try
15753
+ renderParsed(parsed, context)
15754
+ end
15755
+
14349
15756
  private do
14350
15757
  # True when `cursor` sits at the very start of the input and that line is
14351
15758
  # exactly `---`: the only place a frontmatter block may open.
@@ -14375,17 +15782,21 @@ private do
14375
15782
  # A cursor advanced to the next `\n` (not past it), and the text skipped.
14376
15783
  #
14377
15784
  # Hand-rolled rather than `Input#takeWhile`: see `scanText`'s comment on why.
15785
+ #
15786
+ # Tracks the run's start position and slices once at the end, rather than
15787
+ # building it one `Char` at a time with `push!`: pushing to a `var` list
15788
+ # rebinds it to a freshly copied list every call (kexhq/kex#379), so a
15789
+ # character-at-a-time accumulation of an N-character line cost O(N^2), not
15790
+ # O(N). One slice is O(N) total regardless of how many lines are scanned.
14378
15791
  let scanLine(cursor: Input) -> (String, Input) do
14379
15792
  var cur = cursor
14380
- var chars: [Char] = []
15793
+ let start = cur.pos
14381
15794
  loop do
14382
15795
  break if cur.peek == None
14383
15796
  break if cur.peek == Just('\n')
14384
- let Just(ch) = cur.peek
14385
- chars.push!(ch)
14386
15797
  cur.advance!
14387
15798
  end
14388
- (chars.join(""), cur)
15799
+ (cur.input.chars.drop(start).take(cur.pos - start).join(""), cur)
14389
15800
  end
14390
15801
 
14391
15802
  # A line's text with any trailing `\r` dropped, for a source that mixes
@@ -14516,23 +15927,31 @@ private do
14516
15927
  # collides with `List#takeWhile`, and calling it through an `Input` receiver
14517
15928
  # sends overload resolution down the wrong path (`json.kex`'s own scanners
14518
15929
  # hit the same thing and avoid it the same way).
15930
+ #
15931
+ # Tracks each run's start position and slices it out whole, rather than
15932
+ # pushing one `Char` at a time: see `scanLine`'s comment on why that was
15933
+ # quadratic (kexhq/kex#379). `<%%` still needs a per-occurrence split (it
15934
+ # folds three input characters into two output ones), but that split is as
15935
+ # rare as `<%%` itself, not once per character of ordinary text.
14519
15936
  let scanText(cursor: Input) -> (String, Input) do
14520
15937
  var cur = cursor
14521
- var chars: [Char] = []
15938
+ var pieces: [String] = []
15939
+ var runStart = cur.pos
14522
15940
  loop do
14523
15941
  break if cur.peek == None
14524
15942
  break if atTagOpen?(cur)
14525
15943
  if cur.peek == Just('<') && cur.peekAt(1) == Just('%') && cur.peekAt(2) == Just('%')
14526
- chars.push!('<')
14527
- chars.push!('%')
15944
+ pieces.push!(cur.input.chars.drop(runStart).take(cur.pos - runStart).join(""))
15945
+ pieces.push!("<%")
14528
15946
  cur.advanceBy!(3)
15947
+ runStart = cur.pos
14529
15948
  else
14530
- let Just(ch) = cur.peek
14531
- chars.push!(ch)
14532
15949
  cur.advance!
15950
+ ()
14533
15951
  end
14534
15952
  end
14535
- (chars.join(""), cur)
15953
+ pieces.push!(cur.input.chars.drop(runStart).take(cur.pos - runStart).join(""))
15954
+ (pieces.join(""), cur)
14536
15955
  end
14537
15956
 
14538
15957
  # Strips trailing spaces and tabs (not newlines): the indentation a `<%-`
@@ -14616,16 +16035,17 @@ private do
14616
16035
 
14617
16036
  # Reads a tag's body up to its closer, `%>` or `-%>`. Answers the raw text,
14618
16037
  # whether the closer trims the following newline, and the cursor past it.
16038
+ #
16039
+ # Slices the body out once at the closer, rather than pushing one `Char` at
16040
+ # a time — see `scanLine`'s comment on why that was quadratic
16041
+ # (kexhq/kex#379).
14619
16042
  let scanTagBody(cursor: Input) -> Result<(String, Bool, Input), TemplateError> do
14620
16043
  let start = cursor.pos
14621
16044
  var cur = cursor
14622
- var chars: [Char] = []
14623
16045
  loop do
14624
16046
  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)
16047
+ 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('>')
16048
+ 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
16049
  cur.advance!
14630
16050
  end
14631
16051
  end
@@ -15466,10 +16886,10 @@ module Time do
15466
16886
  # Time.daysFromCivil(1970, 1, 1) # => 0
15467
16887
  # Time.daysFromCivil(2026, 7, 30) # => 20664
15468
16888
  let daysFromCivil(year: Integer, month: Integer, day: Integer) -> Integer do
15469
- let y = if month <= 2 then year - 1 else year end
15470
- let era = if y < 0 then (y - 399) / 400 else y / 400 end
16889
+ let y = month <= 2 then year - 1 else year
16890
+ let era = y < 0 then (y - 399) / 400 else y / 400
15471
16891
  let yoe = y - era * 400
15472
- let mp = if month > 2 then month - 3 else month + 9 end
16892
+ let mp = month > 2 then month - 3 else month + 9
15473
16893
  let doy = (153 * mp + 2) / 5 + day - 1
15474
16894
  let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
15475
16895
  return era * 146097 + doe - 719468
@@ -15486,15 +16906,15 @@ module Time do
15486
16906
  # Time.civilFromDays(20664).iso # => "2026-07-30"
15487
16907
  let civilFromDays(epochDay: Integer) -> Date do
15488
16908
  let z = epochDay + 719468
15489
- let era = if z < 0 then (z - 146096) / 146097 else z / 146097 end
16909
+ let era = z < 0 then (z - 146096) / 146097 else z / 146097
15490
16910
  let doe = z - era * 146097
15491
16911
  let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
15492
16912
  let y = yoe + era * 400
15493
16913
  let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
15494
16914
  let mp = (5 * doy + 2) / 153
15495
16915
  let day = doy - (153 * mp + 2) / 5 + 1
15496
- let month = if mp < 10 then mp + 3 else mp - 9 end
15497
- let year = if month <= 2 then y + 1 else y end
16916
+ let month = mp < 10 then mp + 3 else mp - 9
16917
+ let year = month <= 2 then y + 1 else y
15498
16918
  return Date { year: year, month: month, day: day }
15499
16919
  end
15500
16920
 
@@ -15629,8 +17049,8 @@ module Time do
15629
17049
  let formatOffset(offset: Duration) -> String do
15630
17050
  let total = offset.wholeSeconds
15631
17051
  return "Z" if total == 0
15632
- let sign = if total < 0 then "-" else "+" end
15633
- let magnitude = if total < 0 then 0 - total else total end
17052
+ let sign = total < 0 then "-" else "+"
17053
+ let magnitude = total < 0 then 0 - total else total
15634
17054
  return "${sign}${Time.pad2(magnitude / 3600)}:${Time.pad2(magnitude.modulo(3600) / 60)}"
15635
17055
  end
15636
17056
 
@@ -16170,7 +17590,7 @@ make Duration do
16170
17590
  #
16171
17591
  # @example
16172
17592
  # (1.hours - 90.minutes).abs.wholeMinutes # => 30
16173
- let abs -> Duration = Duration { seconds: if this.seconds < 0.0 then 0.0 - this.seconds else this.seconds end }
17593
+ let abs -> Duration = Duration { seconds: this.seconds < 0.0 then 0.0 - this.seconds else this.seconds }
16174
17594
 
16175
17595
  # Returns +true+ when the span is exactly zero.
16176
17596
  #
@@ -16542,9 +17962,9 @@ make Period, implement: Inspectable, Showable do
16542
17962
  # 2.months.iso # => "P2M"
16543
17963
  let iso -> String do
16544
17964
  return "P0D" if this.zero?
16545
- let years = if this.years != 0 then "${this.years}Y" else "" end
16546
- let months = if this.months != 0 then "${this.months}M" else "" end
16547
- let days = if this.days != 0 then "${this.days}D" else "" end
17965
+ let years = this.years != 0 then "${this.years}Y" else ""
17966
+ let months = this.months != 0 then "${this.months}M" else ""
17967
+ let days = this.days != 0 then "${this.days}D" else ""
16548
17968
  return "P${years}${months}${days}"
16549
17969
  end
16550
17970
  end
@@ -16708,7 +18128,7 @@ make Date, implement: Inspectable, Showable do
16708
18128
  let month = total.modulo(12) + 1
16709
18129
  let year = (total - total.modulo(12)) / 12
16710
18130
  let last = Time.daysInValidMonth(year, month)
16711
- let day = if this.day > last then last else this.day end
18131
+ let day = this.day > last then last else this.day
16712
18132
  return Date { year: year, month: month, day: day }
16713
18133
  end
16714
18134
 
@@ -17592,7 +19012,7 @@ make Type do
17592
19012
  # A function reads as its signature: `String -> String`, `foul () -> Void`.
17593
19013
  if this.name == "Function" && this.args.count > 0
17594
19014
  let arrow = this.args.map(&.to(String)).join(" -> ")
17595
- let signature = if this.args.count == 1 then "() -> ${arrow}" else arrow end
19015
+ let signature = this.args.count == 1 then "() -> ${arrow}" else arrow
17596
19016
  return "foul ${signature}" if this.pure == false
17597
19017
  return signature
17598
19018
  end
@@ -17918,11 +19338,21 @@ make Float do
17918
19338
  end
17919
19339
  end
17920
19340
 
17921
- make Measure do
19341
+ make Measure, implement: Showable do
17922
19342
  let factor -> Float = this.unit.factor
17923
19343
  let kind -> Atom = this.unit.kind
17924
19344
  let symbol -> String = this.unit.symbol
17925
19345
  let to(String) -> String = "${this.value} ${this.unit.symbol}"
19346
+
19347
+ # Renders the measure as its value and unit, so +IO.printLine+ and
19348
+ # interpolation show +5.0 W+ rather than the record's fields. Inspecting
19349
+ # it (the REPL's echo, +inspected+) still shows the record itself.
19350
+ #
19351
+ # @return [String] the value and the unit's symbol
19352
+ #
19353
+ # @example
19354
+ # "drawn: ${1.volt * 5.ampere}" # => "drawn: 5.0 W"
19355
+ let showValue -> String = this.to(String)
17926
19356
  # Formatting into a target display unit deliberately has NO prelude clause.
17927
19357
  # A unit module (Units.SI, Units.Data, ...) supplies `to(String, in:)` for
17928
19358
  # the units it owns, and reaching one requires importing it. A catch-all
@@ -18990,6 +20420,21 @@ module Query do
18990
20420
  from : [(String, String?)] -> Query
18991
20421
  let from(entries) = Query { entries: entries }
18992
20422
 
20423
+ # Builds a query from unordered key/value pairs, for the common case where
20424
+ # every key is used once. A `Map` cannot hold "tag" twice, so — unlike the
20425
+ # list form above — repeating a key here does not add a second field; it
20426
+ # silently keeps only one value for it. Reach for the ordered list form
20427
+ # instead of this one for anything that legitimately repeats a key, such
20428
+ # as `?tag=kex&tag=beam`.
20429
+ #
20430
+ # @param entries [Map<String, String?>] decoded key/value pairs, one per key
20431
+ # @return [Query] the query
20432
+ #
20433
+ # @example Building filters that each use a distinct key
20434
+ # Query.from({ "sort": Just("name"), "debug": None })
20435
+ from : Map<String, String?> -> Query
20436
+ let from(entries: Map<String, String?>) = Query { entries: entries.entries }
20437
+
18993
20438
  # Parses generic URI query encoding; ++ remains a literal plus.
18994
20439
  #
18995
20440
  # @param text [String] encoded query text without the leading question mark
@@ -19012,6 +20457,21 @@ module Form do
19012
20457
  from : [(String, String)] -> Form
19013
20458
  let from(entries) = Kex.Intrinsic.URI.formFrom(entries)
19014
20459
 
20460
+ # Builds a form from unordered key/value pairs, for the common case where
20461
+ # every field name is used once. A `Map` cannot hold a name twice, so —
20462
+ # unlike the list form above — a repeated field name here does not add a
20463
+ # second entry; it silently keeps only one value for it. Reach for the
20464
+ # ordered list form instead of this one for anything that legitimately
20465
+ # repeats a field name, such as several same-named checkboxes.
20466
+ #
20467
+ # @param entries [Map<String, String>] decoded form fields, one per name
20468
+ # @return [Form] the form
20469
+ #
20470
+ # @example Preparing a login request body
20471
+ # Form.from({ "email": email, "password": password }).encode
20472
+ from : Map<String, String> -> Form
20473
+ let from(entries: Map<String, String>) = Kex.Intrinsic.URI.formFrom(entries.entries)
20474
+
19015
20475
  # Parses form encoding where ++ represents a space.
19016
20476
  #
19017
20477
  # @param text [String] an +application/x-www-form-urlencoded+ body