@kexhq/kex 0.4.0-alpha.2-dev.20d979e → 0.4.0-alpha.2-dev.db076ab

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.
@@ -1041,6 +1041,7 @@ record Policy do
1041
1041
  multiplier : Float
1042
1042
  maximumDelay : Duration
1043
1043
  maximumElapsed : Duration? = None
1044
+ jitterFraction : Float = 0.0
1044
1045
  end
1045
1046
 
1046
1047
  # Decides whether an application error is eligible for another attempt.
@@ -1055,14 +1056,15 @@ type Sleeper = Duration -> Void
1055
1056
  # @param maximumAttempts [Integer] total calls including the first
1056
1057
  # @param delay [Duration] delay before each subsequent call
1057
1058
  # @return [Policy] the retry schedule
1058
- # @example +Retry.fixed(3, Duration.milliseconds(100))+.
1059
+ # @example +Retry.fixed(3, 100.milliseconds)+.
1059
1060
  fixed : Integer -> Duration -> Policy
1060
1061
  let fixed(maximumAttempts, delay) = Policy {
1061
1062
  maximumAttempts: maximumAttempts,
1062
1063
  initialDelay: delay,
1063
1064
  multiplier: 1.0,
1064
1065
  maximumDelay: delay,
1065
- maximumElapsed: None
1066
+ maximumElapsed: None,
1067
+ jitterFraction: 0.0
1066
1068
  }
1067
1069
 
1068
1070
  # Builds a doubling backoff capped at +maximumDelay+.
@@ -1077,7 +1079,8 @@ let exponential(maximumAttempts, initialDelay, maximumDelay) = Policy {
1077
1079
  initialDelay: initialDelay,
1078
1080
  multiplier: 2.0,
1079
1081
  maximumDelay: maximumDelay,
1080
- maximumElapsed: None
1082
+ maximumElapsed: None,
1083
+ jitterFraction: 0.0
1081
1084
  }
1082
1085
 
1083
1086
  make Policy do
@@ -1089,13 +1092,26 @@ make Policy do
1089
1092
  #
1090
1093
  # @param maximumElapsed [Duration] maximum cumulative scheduled delay
1091
1094
  # @return [Policy] a copied policy with the elapsed bound
1092
- # @example +Retry.fixed(5, Duration.seconds(1)).withMaximumElapsed(Duration.seconds(2))+.
1095
+ # @example +Retry.fixed(5, 1.seconds).withMaximumElapsed(2.seconds)+.
1093
1096
  let withMaximumElapsed(maximumElapsed: Duration) -> Policy = Policy {
1094
1097
  maximumAttempts: @maximumAttempts,
1095
1098
  initialDelay: @initialDelay,
1096
1099
  multiplier: @multiplier,
1097
1100
  maximumDelay: @maximumDelay,
1098
- maximumElapsed: Just(maximumElapsed)
1101
+ maximumElapsed: Just(maximumElapsed),
1102
+ jitterFraction: @jitterFraction
1103
+ }
1104
+
1105
+ # Returns the same schedule with symmetric bounded jitter. A fraction of
1106
+ # +0.25+ selects each actual delay from 75% through 125% of its scheduled
1107
+ # value. Fractions are clamped to +0.0..1.0+.
1108
+ let withJitter(fraction: Float) -> Policy = Policy {
1109
+ maximumAttempts: @maximumAttempts,
1110
+ initialDelay: @initialDelay,
1111
+ multiplier: @multiplier,
1112
+ maximumDelay: @maximumDelay,
1113
+ maximumElapsed: @maximumElapsed,
1114
+ jitterFraction: if fraction < 0.0 then 0.0 else if fraction > 1.0 then 1.0 else fraction end end
1099
1115
  }
1100
1116
  end
1101
1117
 
@@ -1108,7 +1124,7 @@ end
1108
1124
  # @param operation [Block<Result<X,E>>] a fresh attempt
1109
1125
  # @return [Result<X,E>] the first success or final failure
1110
1126
  run : Policy -> Block<Result<X, E>> -> Result<X, E>
1111
- foul run(policy, operation) = runWith(policy, { |error| true }, { |delay| Task.sleep(delay) }, operation)
1127
+ foul run(policy, operation) = runWithRandom(policy, { |error| true }, { |delay| Task.sleep(delay) }, { Kex.Intrinsic.Retry.randomUnit() }, operation)
1112
1128
 
1113
1129
  # Runs with an application-specific error predicate.
1114
1130
  #
@@ -1121,7 +1137,7 @@ foul run(policy, operation) = runWith(policy, { |error| true }, { |delay| Task.s
1121
1137
  # @return [Result<X,E>] the first success or final/non-retryable failure
1122
1138
  # @example +Retry.run(policy, { |error| error.retryable? }, do request() end)+
1123
1139
  run : Policy -> Predicate<E> -> Block<Result<X, E>> -> Result<X, E>
1124
- foul run(policy, predicate, operation) = runWith(policy, predicate, { |delay| Task.sleep(delay) }, operation)
1140
+ foul run(policy, predicate, operation) = runWithRandom(policy, predicate, { |delay| Task.sleep(delay) }, { Kex.Intrinsic.Retry.randomUnit() }, operation)
1125
1141
 
1126
1142
  # Runs with injected error classification and sleeping.
1127
1143
  #
@@ -1134,9 +1150,16 @@ foul run(policy, predicate, operation) = runWith(policy, predicate, { |delay| Ta
1134
1150
  # @param operation [Block<Result<X,E>>] a fresh attempt
1135
1151
  # @return [Result<X,E>] the first success or final failure
1136
1152
  runWith : Policy -> Predicate<E> -> Sleeper -> Block<Result<X, E>> -> Result<X, E>
1137
- foul runWith(policy, predicate, sleeper, operation) = attempt(policy, predicate, sleeper, operation, 1, policy.initialDelay, Duration.seconds(0))
1153
+ foul runWith(policy, predicate, sleeper, operation) = runWithRandom(policy, predicate, sleeper, { 0.5 }, operation)
1138
1154
 
1139
- foul attempt(policy: Policy, predicate: Predicate<E>, sleeper: Sleeper, operation: Block<Result<X, E>>, number: Integer, delay: Duration, elapsed: Duration) -> Result<X, E> do
1155
+ # Runs with injected sleeping and a random source returning a value in
1156
+ # +0.0..1.0+. Out-of-range test values are clamped. Production +run+ uses a
1157
+ # cryptographically secure backend source; this overload makes jitter specs
1158
+ # deterministic.
1159
+ runWithRandom : Policy -> Predicate<E> -> Sleeper -> Block<Float> -> Block<Result<X, E>> -> Result<X, E>
1160
+ foul runWithRandom(policy, predicate, sleeper, random, operation) = attempt(policy, predicate, sleeper, random, operation, 1, policy.initialDelay, Duration.seconds(0))
1161
+
1162
+ 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
1140
1163
  let result = operation()
1141
1164
  return match result do
1142
1165
  Ok(value) => Ok(value)
@@ -1144,7 +1167,10 @@ foul attempt(policy: Policy, predicate: Predicate<E>, sleeper: Sleeper, operatio
1144
1167
  if number >= policy.maximumAttempts || !predicate(error)
1145
1168
  return Error(error)
1146
1169
  end
1147
- let nextElapsed = Duration { seconds: elapsed.seconds + delay.seconds }
1170
+ let sample = random()
1171
+ let boundedSample = if sample < 0.0 then 0.0 else if sample > 1.0 then 1.0 else sample end end
1172
+ let jittered = Duration { seconds: delay.seconds * (1.0 + ((boundedSample * 2.0 - 1.0) * policy.jitterFraction)) }
1173
+ let nextElapsed = Duration { seconds: elapsed.seconds + jittered.seconds }
1148
1174
  let withinElapsed = match policy.maximumElapsed do
1149
1175
  None => true
1150
1176
  Just(maximum) => nextElapsed.seconds <= maximum.seconds
@@ -1152,10 +1178,10 @@ foul attempt(policy: Policy, predicate: Predicate<E>, sleeper: Sleeper, operatio
1152
1178
  if !withinElapsed
1153
1179
  return Error(error)
1154
1180
  end
1155
- sleeper(delay)
1181
+ sleeper(jittered)
1156
1182
  let nextSeconds = delay.seconds * policy.multiplier
1157
1183
  let bounded = if nextSeconds > policy.maximumDelay.seconds then policy.maximumDelay else Duration { seconds: nextSeconds } end
1158
- return attempt(policy, predicate, sleeper, operation, number + 1, bounded, nextElapsed)
1184
+ return attempt(policy, predicate, sleeper, random, operation, number + 1, bounded, nextElapsed)
1159
1185
  end
1160
1186
  end
1161
1187
  end
@@ -1181,6 +1207,10 @@ module Retry do
1181
1207
  # Deterministic seam with an injected sleeper, primarily for specifications.
1182
1208
  runWith : Policy -> Predicate<E> -> Sleeper -> Block<Result<X, E>> -> Result<X, E>
1183
1209
  foul runWith(policy, predicate, sleeper, operation) = Control.Retry.runWith(policy, predicate, sleeper, operation)
1210
+
1211
+ # Deterministic seam with injected sleeping and random sampling.
1212
+ runWithRandom : Policy -> Predicate<E> -> Sleeper -> Block<Float> -> Block<Result<X, E>> -> Result<X, E>
1213
+ foul runWithRandom(policy, predicate, sleeper, random, operation) = Control.Retry.runWithRandom(policy, predicate, sleeper, random, operation)
1184
1214
  end
1185
1215
  # A first-in-first-out queue.
1186
1216
  #
@@ -7759,6 +7789,22 @@ record CacheOptions do
7759
7789
  negativeTtl : Duration = Duration.seconds(30)
7760
7790
  end
7761
7791
 
7792
+ # One DNS server used by a custom resolver.
7793
+ record Nameserver do
7794
+ address : Net.IP.Address
7795
+ port : Net.Port
7796
+ end
7797
+
7798
+ # Isolated resolver configuration. An empty search list only queries the name
7799
+ # as written; search domains are tried in order for single-label names.
7800
+ record ResolverOptions do
7801
+ cache : CacheOptions = CacheOptions {}
7802
+ nameservers : [Nameserver]
7803
+ search : [Name] = []
7804
+ retries : Integer = 2
7805
+ timeout : Duration = 2.seconds
7806
+ end
7807
+
7762
7808
  # Lifetime cache counters. +clear+ empties entries but keeps these counters.
7763
7809
  record CacheStatistics do
7764
7810
  entries : Integer
@@ -7789,6 +7835,12 @@ module Resolver do
7789
7835
  # @param cache [CacheOptions] entry and TTL limits
7790
7836
  # @return [Result<Resolver, NetError>] a resolver, or +Parse+
7791
7837
  foul system(cache: CacheOptions) -> Result<Resolver, NetError> = Kex.Intrinsic.NetDNS.resolver(cache)
7838
+ # Opens an isolated resolver with typed nameservers and query bounds.
7839
+ # @param options [ResolverOptions] nameservers, search domains, retry count,
7840
+ # per-query timeout, and cache bounds
7841
+ # @return [Result<Resolver, NetError>] a resolver, or +Parse+
7842
+ # @example +Resolver.custom(ResolverOptions { nameservers: [Nameserver { address: Net.IP.Address.parse("127.0.0.1").try, port: Net.Port.from(53).try }], timeout: 100.milliseconds }).try+
7843
+ foul custom(options: ResolverOptions) -> Result<Resolver, NetError> = Kex.Intrinsic.NetDNS.resolverWith(options)
7792
7844
  end
7793
7845
 
7794
7846
  make Resolver do
@@ -8029,7 +8081,8 @@ module Client do
8029
8081
  open : Result<Client, NetError>
8030
8082
  foul open() = Kex.Intrinsic.NetHTTPClient.open(ClientOptions {})
8031
8083
  # Opens an explicit client owning the supplied pool policy.
8032
- foul open(options: ClientOptions) -> Result<Client, NetError> = Kex.Intrinsic.NetHTTPClient.open(options)
8084
+ open : ClientOptions -> Result<Client, NetError>
8085
+ foul open(options) = Kex.Intrinsic.NetHTTPClient.open(options)
8033
8086
  end
8034
8087
 
8035
8088
  make Client do
@@ -8288,6 +8341,21 @@ module TCP do
8288
8341
  host : String
8289
8342
  port : Net.Port
8290
8343
  end
8344
+ record ConnectOptions do
8345
+ connectTimeout : Duration = 10.seconds
8346
+ noDelay? : Bool = true
8347
+ keepAlive? : Bool = true
8348
+ sendBuffer : Integer = 0
8349
+ receiveBuffer : Integer = 0
8350
+ end
8351
+ record ListenOptions do
8352
+ backlog : Integer = 128
8353
+ reuseAddress? : Bool = true
8354
+ noDelay? : Bool = true
8355
+ keepAlive? : Bool = true
8356
+ sendBuffer : Integer = 0
8357
+ receiveBuffer : Integer = 0
8358
+ end
8291
8359
 
8292
8360
  module Endpoint do
8293
8361
  # @return [Endpoint] an endpoint using +name+ exactly as supplied
@@ -8309,18 +8377,24 @@ module TCP do
8309
8377
  # @example +TCP.connect(TCP.Endpoint.host("example.test", Port.from(80).try))+.
8310
8378
  connect : Endpoint -> Result<TCPConnection, NetError>
8311
8379
  foul connect(endpoint) = Kex.Intrinsic.NetTCP.connect(endpoint)
8380
+ connect : Endpoint -> ConnectOptions -> Result<TCPConnection, NetError>
8381
+ foul connect(endpoint, options) = Kex.Intrinsic.NetTCP.connectWith(endpoint, options)
8312
8382
  # Binds and starts listening. Port zero selects an ephemeral local port.
8313
8383
  # @return [Result<TCPListener, NetError>] a listener or +Connect+
8314
8384
  # @example +TCP.listen(TCP.Endpoint.loopback(Port.from(0).try))+.
8315
8385
  listen : Endpoint -> Result<TCPListener, NetError>
8316
8386
  foul listen(endpoint) = Kex.Intrinsic.NetTCP.listen(endpoint)
8387
+ listen : Endpoint -> ListenOptions -> Result<TCPListener, NetError>
8388
+ foul listen(endpoint, options) = Kex.Intrinsic.NetTCP.listenWith(endpoint, options)
8317
8389
 
8318
8390
  # Sends every byte or reports the failure and transfer progress.
8319
8391
  foul sendAll(connection: TCPConnection, data: Binary) -> Result<Integer, NetError> = Kex.Intrinsic.NetTCP.sendAll(connection, data)
8320
8392
  # Receives one bounded chunk; EOF is reported as +Closed+.
8321
8393
  foul receiveChunk(connection: TCPConnection, limit: Integer) -> Result<Binary, NetError> = Kex.Intrinsic.NetTCP.receiveChunk(connection, limit)
8394
+ foul receiveChunk(connection: TCPConnection, limit: Integer, timeout: Duration) -> Result<Binary, NetError> = Kex.Intrinsic.NetTCP.receiveChunkWithin(connection, limit, timeout)
8322
8395
  # Receives exactly +count+ bytes or returns a typed EOF/timeout failure.
8323
8396
  foul receiveExactly(connection: TCPConnection, count: Integer) -> Result<Binary, NetError> = Kex.Intrinsic.NetTCP.receiveExactly(connection, count)
8397
+ foul receiveExactly(connection: TCPConnection, count: Integer, timeout: Duration) -> Result<Binary, NetError> = Kex.Intrinsic.NetTCP.receiveExactlyWithin(connection, count, timeout)
8324
8398
  # Receives through the first +delimiter+ without exceeding +limit+ bytes.
8325
8399
  #
8326
8400
  # The delimiter is included in the returned bytes. An empty delimiter is a
@@ -8328,12 +8402,15 @@ module TCP do
8328
8402
  #
8329
8403
  # @example +connection.receiveUntil("\r\n\r\n".to(Binary).try, 65536).try+
8330
8404
  foul receiveUntil(connection: TCPConnection, delimiter: Binary, limit: Integer) -> Result<Binary, NetError> = Kex.Intrinsic.NetTCP.receiveUntil(connection, delimiter, limit)
8405
+ foul receiveUntil(connection: TCPConnection, delimiter: Binary, limit: Integer, timeout: Duration) -> Result<Binary, NetError> = Kex.Intrinsic.NetTCP.receiveUntilWithin(connection, delimiter, limit, timeout)
8331
8406
  # Receives through a newline without exceeding +limit+ bytes.
8332
8407
  foul receiveLine(connection: TCPConnection, limit: Integer) -> Result<Binary, NetError> = Kex.Intrinsic.NetTCP.receiveLine(connection, limit)
8408
+ foul receiveLine(connection: TCPConnection, limit: Integer, timeout: Duration) -> Result<Binary, NetError> = Kex.Intrinsic.NetTCP.receiveLineWithin(connection, limit, timeout)
8333
8409
  # Half-closes the write side while leaving reads available.
8334
8410
  foul shutdownWrite(connection: TCPConnection) -> Result<Void, NetError> = Kex.Intrinsic.NetTCP.shutdownWrite(connection)
8335
8411
  # Waits for and returns the next connection.
8336
8412
  foul accept(listener: TCPListener) -> Result<TCPConnection, NetError> = Kex.Intrinsic.NetTCP.accept(listener)
8413
+ foul accept(listener: TCPListener, timeout: Duration) -> Result<TCPConnection, NetError> = Kex.Intrinsic.NetTCP.acceptWithin(listener, timeout)
8337
8414
  # Idempotently closes a connected stream.
8338
8415
  foul close(connection: TCPConnection) -> Void = Kex.Intrinsic.NetTCP.close(connection)
8339
8416
  # Idempotently closes a listener and releases its port.
@@ -8373,6 +8450,14 @@ module UDP do
8373
8450
  source : Endpoint
8374
8451
  data : Binary
8375
8452
  end
8453
+ # Curated socket policy. Broadcast is opt-in. The receive timeout applies to
8454
+ # each +receiveFrom+ call; multicast TTL is bounded to the IP hop-limit range.
8455
+ record BindOptions do
8456
+ broadcast? : Bool = false
8457
+ multicastTtl : Integer = 1
8458
+ multicastLoopback? : Bool = true
8459
+ receiveTimeout : Duration = 30.seconds
8460
+ end
8376
8461
  module Endpoint do
8377
8462
  # @return [Endpoint] an endpoint using +name+ exactly as supplied
8378
8463
  host : String -> Net.Port -> Endpoint
@@ -8387,6 +8472,9 @@ module UDP do
8387
8472
  # Binds a datagram socket. Port zero selects an ephemeral local port.
8388
8473
  bind : Endpoint -> Result<Socket, NetError>
8389
8474
  foul bind(endpoint) = Kex.Intrinsic.NetUDP.bind(endpoint)
8475
+ # Binds with explicit broadcast, multicast, and receive-timeout policy.
8476
+ bind : Endpoint -> BindOptions -> Result<Socket, NetError>
8477
+ foul bind(endpoint, options) = Kex.Intrinsic.NetUDP.bindWith(endpoint, options)
8390
8478
 
8391
8479
  # Sends one complete datagram and returns its byte count.
8392
8480
  foul sendTo(socket: Socket, endpoint: Endpoint, data: Binary) -> Result<Integer, NetError> = Kex.Intrinsic.NetUDP.sendTo(socket, endpoint, data)
@@ -8398,6 +8486,11 @@ module UDP do
8398
8486
  foul closed?(socket: Socket) -> Bool = Kex.Intrinsic.NetUDP.closed?(socket)
8399
8487
  # Returns the bound endpoint, including an ephemeral assigned port.
8400
8488
  foul localAddress(socket: Socket) -> Result<Endpoint, NetError> = Kex.Intrinsic.NetUDP.localAddress(socket)
8489
+ # Joins an IPv4 multicast group on the selected local interface. The group
8490
+ # must be multicast and the interface must be an IPv4 address.
8491
+ foul joinMulticast(socket: Socket, group: Net.IP.Address, interface: Net.IP.Address) -> Result<Void, NetError> = Kex.Intrinsic.NetUDP.joinMulticast(socket, group, interface)
8492
+ # Leaves a membership previously joined with the same group and interface.
8493
+ foul leaveMulticast(socket: Socket, group: Net.IP.Address, interface: Net.IP.Address) -> Result<Void, NetError> = Kex.Intrinsic.NetUDP.leaveMulticast(socket, group, interface)
8401
8494
 
8402
8495
  end
8403
8496
 
@@ -8418,6 +8511,16 @@ module Unix do
8418
8511
  record Address do
8419
8512
  path : String
8420
8513
  end
8514
+ record ConnectOptions do
8515
+ connectTimeout : Duration = 10.seconds
8516
+ receiveTimeout : Duration = 30.seconds
8517
+ end
8518
+ record ListenOptions do
8519
+ backlog : Integer = 128
8520
+ removeStale? : Bool = false
8521
+ acceptTimeout : Duration = 30.seconds
8522
+ receiveTimeout : Duration = 30.seconds
8523
+ end
8421
8524
  module Address do
8422
8525
  # Validates a nonempty absolute Unix-domain socket path.
8423
8526
  # @return [Result<Address, NetError>] the address, or +Parse+
@@ -8427,9 +8530,15 @@ module Unix do
8427
8530
  # Connects to a filesystem-domain listener.
8428
8531
  connect : Address -> Result<UnixConnection, NetError>
8429
8532
  foul connect(address) = Kex.Intrinsic.NetUnix.connect(address)
8533
+ connect : Address -> ConnectOptions -> Result<UnixConnection, NetError>
8534
+ foul connect(address, options) = Kex.Intrinsic.NetUnix.connectWith(address, options)
8430
8535
  # Binds a new path; an existing filesystem entry is never removed implicitly.
8431
8536
  listen : Address -> Result<UnixListener, NetError>
8432
8537
  foul listen(address) = Kex.Intrinsic.NetUnix.listen(address)
8538
+ # With +removeStale?+, only an existing filesystem socket is removed;
8539
+ # regular files, directories, and other path types are always preserved.
8540
+ listen : Address -> ListenOptions -> Result<UnixListener, NetError>
8541
+ foul listen(address, options) = Kex.Intrinsic.NetUnix.listenWith(address, options)
8433
8542
 
8434
8543
  # Sends every byte and returns the count.
8435
8544
  foul sendAll(connection: UnixConnection, data: Binary) -> Result<Integer, NetError> = Kex.Intrinsic.NetUnix.sendAll(connection, data)
@@ -11945,6 +12054,31 @@ make String, implement: Enumerable, Foldable do
11945
12054
  length :> Integer
11946
12055
  let length = Kex.Intrinsic.List.length(this)
11947
12056
 
12057
+ # Returns the number of user-perceived characters — extended grapheme
12058
+ # clusters (Unicode UAX #29) — which is not always +count+.
12059
+ #
12060
+ # +count+ answers the codepoint count, and most text is one codepoint per
12061
+ # grapheme cluster, so the two usually agree. They part ways for a base
12062
+ # character combined with a following mark, an emoji built from more than
12063
+ # one codepoint, and `"\r\n"`, which is one grapheme cluster over two
12064
+ # codepoints — reach for +graphemeCount+ over +count+ wherever "how many
12065
+ # characters does a person see" is the question, such as sizing text for
12066
+ # display or truncating it at a boundary a reader would recognize.
12067
+ #
12068
+ # @return [Integer] the grapheme cluster count
12069
+ #
12070
+ # @example
12071
+ # "hello".graphemeCount # => 5, same as count
12072
+ # "\r\n".graphemeCount # => 1 ("\r\n".count is 2 -- a CR codepoint and an LF codepoint)
12073
+ #
12074
+ # @example A base character plus a combining mark
12075
+ # let acute = String.fromCodepoint(0x301).or("") # combining acute accent
12076
+ # let e = "e" + acute
12077
+ # e.count # => 2 (two codepoints: 'e' and the combining mark)
12078
+ # e.graphemeCount # => 1 (one character on screen)
12079
+ graphemeCount :> Integer
12080
+ let graphemeCount = Kex.Intrinsic.String.graphemeCount(this)
12081
+
11948
12082
  # Returns +true+ when the string has no characters.
11949
12083
  #
11950
12084
  # Note that a string of spaces is not empty — use +blank?+ from the
@@ -12876,6 +13010,431 @@ warnBetween : Integer -> Integer -> String -> Issue
12876
13010
  let warnBetween(start, finish, message) do
12877
13011
  Warn(Between(start, finish), message)
12878
13012
  end
13013
+ # An ERB-shaped template scanner: template text in, a template AST out.
13014
+ #
13015
+ # Opt-in — nothing here is in scope until `using Template`.
13016
+ #
13017
+ # This is the scanning stage only (see the Template proposal for the fuller
13018
+ # design): it turns template source into a flat list of +Node+s — plain text,
13019
+ # and the four kinds of `<% %>` region — plus whatever frontmatter tags sit
13020
+ # ahead of the body. It does not evaluate anything and does not know Kex
13021
+ # syntax; the text inside a hole is kept as-is, for a later stage to parse
13022
+ # and lower into real Kex.
13023
+ #
13024
+ # using Template
13025
+ #
13026
+ # let source = "---\nlayout: page\nparams: [name, library: Bool]\n---\nHi <%= name %>!"
13027
+ # let parsed = Template.scan(source).try
13028
+ # parsed.frontmatter.get("layout") # => Just(Scalar("page"))
13029
+ # parsed.parameters # => [TemplateParam { name: "name", type: "" },
13030
+ # # TemplateParam { name: "library", type: "Bool" }]
13031
+ # parsed.nodes # => [Text("Hi "), Interpolate("name"), Text("!")]
13032
+ #
13033
+ # ## Syntax
13034
+ #
13035
+ # <%= expr %> interpolate (escaping is a later stage's job)
13036
+ # <%== expr %> interpolate raw, no escaping
13037
+ # <% ... %> a Kex control region: `if`/`match` arms, block bodies, `let`
13038
+ # <%# ... %> comment, emits nothing
13039
+ # <%- ... -%> whitespace control: trims the line's leading indent before
13040
+ # the tag, and the newline right after it
13041
+ # <%% a literal `<%`, for a template that generates ERB-shaped
13042
+ # output itself
13043
+ #
13044
+ # ## Frontmatter
13045
+ #
13046
+ # A template file may open with a `---` line, generic `key: value` tags up to
13047
+ # a closing `---` line, and then the body. This is metadata for whatever
13048
+ # reads the template — a title, a layout name, a list of tags — and there is
13049
+ # nothing template-specific about it: it is scanned the same generic way
13050
+ # whatever key is used.
13051
+ #
13052
+ # ---
13053
+ # title: Release notes
13054
+ # tags: [changelog, public]
13055
+ # ---
13056
+ # # <%= title %>
13057
+ #
13058
+ # A template's parameters, when a later stage needs them declared rather than
13059
+ # inferred, are just another frontmatter key — conventionally `params`, a
13060
+ # list of names each with an optional `: Type`:
13061
+ #
13062
+ # ---
13063
+ # params: [name, library: Bool, dependencies: [Dependency]]
13064
+ # ---
13065
+ # # <%= name %>
13066
+ #
13067
+ # Nothing in the scanner treats `params` specially while scanning — it is
13068
+ # metadata like any other key, a `[a, b, c]` list same as `tags` above.
13069
+ # `Parsed#parameters` is a convenience reader for it: it splits each entry on
13070
+ # its first colon into a `TemplateParam { name, type }` (`type` is `""` for a bare
13071
+ # name), so a caller does not need to know the key name, match on `Tag`
13072
+ # itself, or split each entry by hand. `type` is raw text, same as
13073
+ # everywhere else in this module — turning `[Dependency]` into a real,
13074
+ # resolved Kex type is later work, not this module's.
13075
+ module Template
13076
+ using Parsing
13077
+
13078
+ # One piece of a scanned template.
13079
+ #
13080
+ # Hole and control content is carried as raw text — the Kex source that was
13081
+ # between the delimiters, trimmed of surrounding whitespace. Turning that
13082
+ # text into real, type-checked Kex expressions is later work; a `Node` only
13083
+ # records what KIND of region it is and what text it held.
13084
+ type Node = Text(String)
13085
+ | Interpolate(String)
13086
+ | InterpolateRaw(String)
13087
+ | Control(String)
13088
+ | Comment(String)
13089
+
13090
+ # A frontmatter value: a plain scalar, or a `[a, b, c]` list.
13091
+ type Tag = Scalar(String)
13092
+ | Tags([String])
13093
+
13094
+ # Why a template's text could not be scanned, and where.
13095
+ type TemplateError = UnterminatedTag(Integer)
13096
+ | UnterminatedFrontmatter
13097
+ | MalformedFrontmatterLine(String)
13098
+
13099
+ # One entry from a `params: [...]` frontmatter list: a name, and its
13100
+ # optional `: Type` annotation. `type` is raw text — `""` for a bare name,
13101
+ # never a resolved Kex type — the same way a frontmatter `Tag` is text and
13102
+ # not a parsed value.
13103
+ record TemplateParam do
13104
+ name : String
13105
+ type : String
13106
+ end
13107
+
13108
+ # One scanned `<% ... %>` region: its node, and the whitespace trims its
13109
+ # delimiters asked for. Not part of the public API — internal to scanning —
13110
+ # but declared at module scope rather than inside `private do`: nested
13111
+ # there, this record's fields lose their names under BEAM codegen and the
13112
+ # value round-trips as an untagged tuple (`Undefined method: leftTrim for
13113
+ # Tuple`), even though the same code is fine on the tree-walking interpreter.
13114
+ record TagScan do
13115
+ node : Node
13116
+ leftTrim : Bool
13117
+ rightTrim : Bool
13118
+ end
13119
+
13120
+ # A scanned template: its frontmatter tags, and its body as a node list.
13121
+ record Parsed do
13122
+ frontmatter : {String: Tag}
13123
+ nodes : [Node]
13124
+ end
13125
+
13126
+ make Parsed do
13127
+ # The template's declared parameters, out of a `params: [...]` frontmatter
13128
+ # key — `[]` when the template declares none, or when `params` holds a
13129
+ # bare scalar rather than a list.
13130
+ #
13131
+ # @return [[TemplateParam]] the declared parameters, in the order written
13132
+ #
13133
+ # @example
13134
+ # parsed.parameters
13135
+ # # => [TemplateParam { name: "name", type: "" }, TemplateParam { name: "library", type: "Bool" }]
13136
+ let parameters -> [TemplateParam] do
13137
+ let none: [TemplateParam] = []
13138
+ match @frontmatter.get("params") do
13139
+ Just(Tags(names)) => parseTemplateParams(names)
13140
+ _ => none
13141
+ end
13142
+ end
13143
+ end
13144
+
13145
+ # Scans template source into a `Parsed` template, or says where it broke.
13146
+ #
13147
+ # @param source [String] the template file's full text
13148
+ # @return [Result<Parsed, TemplateError>] the scanned template, or why not
13149
+ #
13150
+ # @example
13151
+ # Template.scan("Hi <%= name %>!").map(~nodes)
13152
+ # # => Ok([Text("Hi "), Interpolate("name"), Text("!")])
13153
+ let scan(source: String) -> Result<Parsed, TemplateError> do
13154
+ let cursor = Input { input: source }
13155
+ let (frontmatter, afterFrontmatter) = scanFrontmatter(cursor).try
13156
+ let (nodes, _) = scanBody(afterFrontmatter).try
13157
+ Ok(Parsed { frontmatter: frontmatter, nodes: nodes })
13158
+ end
13159
+
13160
+ private do
13161
+ # True when `cursor` sits at the very start of the input and that line is
13162
+ # exactly `---` — the only place a frontmatter block may open.
13163
+ # Hand-rolled peek chain rather than `Input#string("---")`: `string`'s name
13164
+ # collides with `Char#string` (a zero-arg property) the same way
13165
+ # `takeWhile`/`atEnd?`/`readLine` collide elsewhere in this file — the
13166
+ # prelude-interface build picks the wrong candidate and reports an arity
13167
+ # mismatch even though the receiver type here is unambiguous.
13168
+ let atFrontmatterOpen?(cursor: Input) -> Bool do
13169
+ (cursor.pos == 0 &&
13170
+ cursor.peek == Just('-') && cursor.peekAt(1) == Just('-') && cursor.peekAt(2) == Just('-') &&
13171
+ (cursor.peekAt(3) == Just('\n') || cursor.peekAt(3) == Just('\r') || cursor.peekAt(3) == None))
13172
+ end
13173
+
13174
+ # A cursor advanced past the current character if it is `\r\n` or `\n`;
13175
+ # otherwise unchanged. What a `-%>` closer's trailing-newline trim needs.
13176
+ let skipOneNewline(cursor: Input) -> Input do
13177
+ if cursor.peek == Just('\r') && cursor.peekAt(1) == Just('\n')
13178
+ cursor.advanceBy(2)
13179
+ elif cursor.peek == Just('\n')
13180
+ cursor.advance
13181
+ else
13182
+ cursor
13183
+ end
13184
+ end
13185
+
13186
+ # A cursor advanced to the next `\n` (not past it), and the text skipped.
13187
+ #
13188
+ # Hand-rolled rather than `Input#takeWhile` — see `scanText`'s comment on why.
13189
+ let scanLine(cursor: Input) -> (String, Input) do
13190
+ var cur = cursor
13191
+ var chars: [Char] = []
13192
+ loop do
13193
+ break if cur.peek == None
13194
+ break if cur.peek == Just('\n')
13195
+ let Just(ch) = cur.peek
13196
+ chars.push!(ch)
13197
+ cur.advance!
13198
+ end
13199
+ (chars.join(""), cur)
13200
+ end
13201
+
13202
+ # A line's text with any trailing `\r` dropped, for a source that mixes
13203
+ # `\r\n` and `\n` line endings.
13204
+ let stripTrailingCR(text: String) -> String do
13205
+ text.endsWith?("\r") then text.take(text.count - 1) else text
13206
+ end
13207
+
13208
+ # Strips one layer of matching `"..."` or `'...'` quotes, if present.
13209
+ let unquote(text: String) -> String do
13210
+ let quoted = (text.count >= 2 &&
13211
+ ((text.startsWith?("\"") && text.endsWith?("\"")) || (text.startsWith?("'") && text.endsWith?("'"))))
13212
+ quoted then text.drop(1).take(text.count - 2) else text
13213
+ end
13214
+
13215
+ # A frontmatter value's text, parsed as a `[a, b, c]` list or a scalar.
13216
+ # Splits `text` on commas that are not nested inside `[]`, `()`, `<>`, or
13217
+ # `{}` — so a `params` entry's type, `Map<String, Integer>` or
13218
+ # `[Dependency, Other]`, stays one list item rather than splitting on its
13219
+ # own internal comma.
13220
+ let splitTopLevelCommas(text: String) -> [String] do
13221
+ let empty: ([String], String, Integer) = ([], "", 0)
13222
+ let (parts, last, _) = text.reduce(empty) do |acc, ch|
13223
+ let (parts, current, depth) = acc
13224
+ if ch == '[' || ch == '(' || ch == '<' || ch == '{'
13225
+ (parts, "${current}${ch}", depth + 1)
13226
+ elif ch == ']' || ch == ')' || ch == '>' || ch == '}'
13227
+ (parts, "${current}${ch}", depth - 1)
13228
+ elif ch == ',' && depth == 0
13229
+ ([...parts, current], "", depth)
13230
+ else
13231
+ (parts, "${current}${ch}", depth)
13232
+ end
13233
+ end
13234
+ last.empty? then parts else [...parts, last]
13235
+ end
13236
+
13237
+ let parseFrontmatterValue(raw: String) -> Tag do
13238
+ if raw.startsWith?("[") && raw.endsWith?("]")
13239
+ let inner = raw.drop(1).take(raw.count - 2).trim
13240
+ let items = inner.empty? then [] else splitTopLevelCommas(inner).map { |item| unquote(item.trim) }
13241
+ Tags(items)
13242
+ else
13243
+ Scalar(unquote(raw))
13244
+ end
13245
+ end
13246
+
13247
+ # One `key: value` frontmatter line, split at its first colon.
13248
+ let parseFrontmatterLine(line: String) -> Result<(String, Tag), TemplateError> do
13249
+ let colon = line.findIndex { |ch| ch == ':' }
13250
+ return Error(MalformedFrontmatterLine(line)) if colon == None
13251
+ # `.or(0)` rather than `let Just(index) = colon`: the guard above already
13252
+ # rules out `None`, but destructuring it and feeding the bound name
13253
+ # straight into another generic call (`take`/`drop`) trips a real
13254
+ # type-checker bug where the prelude-interface build cross-contaminates
13255
+ # unrelated generic inference elsewhere in the stdlib. Unwrapping with a
13256
+ # (dead) default avoids it.
13257
+ let index = colon.or(0)
13258
+ let key = line.take(index).trim
13259
+ return Error(MalformedFrontmatterLine(line)) if key.empty?
13260
+ let value = parseFrontmatterValue(line.drop(index + 1).trim)
13261
+ Ok((key, value))
13262
+ end
13263
+
13264
+ # One `params` list entry, split into a name and its optional `: Type`.
13265
+ # `type` is `""` when `raw` is a bare name.
13266
+ let parseTemplateParam(raw: String) -> TemplateParam do
13267
+ let colon = raw.findIndex { |ch| ch == ':' }
13268
+ if colon == None
13269
+ TemplateParam { name: raw.trim, type: "" }
13270
+ else
13271
+ # `.or(0)` rather than destructuring — see `parseFrontmatterLine`'s
13272
+ # comment on why.
13273
+ let index = colon.or(0)
13274
+ TemplateParam { name: raw.take(index).trim, type: raw.drop(index + 1).trim }
13275
+ end
13276
+ end
13277
+
13278
+ # `names.map(~parseTemplateParam)`, spelled as an indexed loop rather than a
13279
+ # generic `map`/`reduce` call: `[String]`'s element type is itself a type
13280
+ # that implements `Enumerable`, and going through the shared HOF name here
13281
+ # sends the prelude-interface build's overload resolution to `String`'s own
13282
+ # `reduce` (iterating characters) instead of `List`'s — the bound block
13283
+ # parameter then type-checks as `Char`, not `String`. Same collision class
13284
+ # as `Input#takeWhile`/`atEnd?`/`string` elsewhere in this file.
13285
+ let parseTemplateParams(names: [String]) -> [TemplateParam] do
13286
+ var params: [TemplateParam] = []
13287
+ var i = 0
13288
+ loop do
13289
+ break if i >= names.count
13290
+ params.push!(parseTemplateParam(names.at(i).or("")))
13291
+ i = i + 1
13292
+ end
13293
+ params
13294
+ end
13295
+
13296
+ # Reads a leading `---` ... `---` frontmatter block, if the input opens with
13297
+ # one. Answers an empty map and the cursor unmoved when it does not.
13298
+ let scanFrontmatter(cursor: Input) -> Result<({String: Tag}, Input), TemplateError> do
13299
+ return Ok(({}, cursor)) if !atFrontmatterOpen?(cursor)
13300
+
13301
+ let (_, afterOpenLine) = scanLine(cursor)
13302
+ var cur = skipOneNewline(afterOpenLine)
13303
+ var tags: {String: Tag} = {}
13304
+ loop do
13305
+ return Error(UnterminatedFrontmatter) if cur.peek == None
13306
+ let (rawLine, afterLine) = scanLine(cur)
13307
+ let line = stripTrailingCR(rawLine).trim
13308
+ cur = skipOneNewline(afterLine)
13309
+ return Ok((tags, cur)) if line == "---"
13310
+ if !line.empty?
13311
+ let (key, value) = parseFrontmatterLine(line).try
13312
+ tags = tags.put(key, value)
13313
+ end
13314
+ end
13315
+ end
13316
+
13317
+ # True when `cursor` sits at a `<%` tag opener (and it is not the `<%%`
13318
+ # escape for a literal `<%`).
13319
+ let atTagOpen?(cursor: Input) -> Bool do
13320
+ cursor.peek == Just('<') && cursor.peekAt(1) == Just('%') && cursor.peekAt(2) != Just('%')
13321
+ end
13322
+
13323
+ # Reads plain text up to the next tag opener or end of input. `<%%` is
13324
+ # consumed as a literal `<%` and folded into the text rather than ending it.
13325
+ #
13326
+ # Hand-rolled rather than built on `Input#takeWhile`: that method's name
13327
+ # collides with `List#takeWhile`, and calling it through an `Input` receiver
13328
+ # sends overload resolution down the wrong path (`json.kex`'s own scanners
13329
+ # hit the same thing and avoid it the same way).
13330
+ let scanText(cursor: Input) -> (String, Input) do
13331
+ var cur = cursor
13332
+ var chars: [Char] = []
13333
+ loop do
13334
+ break if cur.peek == None
13335
+ break if atTagOpen?(cur)
13336
+ if cur.peek == Just('<') && cur.peekAt(1) == Just('%') && cur.peekAt(2) == Just('%')
13337
+ chars.push!('<')
13338
+ chars.push!('%')
13339
+ cur.advanceBy!(3)
13340
+ else
13341
+ let Just(ch) = cur.peek
13342
+ chars.push!(ch)
13343
+ cur.advance!
13344
+ end
13345
+ end
13346
+ (chars.join(""), cur)
13347
+ end
13348
+
13349
+ # Strips trailing spaces and tabs (not newlines) — the indentation a `<%-`
13350
+ # opener asks to have removed from the text before it.
13351
+ let trimTrailingIndent(text: String) -> String do
13352
+ var chars = text.chars
13353
+ loop do
13354
+ break if chars.empty?
13355
+ let Just(last) = chars.last
13356
+ break if last != ' ' && last != '\t'
13357
+ chars = chars.take(chars.count - 1)
13358
+ end
13359
+ chars.join("")
13360
+ end
13361
+
13362
+ # `<%=`, `<%==`, `<%#`, or plain `<%` — which kind of region this is, and
13363
+ # the cursor past the marker.
13364
+ let classifyTag(cursor: Input) -> (String, Input) do
13365
+ if cursor.peek == Just('=') && cursor.peekAt(1) == Just('=')
13366
+ ("raw", cursor.advanceBy(2))
13367
+ elif cursor.peek == Just('=')
13368
+ ("interpolate", cursor.advance)
13369
+ elif cursor.peek == Just('#')
13370
+ ("comment", cursor.advance)
13371
+ else
13372
+ ("control", cursor)
13373
+ end
13374
+ end
13375
+
13376
+ # Reads a tag's body up to its closer, `%>` or `-%>`. Answers the raw text,
13377
+ # whether the closer trims the following newline, and the cursor past it.
13378
+ let scanTagBody(cursor: Input) -> Result<(String, Bool, Input), TemplateError> do
13379
+ let start = cursor.pos
13380
+ var cur = cursor
13381
+ var chars: [Char] = []
13382
+ loop do
13383
+ return Error(UnterminatedTag(start)) if cur.peek == None
13384
+ return Ok((chars.join(""), true, cur.advanceBy(3))) if cur.peek == Just('-') && cur.peekAt(1) == Just('%') && cur.peekAt(2) == Just('>')
13385
+ return Ok((chars.join(""), false, cur.advanceBy(2))) if cur.peek == Just('%') && cur.peekAt(1) == Just('>')
13386
+ let Just(ch) = cur.peek
13387
+ chars.push!(ch)
13388
+ cur.advance!
13389
+ end
13390
+ end
13391
+
13392
+ # The node a tag's kind and trimmed body settle into.
13393
+ let tagNode(kind: String, body: String) -> Node do
13394
+ if kind == "raw"
13395
+ InterpolateRaw(body)
13396
+ elif kind == "interpolate"
13397
+ Interpolate(body)
13398
+ elif kind == "comment"
13399
+ Comment(body)
13400
+ else
13401
+ Control(body)
13402
+ end
13403
+ end
13404
+
13405
+ # Scans one tag starting at `cursor`, which must sit at a `<%` opener.
13406
+ let scanTag(cursor: Input) -> Result<(TagScan, Input), TemplateError> do
13407
+ let opened = cursor.advanceBy(2)
13408
+ let leftTrim = opened.peek == Just('-')
13409
+ let afterTrim = leftTrim then opened.advance else opened
13410
+ let (kind, afterKind) = classifyTag(afterTrim)
13411
+ let (body, rightTrim, rest) = scanTagBody(afterKind).try
13412
+ let scanned = TagScan { node: tagNode(kind, body.trim), leftTrim: leftTrim, rightTrim: rightTrim }
13413
+ Ok((scanned, rest))
13414
+ end
13415
+
13416
+ # Scans the template body — everything after any frontmatter — into nodes.
13417
+ let scanBody(cursor: Input) -> Result<([Node], Input), TemplateError> do
13418
+ var cur = cursor
13419
+ var nodes: [Node] = []
13420
+ loop do
13421
+ let (text, afterText) = scanText(cur)
13422
+ if afterText.peek == None
13423
+ if !text.empty?
13424
+ nodes.push!(Text(text))
13425
+ end
13426
+ return Ok((nodes, afterText))
13427
+ end
13428
+ let (tag, afterTag) = scanTag(afterText).try
13429
+ let keptText = tag.leftTrim then trimTrailingIndent(text) else text
13430
+ if !keptText.empty?
13431
+ nodes.push!(Text(keptText))
13432
+ end
13433
+ nodes.push!(tag.node)
13434
+ cur = tag.rightTrim then skipOneNewline(afterTag) else afterTag
13435
+ end
13436
+ end
13437
+ end
12879
13438
  # The built-in testing DSL: +describe+, +it+, +before+, +after+, and
12880
13439
  # assertion helpers.
12881
13440
  #