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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,8 @@
1
- # Algebraic structures, ordering, and combining values associatively.
1
+ # Algebraic structures: combining values associatively.
2
2
  #
3
3
  # Kex traits do not inherit from one another, so concrete types explicitly
4
4
  # implement every structure whose laws they satisfy.
5
5
  #
6
- # The two most important things that you will meet in everyday code. +Ordering+ is what
7
- # a comparison answers, and it composes. This is how a multi-key sort is
8
- # written without nested +if+s:
9
- #
10
- # a.age.compare(b.age).thenBy { a.score.compare(b.score) }
11
- #
12
6
  # +Monoid+ is "these two values combine, and there is a neutral one". Numbers,
13
7
  # strings and lists all satisfy it, which is what lets +repeat+ be written
14
8
  # once:
@@ -16,70 +10,15 @@
16
10
  # "ab".repeat(3) # => "ababab"
17
11
  # [1].repeat(2) # => [1, 1]
18
12
  # 5.repeat(3) # => 15
19
-
20
- # The result of a comparison: +Less+, +Equal+ or +Greater+.
21
- #
22
- # Declared here rather than only inside the interpreter so that `Ordering`,
23
- # `Less`, `Equal` and `Greater` reach the semantic layer the same way every
24
- # other stdlib type does (through the collected interfaces) instead of
25
- # existing solely as native environment bindings the type checker and name
26
- # resolver cannot see.
27
- type Ordering = Less | Equal | Greater
28
-
29
- # Types that have a total order.
30
13
  #
31
- # Implemented by +Number+, which covers both +Integer+ and +Float+.
32
- trait Comparable do
33
- # Compares this value with +other+ and answers +Less+, +Equal+ or
34
- # +Greater+.
35
- #
36
- # +==+ stays independent of this: a type may be equatable without being
37
- # ordered.
38
- #
39
- # @param other [This] the value to compare against
40
- # @return [Ordering] how this value orders against +other+
41
- #
42
- # @example
43
- # 1.compare(2) # => Less
44
- # 2.compare(2) # => Equal
45
- # 3.compare(2) # => Greater
46
- #
47
- # @example Sorting with an explicit comparison
48
- # people.sort { |a, b| a.age.compare(b.age) == Less }
49
- # A total order: `compare` answers Less, Equal or Greater. `==` stays
50
- # independent: a type may be Equatable without being ordered.
51
- compare :> This -> Ordering
52
- end
53
-
54
- # Number carries the implementation, so Integer and Float both inherit it
55
- # rather than repeating the same three comparisons. Mixed receivers work
56
- # because `<` and `>` promote across the two (`1.compare(1.0)` is Equal).
57
- make Number, implement: Comparable do
58
- # Compares two numbers, across the +Integer+/+Float+ boundary.
59
- #
60
- # Mixed receivers work because +<+ and +>+ promote across the two, so
61
- # +1.compare(1.0)+ is +Equal+.
62
- #
63
- # @param other [Number] the number to compare against
64
- # @return [Ordering] how this number orders against +other+
65
- #
66
- # @example
67
- # 1.compare(2) # => Less
68
- # 1.compare(1.0) # => Equal
69
- # 2.5.compare(2) # => Greater
70
- let compare(other: Number) -> Ordering do
71
- return Less if this < other
72
- return Greater if this > other
73
-
74
- return Equal
75
- end
76
- end
14
+ # Ordering and comparison (+Ordering+, +Comparable+) live in
15
+ # `comparable.kex`.
77
16
 
78
17
  # Types whose values combine associatively and have a neutral element.
79
18
  #
80
19
  # Implemented by +Integer+ (addition), +String+ and +List+ (concatenation),
81
20
  # +Map+ and both +Set+ flavours (union), and +Ordering+ ("first decision
82
- # wins").
21
+ # wins" — declared beside +Ordering+ in `comparable.kex`).
83
22
  trait Monoid do
84
23
  # The neutral element: combining it with any value gives that value back.
85
24
  #
@@ -145,7 +84,6 @@ trait Group do
145
84
 
146
85
  # Combines this value with +other+.
147
86
  #
148
- # @param other [This] the value to combine with
149
87
  # @return [This] the combined value
150
88
  combine :> This -> This
151
89
 
@@ -233,82 +171,6 @@ make [A], implement: Monoid do
233
171
  # groups.reduce([]) { |acc, g| acc.combine(g) }
234
172
  let combine(other: This) -> This = this + other
235
173
  end
236
-
237
- # `Ordering` is a Monoid under "first decision wins", with Equal as identity.
238
- # That is what makes multi-key comparison compose instead of nesting ifs:
239
- #
240
- # a.name.compare(b.name).combine(a.age.compare(b.age))
241
- #
242
- # `combine` evaluates its argument eagerly, so the later comparison runs even
243
- # when the earlier one already decided. Use `thenBy` when that matters.
244
- make Ordering, implement: Monoid do
245
- # +Equal+ is the neutral element, since an undecided comparison lets the next
246
- # one decide.
247
- #
248
- # @return [Ordering] +Equal+
249
- let identity = Equal
250
-
251
- # Returns the first decisive ordering: this one if it is not +Equal+,
252
- # otherwise +other+.
253
- #
254
- # This is what makes multi-key comparison compose. Note that +other+ is
255
- # evaluated eagerly, so the later comparison runs even when the earlier one
256
- # has already decided: use +thenBy+ when that matters.
257
- #
258
- # @param other [Ordering] the tie-breaking ordering
259
- # @return [Ordering] the first decisive ordering
260
- #
261
- # @example
262
- # Equal.combine(Less) # => Less
263
- # Less.combine(Greater) # => Less
264
- #
265
- # @example Sorting by surname, then by first name
266
- # a.last.compare(b.last).combine(a.first.compare(b.first))
267
- let combine(@Equal, other: Ordering) -> Ordering = other
268
- let combine(@Less, _) -> Ordering = Less
269
- let combine(@Greater, _) -> Ordering = Greater
270
-
271
- # Returns the opposite ordering: +Less+ becomes +Greater+, +Greater+ becomes
272
- # +Less+, and +Equal+ stays +Equal+.
273
- #
274
- # The one-word way to turn an ascending comparison into a descending one.
275
- #
276
- # @return [Ordering] the reversed ordering
277
- #
278
- # @example
279
- # Less.reverse # => Greater
280
- # Greater.reverse # => Less
281
- # Equal.reverse # => Equal
282
- #
283
- # @example Sorting newest first
284
- # a.created.compare(b.created).reverse
285
- reverse :> Ordering
286
- let reverse(@Less) -> Ordering = Greater
287
- let reverse(@Greater) -> Ordering = Less
288
- let reverse(@Equal) -> Ordering = Equal
289
-
290
- # Returns this ordering if it is decisive, otherwise the result of calling
291
- # +tieBreaker+.
292
- #
293
- # The short-circuiting form of +combine+: the block runs only when this
294
- # comparison is +Equal+, so a tie-breaker costs nothing once the order is
295
- # already decided. Prefer it whenever the tie-breaker is more than a field
296
- # read.
297
- #
298
- # @param tieBreaker [Block<Ordering>] evaluated only on a tie
299
- # @return [Ordering] the first decisive ordering
300
- #
301
- # @example
302
- # Equal.thenBy { 2.compare(1) } # => Greater
303
- # Less.thenBy { 2.compare(1) } # => Less
304
- #
305
- # @example Sorting by age, then by an expensive score
306
- # a.age.compare(b.age).thenBy { score(a).compare(score(b)) }
307
- thenBy :> Block<Ordering> -> Ordering
308
- let thenBy(@Equal, tieBreaker) -> Ordering = tieBreaker()
309
- let thenBy(@Less, _) -> Ordering = Less
310
- let thenBy(@Greater, _) -> Ordering = Greater
311
- end
312
174
  # An opaque, immutable sequence of bytes. A +Binary+ never implicitly becomes
313
175
  # text.
314
176
  #
@@ -843,6 +705,178 @@ make [X], implement: Blankable do
843
705
  let blank?(@[]) = true
844
706
  let blank?(@[_|_]) = false
845
707
  end
708
+ # Ordering and comparison: what a comparison answers, and the types that
709
+ # have a total order.
710
+ #
711
+ # Kex traits do not inherit from one another, so concrete types explicitly
712
+ # implement every structure whose laws they satisfy.
713
+ #
714
+ # The two things you meet in everyday code here. +Ordering+ is what a
715
+ # comparison answers, and it composes. This is how a multi-key sort is
716
+ # written without nested +if+s:
717
+ #
718
+ # a.age.compare(b.age).thenBy { a.score.compare(b.score) }
719
+ #
720
+ # +Ordering+ is also a +Monoid+ under "first decision wins", which is what
721
+ # makes comparisons chain with +combine+; that conformance is declared here,
722
+ # beside the type it belongs to, rather than in `algebra.kex` with the
723
+ # +Monoid+ trait.
724
+
725
+ # The result of a comparison: +Less+, +Equal+ or +Greater+.
726
+ #
727
+ # Declared here rather than only inside the interpreter so that `Ordering`,
728
+ # `Less`, `Equal` and `Greater` reach the semantic layer the same way every
729
+ # other stdlib type does (through the collected interfaces) instead of
730
+ # existing solely as native environment bindings the type checker and name
731
+ # resolver cannot see.
732
+ type Ordering = Less | Equal | Greater
733
+
734
+ # Types that have a total order.
735
+ #
736
+ # Implemented by +Number+, which covers both +Integer+ and +Float+, and by
737
+ # +String+.
738
+ trait Comparable do
739
+ # Compares this value with +other+ and answers +Less+, +Equal+ or
740
+ # +Greater+.
741
+ #
742
+ # +==+ stays independent of this: a type may be equatable without being
743
+ # ordered.
744
+ #
745
+ # @param other [This] the value to compare against
746
+ # @return [Ordering] how this value orders against +other+
747
+ #
748
+ # @example
749
+ # 1.compare(2) # => Less
750
+ # 2.compare(2) # => Equal
751
+ # 3.compare(2) # => Greater
752
+ #
753
+ # @example Sorting with an explicit comparison
754
+ # people.sort { |a, b| a.age.compare(b.age) == Less }
755
+ # A total order: `compare` answers Less, Equal or Greater. `==` stays
756
+ # independent: a type may be Equatable without being ordered.
757
+ compare :> This -> Ordering
758
+ end
759
+
760
+ # Number carries the implementation, so Integer and Float both inherit it
761
+ # rather than repeating the same three comparisons. Mixed receivers work
762
+ # because `<` and `>` promote across the two (`1.compare(1.0)` is Equal).
763
+ make Number, implement: Comparable do
764
+ # Compares two numbers, across the +Integer+/+Float+ boundary.
765
+ #
766
+ # Mixed receivers work because +<+ and +>+ promote across the two, so
767
+ # +1.compare(1.0)+ is +Equal+.
768
+ #
769
+ # @param other [Number] the number to compare against
770
+ # @return [Ordering] how this number orders against +other+
771
+ #
772
+ # @example
773
+ # 1.compare(2) # => Less
774
+ # 1.compare(1.0) # => Equal
775
+ # 2.5.compare(2) # => Greater
776
+ let compare(other: Number) -> Ordering do
777
+ return Less if this < other
778
+ return Greater if this > other
779
+
780
+ return Equal
781
+ end
782
+ end
783
+
784
+ # Strings order lexicographically, character by character, by code point —
785
+ # the same order `<` and `>` already give them.
786
+ make String, implement: Comparable do
787
+ # Compares this string with +other+ lexicographically, the same order
788
+ # +<+ and +>+ give strings.
789
+ #
790
+ # @param other [String] the string to compare against
791
+ # @return [Ordering] how this string orders against +other+
792
+ #
793
+ # @example
794
+ # "apple".compare("banana") # => Less
795
+ # "kex".compare("kex") # => Equal
796
+ # "cherry".compare("apple") # => Greater
797
+ let compare(other: String) -> Ordering do
798
+ return Less if this < other
799
+ return Greater if this > other
800
+
801
+ return Equal
802
+ end
803
+ end
804
+
805
+ # `Ordering` is a Monoid under "first decision wins", with Equal as identity.
806
+ # That is what makes multi-key comparison compose instead of nesting ifs:
807
+ #
808
+ # a.name.compare(b.name).combine(a.age.compare(b.age))
809
+ #
810
+ # `combine` evaluates its argument eagerly, so the later comparison runs even
811
+ # when the earlier one already decided. Use `thenBy` when that matters.
812
+ make Ordering, implement: Monoid do
813
+ # +Equal+ is the neutral element, since an undecided comparison lets the next
814
+ # one decide.
815
+ #
816
+ # @return [Ordering] +Equal+
817
+ let identity = Equal
818
+
819
+ # Returns the first decisive ordering: this one if it is not +Equal+,
820
+ # otherwise +other+.
821
+ #
822
+ # This is what makes multi-key comparison compose. Note that +other+ is
823
+ # evaluated eagerly, so the later comparison runs even when the earlier one
824
+ # has already decided: use +thenBy+ when that matters.
825
+ #
826
+ # @param other [Ordering] the tie-breaking ordering
827
+ # @return [Ordering] the first decisive ordering
828
+ #
829
+ # @example
830
+ # Equal.combine(Less) # => Less
831
+ # Less.combine(Greater) # => Less
832
+ #
833
+ # @example Sorting by surname, then by first name
834
+ # a.last.compare(b.last).combine(a.first.compare(b.first))
835
+ let combine(@Equal, other: Ordering) -> Ordering = other
836
+ let combine(@Less, _) -> Ordering = Less
837
+ let combine(@Greater, _) -> Ordering = Greater
838
+
839
+ # Returns the opposite ordering: +Less+ becomes +Greater+, +Greater+ becomes
840
+ # +Less+, and +Equal+ stays +Equal+.
841
+ #
842
+ # The one-word way to turn an ascending comparison into a descending one.
843
+ #
844
+ # @return [Ordering] the reversed ordering
845
+ #
846
+ # @example
847
+ # Less.reverse # => Greater
848
+ # Greater.reverse # => Less
849
+ # Equal.reverse # => Equal
850
+ #
851
+ # @example Sorting newest first
852
+ # a.created.compare(b.created).reverse
853
+ reverse :> Ordering
854
+ let reverse(@Less) -> Ordering = Greater
855
+ let reverse(@Greater) -> Ordering = Less
856
+ let reverse(@Equal) -> Ordering = Equal
857
+
858
+ # Returns this ordering if it is decisive, otherwise the result of calling
859
+ # +tieBreaker+.
860
+ #
861
+ # The short-circuiting form of +combine+: the block runs only when this
862
+ # comparison is +Equal+, so a tie-breaker costs nothing once the order is
863
+ # already decided. Prefer it whenever the tie-breaker is more than a field
864
+ # read.
865
+ #
866
+ # @param tieBreaker [Block<Ordering>] evaluated only on a tie
867
+ # @return [Ordering] the first decisive ordering
868
+ #
869
+ # @example
870
+ # Equal.thenBy { 2.compare(1) } # => Greater
871
+ # Less.thenBy { 2.compare(1) } # => Less
872
+ #
873
+ # @example Sorting by age, then by an expensive score
874
+ # a.age.compare(b.age).thenBy { score(a).compare(score(b)) }
875
+ thenBy :> Block<Ordering> -> Ordering
876
+ let thenBy(@Equal, tieBreaker) -> Ordering = tieBreaker()
877
+ let thenBy(@Less, _) -> Ordering = Less
878
+ let thenBy(@Greater, _) -> Ordering = Greater
879
+ end
846
880
  # ANSI terminal styling: colors, text attributes, and cursor control.
847
881
  #
848
882
  # Every constant here becomes an empty string when Kex is started with
@@ -3565,6 +3599,36 @@ make FileHandle<R, W> do
3565
3599
  # end
3566
3600
  close :> Void
3567
3601
  foul close = Kex.Intrinsic.FileHandle.close(this)
3602
+
3603
+ # Moves the handle's cursor to an absolute byte offset from the start of
3604
+ # the file. Read and write share one cursor, so this repositions both —
3605
+ # a `readLine` right after `seek(0)` starts over from the top, and a
3606
+ # `write` right after does too, overwriting from that point.
3607
+ #
3608
+ # @param offset [Integer] the byte offset to seek to, from the start of
3609
+ # the file
3610
+ # @return [Result<Void, ReadError>] +Ok+ on success, or why the seek
3611
+ # failed
3612
+ #
3613
+ # @example Reading a length-prefixed record, then rewinding past it
3614
+ # let length = handle.readLine.or("0").to(Integer).or(0)
3615
+ # let record = handle.readBytes.try
3616
+ # handle.seek(0)
3617
+ seek : Integer -> Result<Void, ReadError>
3618
+ foul seek(offset) = Kex.Intrinsic.FileHandle.seek(this, offset)
3619
+
3620
+ # Moves the handle's cursor back to the start of the file — the same as
3621
+ # +seek(0)+, for the common case of re-reading a handle from the top.
3622
+ #
3623
+ # @return [Result<Void, ReadError>] +Ok+ on success, or why the reset
3624
+ # failed
3625
+ #
3626
+ # @example Reading a file twice
3627
+ # let firstPass = handle.read.or("")
3628
+ # handle.reset
3629
+ # let secondPass = handle.read.or("")
3630
+ reset :> Result<Void, ReadError>
3631
+ foul reset = Kex.Intrinsic.FileHandle.reset(this)
3568
3632
  end
3569
3633
  # The filesystem: reading and writing files, walking directories, and
3570
3634
  # manipulating paths.
@@ -8243,6 +8307,18 @@ module Net.HTTP
8243
8307
 
8244
8308
  # An insertion-ordered HTTP field collection. Names compare case-insensitively
8245
8309
  # and duplicate fields are preserved.
8310
+ #
8311
+ # A list of pairs rather than a +{String: String}+ map, and deliberately so.
8312
+ # A map cannot hold the same name twice, and +Set-Cookie+ needs exactly that:
8313
+ # RFC 6265 does not define it as a comma-separated list, so two cookies must
8314
+ # travel as two fields and cannot be joined into one. A map interface would
8315
+ # read as the obvious one right up to the first response that sets two
8316
+ # cookies, then silently keep one — the same class of quiet data loss this
8317
+ # module's +Result+-returning builders exist to avoid.
8318
+ #
8319
+ # Order is kept for the same reason. RFC 9110 makes order insignificant
8320
+ # BETWEEN different names but significant between fields sharing a name, and
8321
+ # a map has no order to keep.
8246
8322
  record Headers do
8247
8323
  entries : [(String, String)]
8248
8324
  end
@@ -8345,8 +8421,8 @@ module Headers do
8345
8421
  #
8346
8422
  # @example Building request headers immutably
8347
8423
  # Headers.empty
8348
- # .add("Accept", "application/json")
8349
- # .add("User-Agent", "inventory-sync/1.0")
8424
+ # .add("Accept", "application/json").try
8425
+ # .add("User-Agent", "inventory-sync/1.0").try
8350
8426
  empty : Headers
8351
8427
  let empty = Headers { entries: [] }
8352
8428
 
@@ -8398,7 +8474,7 @@ module Response do
8398
8474
  # Builds a buffered binary response with validated headers.
8399
8475
  #
8400
8476
  # @example Returning a downloaded file without decoding it as text
8401
- # Response.binary(200, archive, Headers.empty.set("Content-Type", "application/zip"))
8477
+ # Response.binary(200, archive, Headers.empty.add("Content-Type", "application/zip").try)
8402
8478
  foul binary(status: Integer, body: Binary, headers: Headers) -> Response<Binary> = Kex.Intrinsic.NetHTTP.responseBinary(status, body, headers)
8403
8479
  # Builds a UTF-8 text response with an explicit text/plain content type.
8404
8480
  #
@@ -8524,8 +8600,8 @@ make Client do
8524
8600
  #
8525
8601
  # @example Sending JSON with an idempotency key
8526
8602
  # let headers = Headers.empty
8527
- # .set("Content-Type", "application/json")
8528
- # .set("Idempotency-Key", requestId)
8603
+ # .add("Content-Type", "application/json").try
8604
+ # .add("Idempotency-Key", requestId).try
8529
8605
  # client.request("POST", url, headers, JSON.stringify(order).to(Binary).try)
8530
8606
  foul request(method: String, url: String, headers: Headers, body: Binary) -> Result<Response<Binary>, NetError> = Kex.Intrinsic.NetHTTPClient.request(this, method, url, headers, body)
8531
8607
  # Sends a buffered GET request.
@@ -8557,12 +8633,27 @@ make Client do
8557
8633
  end
8558
8634
 
8559
8635
  make Headers, implement: Showable, Inspectable do
8560
- # Appends a field without replacing existing fields of the same name.
8561
- # @example +Headers.empty.add("Accept", "text/plain")+
8562
- let add(name: String, value: String) -> Headers = Kex.Intrinsic.NetHTTP.addHeader(this, name, value)
8563
- # Replaces all fields of +name+ with one value.
8564
- # @example +headers.set("Content-Type", "application/json")+
8565
- let set(name: String, value: String) -> Headers = Kex.Intrinsic.NetHTTP.setHeader(this, name, value)
8636
+ # Appends a field, keeping existing fields of the same name.
8637
+ #
8638
+ # Repeats are how +Set-Cookie+ works: it is not a comma-separated list, so
8639
+ # two cookies must be two fields. For replace-semantics, +remove+ first.
8640
+ #
8641
+ # An invalid name or value is an +Error+, not a silent drop. Rejecting
8642
+ # +"a\r\nX: y"+ is what stops response splitting, but dropping it quietly
8643
+ # left the caller holding a valid +Headers+ that simply lacked the field it
8644
+ # asked for, and a response with no +Content-Type+ invites MIME sniffing.
8645
+ # +from+ and +parse+ already answer with a +Result+ for this same input.
8646
+ #
8647
+ # @return [Result<Headers, NetError>] the extended fields, or +Parse+
8648
+ #
8649
+ # @example Two cookies on one response
8650
+ # Headers.empty
8651
+ # .add("Set-Cookie", "session=abc; HttpOnly").try
8652
+ # .add("Set-Cookie", "theme=dark").try
8653
+ #
8654
+ # @example Replacing a field
8655
+ # headers.remove("Content-Type").add("Content-Type", "application/json").try
8656
+ let add(name: String, value: String) -> Result<Headers, NetError> = Kex.Intrinsic.NetHTTP.addHeader(this, name, value)
8566
8657
  # Removes every field matching +name+ case-insensitively.
8567
8658
  #
8568
8659
  # @example Stripping hop-by-hop state before forwarding
@@ -8619,7 +8710,7 @@ module HTTP do
8619
8710
  # occasional requests; use +Client+ for a service making repeated calls.
8620
8711
  #
8621
8712
  # @example A one-off authenticated request in a command-line tool
8622
- # let headers = Headers.empty.set("Authorization", "Bearer ${token}")
8713
+ # let headers = Headers.empty.add("Authorization", "Bearer ${token}").try
8623
8714
  # HTTP.request("GET", url, headers).try
8624
8715
  request : String -> String -> Headers -> Binary -> Result<Response<Binary>, NetError>
8625
8716
  let request(method, url, headers = Headers.empty, body = Binary.empty) = Kex.Intrinsic.NetHTTP.request(method, url, headers, body)
@@ -9748,6 +9839,7 @@ end
9748
9839
  # empty. Most of the time that is a single +.or(default)+ at the end of a
9749
9840
  # chain.
9750
9841
  #
9842
+ # @example
9751
9843
  # let names = ["ada", "grace"]
9752
9844
  # names.first.or("nobody") # => "ada"
9753
9845
  # names.at(9).or("nobody") # => "nobody"
@@ -9755,6 +9847,7 @@ end
9755
9847
  #
9756
9848
  # Pattern matching handles the cases that need more than a default:
9757
9849
  #
9850
+ # @example
9758
9851
  # match config.get("port") do
9759
9852
  # Just(port) => IO.printLine("listening on ${port}")
9760
9853
  # None => IO.printLine("no port configured")
@@ -9767,7 +9860,7 @@ type Optional<X> = Just(X) | None
9767
9860
  # Use +Result+ over +Optional+ when the *reason* for failure matters to the
9768
9861
  # caller. Parsing is the standard example: +"12x".to(Integer)+ answers +None+,
9769
9862
  # while +Integer.parse("12x")+ answers an +Error+ that says where it stopped.
9770
- #
9863
+ # @example
9771
9864
  # Integer.parse("42").or(0) # => 42
9772
9865
  # Integer.parse("4x").or(0) # => 0
9773
9866
  #
@@ -9782,6 +9875,7 @@ type Result<X, E> = Ok(X) | Error(E)
9782
9875
  # Unlike +Result+, neither side means failure: +Either+ is for a value that is
9783
9876
  # legitimately one of two shapes.
9784
9877
  #
9878
+ # @example
9785
9879
  # type Id = Either<Integer, String>
9786
9880
  #
9787
9881
  # let describe(id: Id) -> String do
@@ -9792,19 +9886,85 @@ type Result<X, E> = Ok(X) | Error(E)
9792
9886
  # end
9793
9887
  type Either<L, R> = Left(L) | Right(R)
9794
9888
 
9795
- # Marker trait for +Optional+. Constrain a generic parameter with it when a
9889
+ # A value that may be absent. Constrain a generic parameter with it when a
9796
9890
  # function accepts any optional value.
9891
+ #
9892
+ # The two operations below are what make the constraint worth having: an
9893
+ # empty trait would accept a value and then let you do nothing with it, since
9894
+ # there would be no method to call. +map+ is deliberately NOT required — its
9895
+ # result type differs per implementer (+Y?+ here, +Result<Y, E>+ for
9896
+ # +Resultable+), which needs a higher-kinded parameter Kex does not have.
9797
9897
  trait Optionable do
9898
+ # Answers +true+ when a value is present.
9899
+ #
9900
+ # The one operation an +Optionable+ type must define; +none?+ is its
9901
+ # negation.
9902
+ #
9903
+ # @return [Bool] +true+ for a present value
9904
+ #
9905
+ # @example
9906
+ # Just(1).set? # => true
9907
+ # None.set? # => false
9908
+ set? :> Bool
9909
+
9910
+ # Returns the wrapped value, or +default+ when there is none.
9911
+ #
9912
+ # The way out of the optional world, and the reason a constrained parameter
9913
+ # is usable at all.
9914
+ #
9915
+ # @param default [X] the value to use when absent
9916
+ # @return [X] the wrapped value, or +default+
9917
+ #
9918
+ # @example
9919
+ # Just(42).or(0) # => 42
9920
+ # None.or(0) # => 0
9921
+ or :> X -> X
9798
9922
  end
9799
9923
 
9800
- # Marker trait for +Result+. Constrain a generic parameter with it when a
9801
- # function accepts any result value.
9924
+ # A value that either succeeded or failed with a reason. Constrain a generic
9925
+ # parameter with it when a function accepts any result value.
9802
9926
  trait Resultable do
9927
+ # Answers +true+ for a success.
9928
+ #
9929
+ # The one operation a +Resultable+ type must define; +error?+ is its
9930
+ # negation.
9931
+ #
9932
+ # @return [Bool] +true+ for +Ok+
9933
+ #
9934
+ # @example
9935
+ # Ok(1).ok? # => true
9936
+ # Error("!").ok? # => false
9937
+ ok? :> Bool
9938
+
9939
+ # Returns the success value, or +default+ on failure.
9940
+ #
9941
+ # @param default [X] the value to use on failure
9942
+ # @return [X] the +Ok+ value, or +default+
9943
+ #
9944
+ # @example
9945
+ # Ok(42).or(0) # => 42
9946
+ # Error("!").or(0) # => 0
9947
+ or :> X -> X
9803
9948
  end
9804
9949
 
9805
- # Marker trait for +Either+. Constrain a generic parameter with it when a
9806
- # function accepts any either value.
9950
+ # One of two values, neither meaning failure. Constrain a generic parameter
9951
+ # with it when a function accepts any either value.
9807
9952
  trait Eitherable do
9953
+ # Case analysis: applies +onLeft+ to a +Left+ and +onRight+ to a +Right+.
9954
+ #
9955
+ # The one operation an +Eitherable+ type must define — +left?+ and +right?+
9956
+ # are both written in terms of it. A discriminator alone would let you ask
9957
+ # which side a value is on without being able to reach it, which is why this
9958
+ # is the requirement rather than those.
9959
+ #
9960
+ # @param onLeft [L -> A] applied to a +Left+
9961
+ # @param onRight [R -> A] applied to a +Right+
9962
+ # @return [A] whichever branch ran
9963
+ #
9964
+ # @example
9965
+ # Left(2).either(~toString, ~upperCase) # => "2"
9966
+ # Right("ok").either(~toString, ~upperCase) # => "OK"
9967
+ either :> (L -> A) -> (R -> A) -> A
9808
9968
  end
9809
9969
 
9810
9970
  make Optional<X>, implement: Optionable do
@@ -10003,21 +10163,6 @@ make Result<X, E>, implement: Resultable do
10003
10163
  let optional(@Error(_)) = None
10004
10164
  end
10005
10165
 
10006
- # Returns the value unchanged.
10007
- #
10008
- # The catch-all clause of +or+: a value that is neither an +Optional+ nor a
10009
- # +Result+ has already succeeded, so there is nothing to fall back to. This is
10010
- # what lets +.or(default)+ be written after a call whose return type may later
10011
- # stop being optional, without the call site changing.
10012
- #
10013
- # @param value [A] any plain value
10014
- # @return [A] the same value
10015
- #
10016
- # @example
10017
- # 42.or(0) # => 42
10018
- # "text".or("") # => "text"
10019
- let or(value, _) = value
10020
-
10021
10166
  # Converts +value+ to the type +t+, or +None+ if it cannot be represented.
10022
10167
  #
10023
10168
  # +t+ is a runtime type value: write the type name itself: +String+,
@@ -10066,6 +10211,49 @@ let to(value, t) = Kex.Intrinsic.Fun.convertTo(value, t)
10066
10211
  let to(value, t, radix: Integer) = Kex.Intrinsic.Fun.convertTo(value, t, radix)
10067
10212
 
10068
10213
  make Either<L, R>, implement: Eitherable do
10214
+ # Case analysis: applies +onLeft+ to a +Left+ and +onRight+ to a +Right+.
10215
+ #
10216
+ # The way an +Either+ is consumed without a +match+, and the operation
10217
+ # +Eitherable+ requires. Both branches answer the same type, so the result
10218
+ # is a plain value rather than another +Either+.
10219
+ #
10220
+ # @param onLeft [L -> A] applied to a +Left+
10221
+ # @param onRight [R -> A] applied to a +Right+
10222
+ # @return [A] whichever branch ran
10223
+ #
10224
+ # @example
10225
+ # Left(2).either(~toString, ~upperCase) # => "2"
10226
+ # Right("ok").either(~toString, ~upperCase) # => "OK"
10227
+ #
10228
+ # @example Collapsing an id to text
10229
+ # let render(id: Either<Integer, String>) -> String do
10230
+ # id.either({ |n| "#${n}" }, { |slug| slug })
10231
+ # end
10232
+ either :> (L -> A) -> (R -> A) -> A
10233
+ let either(@Left(l), onLeft, _) = onLeft(l)
10234
+ let either(@Right(r), _, onRight) = onRight(r)
10235
+
10236
+ # Returns +true+ for a +Left+.
10237
+ #
10238
+ # @return [Bool]
10239
+ #
10240
+ # @example
10241
+ # Left(1).left? # => true
10242
+ # Right(1).left? # => false
10243
+ left? :> Bool
10244
+ let left?(@Left(_)) = true
10245
+ let left?(@Right(_)) = false
10246
+
10247
+ # Returns +true+ for a +Right+. The opposite of +left?+.
10248
+ #
10249
+ # @return [Bool]
10250
+ #
10251
+ # @example
10252
+ # Right(1).right? # => true
10253
+ # Left(1).right? # => false
10254
+ right? :> Bool
10255
+ let right?(@Left(_)) = false
10256
+ let right?(@Right(_)) = true
10069
10257
  end
10070
10258
  # Declarative command-line parsing, shared by Kex tools and applications.
10071
10259
  #
@@ -10144,6 +10332,15 @@ record CommandSpec do
10144
10332
  # What to run when this command is named.
10145
10333
  handler : CommandHandler
10146
10334
 
10335
+ # Whether the command parses its own options. A passthrough command owns
10336
+ # every token after its name: the outer parser stops matching options
10337
+ # against its OWN declarations and forwards them verbatim in `arguments`,
10338
+ # the way everything after `--` is forwarded. Without this a subcommand
10339
+ # that runs its own OptionParser can only receive the option names the
10340
+ # outer tool does not itself declare, and a collision is silent — the
10341
+ # outer tool consumes the value and the subcommand sees nothing.
10342
+ passthrough : Bool = false
10343
+
10147
10344
  # The help heading this command is listed under. "" is the tool's own
10148
10345
  # `Commands:` block; anything else gets its own heading, in the order the
10149
10346
  # sections were first declared. Commands a tool discovers at runtime: a
@@ -10393,6 +10590,31 @@ make OptionConfig do
10393
10590
  New { commands: [...@commands, spec] }
10394
10591
  end
10395
10592
 
10593
+ # Declares a command that parses its own options.
10594
+ #
10595
+ # Everything after the command's name is handed to the handler in
10596
+ # +arguments+ untouched, including options this tool also declares. Use it
10597
+ # for a subcommand backed by its own +OptionConfig+: without it, an option
10598
+ # the outer tool happens to declare too is consumed here and never reaches
10599
+ # the subcommand, with no error to say so.
10600
+ #
10601
+ # @param name [String] the command's name, one or more words
10602
+ # @param usage [String] the argument shape shown in the help text
10603
+ # @param description [String] the help-text description
10604
+ # @param handler [CommandHandler] what to run
10605
+ # @return [OptionConfig] the config, with the command added
10606
+ #
10607
+ # @example
10608
+ # config.passthroughCommand("docs", "<build|serve>",
10609
+ # "generate documentation", ~docs)
10610
+ let passthroughCommand(name: String, usage: String, description: String,
10611
+ handler: CommandHandler) -> OptionConfig do
10612
+ let spec = CommandSpec { name: name, description: description,
10613
+ usage: usage, handler: handler,
10614
+ passthrough: true }
10615
+ New { commands: [...@commands, spec] }
10616
+ end
10617
+
10396
10618
  # Parses +args+ into option values and leftover words, without dispatching
10397
10619
  # to a command.
10398
10620
  #
@@ -10588,6 +10810,28 @@ module OptionParser do
10588
10810
  end
10589
10811
  end
10590
10812
 
10813
+ # Whether the command +arguments+ names parses its own options.
10814
+ #
10815
+ # +false+ when no command matches yet, so options before any command word
10816
+ # are still this parser's to claim.
10817
+ #
10818
+ # @param commands [[CommandSpec]] the declared commands
10819
+ # @param arguments [[String]] the positional words seen so far
10820
+ # @return [Bool] +true+ when a matched command is passthrough
10821
+ #
10822
+ # @example
10823
+ # OptionParser.passthrough?(commands, ["docs", "build"]) # => true
10824
+ # OptionParser.passthrough?(commands, []) # => false
10825
+ let passthrough?(commands: [CommandSpec], arguments: [String]) -> Bool do
10826
+ match OptionParser.commandFor(commands, arguments) do
10827
+ None => false
10828
+ Just(pair) => do
10829
+ let (command, _) = pair
10830
+ return command.passthrough
10831
+ end
10832
+ end
10833
+ end
10834
+
10591
10835
  # Returns +true+ when +arguments+ begins with the words of +name+.
10592
10836
  #
10593
10837
  # The word-wise prefix test +commandFor+ matches with: +"docs build"+ opens
@@ -10682,6 +10926,15 @@ module OptionParser do
10682
10926
  return OptionParser.walk(options, commands, rest, values, [...arguments, argument], false)
10683
10927
  end
10684
10928
 
10929
+ # A passthrough command owns the rest of the line. Once its words are
10930
+ # in `arguments`, every option belongs to ITS vocabulary, so none is
10931
+ # matched against this tool's declarations — otherwise a name both
10932
+ # parsers declare (`tey --package` and `tey docs --package`) is eaten
10933
+ # here and the subcommand silently never sees it.
10934
+ if OptionParser.passthrough?(commands, arguments)
10935
+ return OptionParser.walk(options, commands, rest, values, [...arguments, argument], false)
10936
+ end
10937
+
10685
10938
  let equals = argument.startsWith?("--") then argument.split("=") else [argument]
10686
10939
  let spelling = equals.first.or(argument)
10687
10940
  match OptionParser.findOption(options, spelling) do
@@ -11082,6 +11335,7 @@ end
11082
11335
  using Algebra
11083
11336
  using Binary
11084
11337
  using Blankable
11338
+ using Comparable
11085
11339
  using Console
11086
11340
  using Enumerable
11087
11341
  using Env
@@ -11269,6 +11523,32 @@ module Process do
11269
11523
  run : String -> [String] -> Result<ProcessResult, String>
11270
11524
  foul run(command, args) = Kex.Intrinsic.Process.run(command, args)
11271
11525
 
11526
+ # Runs an executable with a time budget, and kills it if it overruns.
11527
+ #
11528
+ # Everything +run+ does, plus a deadline: when +timeoutMs+ milliseconds
11529
+ # pass with the child still running, the child's process group is sent
11530
+ # SIGTERM (then SIGKILL after a short grace) and reaped, and the call
11531
+ # answers a timeout Error rather than the child's output. Signalling the
11532
+ # group means forked grandchildren die with it. The budget bounds the
11533
+ # whole call, so a hung child cannot hang the caller.
11534
+ #
11535
+ # A child that finishes in time answers exactly as +run+ would; a missing
11536
+ # program still reports +executable not found+ without waiting.
11537
+ #
11538
+ # @param command [String] the executable to run
11539
+ # @param args [[String]] its arguments, one per element
11540
+ # @param timeoutMs [Integer] milliseconds before the child is killed
11541
+ # @return [Result<ProcessResult, String>] the captured result, or why it
11542
+ # could not start — or that the budget ran out
11543
+ #
11544
+ # @example
11545
+ # match Process.run("sleep", ["30"], 300) do
11546
+ # Error(error) => IO.printLine(error) # prints: timed out after 300ms
11547
+ # Ok(result) => IO.printLine(result.exitCode)
11548
+ # end
11549
+ run : String -> [String] -> Integer -> Result<ProcessResult, String>
11550
+ foul run(command, args, timeoutMs) = Kex.Intrinsic.Process.run(command, args, timeoutMs)
11551
+
11272
11552
  # Runs an executable with the CALLER's stdout and stderr, so its output
11273
11553
  # appears as it is produced rather than in one block when it exits.
11274
11554
  #
@@ -12986,6 +13266,52 @@ make String, implement: Enumerable, Foldable do
12986
13266
  bytes :> [Byte]
12987
13267
  let bytes = Kex.Intrinsic.String.bytes(this)
12988
13268
 
13269
+ # The storage size in bytes, without building the byte list.
13270
+ #
13271
+ # +bytes.count+ answers the same number by materialising every byte first,
13272
+ # which on a megabyte payload means a million values to count them. This
13273
+ # reads the encoded length directly.
13274
+ #
13275
+ # @return [Integer] the length of the UTF-8 encoding
13276
+ #
13277
+ # @example Text length and storage length differ
13278
+ # "héllo".count # => 5
13279
+ # "héllo".byteSize # => 6
13280
+ byteSize :> Integer
13281
+ let byteSize = Kex.Intrinsic.String.byteSize(this)
13282
+
13283
+ # One byte of the storage view, or +None+ when the index is out of range.
13284
+ #
13285
+ # This indexes the ENCODING, not the text: +byteAt+ on a multi-byte
13286
+ # character returns one of its bytes, never the character. Use +at+ or
13287
+ # +chars+ for the text view.
13288
+ #
13289
+ # @param index [Integer] a zero-based byte offset
13290
+ # @return [Byte?] the byte, or +None+ past the end
13291
+ #
13292
+ # @example The two views of the same string
13293
+ # "héllo".byteAt(1) # => Just(195)
13294
+ # "héllo".at(1) # => Just("é")
13295
+ # "héllo".byteAt(99) # => None
13296
+ byteAt :> Integer -> Byte?
13297
+ let byteAt(index: Integer) = Kex.Intrinsic.String.byteAt(this, index)
13298
+
13299
+ # A slice of the storage view, by byte offset and byte count.
13300
+ #
13301
+ # The range is clamped rather than refused, the way +take+ and +drop+
13302
+ # already behave. Slicing mid-character yields a string holding partial
13303
+ # UTF-8 — legal storage, but not text: decode it only at a boundary you
13304
+ # know is a character boundary.
13305
+ #
13306
+ # @param offset [Integer] a zero-based byte offset, clamped to the string
13307
+ # @param count [Integer] how many bytes, clamped to what remains
13308
+ # @return [String] the bytes in that range
13309
+ #
13310
+ # @example Reading a length-prefixed field out of a payload
13311
+ # payload.bytePart(4, payload.byteSize - 4)
13312
+ bytePart :> Integer -> Integer -> String
13313
+ let bytePart(offset: Integer, count: Integer) = Kex.Intrinsic.String.bytePart(this, offset, count)
13314
+
12989
13315
  # Splits the string into its individual characters, as one-character
12990
13316
  # strings.
12991
13317
  #
@@ -13816,6 +14142,24 @@ end
13816
14142
  # # TemplateParam { name: "library", type: "Bool" }]
13817
14143
  # parsed.nodes # => [Text("Hi "), Interpolate("name"), Text("!")]
13818
14144
  #
14145
+ # A template file carries its host's extension ahead of `.ket`, so an editor
14146
+ # can highlight it as what it is — `README.md.ket`, `profile.html.ket`:
14147
+ #
14148
+ # ---
14149
+ # params: [name, library: Bool, dependencies: [Dependency]]
14150
+ # ---
14151
+ # # <%= name %>
14152
+ #
14153
+ # <% if library %>
14154
+ # A Kex library.
14155
+ # <% else %>
14156
+ # A Kex application.
14157
+ # <% end %>
14158
+ #
14159
+ # <% dependencies.map do |dep| %>
14160
+ # - `<%= dep.name %>` ~> <%= dep.version %>
14161
+ # <% end %>
14162
+ #
13819
14163
  # ## Syntax
13820
14164
  #
13821
14165
  # <%= expr %> interpolate (escaping is a later stage's job)
@@ -13823,10 +14167,40 @@ end
13823
14167
  # <% ... %> a Kex control region: `if`/`match` arms, block bodies, `let`
13824
14168
  # <%# ... %> comment, emits nothing
13825
14169
  # <%- ... -%> whitespace control: trims the line's leading indent before
13826
- # the tag, and the newline right after it
14170
+ # the tag, and the newline right after it. A `<% %>` or
14171
+ # `<%# %>` tag standing alone on its line does this on its
14172
+ # own, so the markers are for a tag sharing its line with
14173
+ # real content
13827
14174
  # <%% a literal `<%`, for a template that generates ERB-shaped
13828
14175
  # output itself
13829
14176
  #
14177
+ # ## Whitespace
14178
+ #
14179
+ # A tag that emits nothing — `<% %>` and `<%# %>` — and stands alone on its
14180
+ # line takes that line with it. Only whitespace may share the line with it:
14181
+ # the indent before it and the newline after it are scaffolding, never
14182
+ # content, so a template reads the way its output does:
14183
+ #
14184
+ # <% if dependencies.count > 0 %>
14185
+ # ## Dependencies
14186
+ # <% end %>
14187
+ #
14188
+ # # => "## Dependencies\n" — no blank line where the tags were
14189
+ #
14190
+ # The `<%- -%>` markers are for the case this does not cover: a tag sharing
14191
+ # its line with real content, where what to trim is a judgement call rather
14192
+ # than obvious.
14193
+ #
14194
+ # Total: <%= total %> <%- if pending > 0 %>(<%= pending %> pending)<% end %>
14195
+ #
14196
+ # An interpolation is never trimmed, on its own line or not. It stands in for
14197
+ # content, so the whitespace around it is content too:
14198
+ #
14199
+ # <%= greeting %>
14200
+ # <%= name %>
14201
+ #
14202
+ # # => "Hi\nAda\n" — both newlines survive
14203
+ #
13830
14204
  # ## Frontmatter
13831
14205
  #
13832
14206
  # A template file may open with a `---` line, generic `key: value` tags up to
@@ -13936,6 +14310,14 @@ end
13936
14310
  # @example
13937
14311
  # Template.scan("Hi <%= name %>!").map(~nodes)
13938
14312
  # # => Ok([Text("Hi "), Interpolate("name"), Text("!")])
14313
+ #
14314
+ # @example A control tag on a line of its own leaves no blank line behind
14315
+ # Template.scan("<% if admin %>\nWelcome back.\n<% end %>\n").try.nodes
14316
+ # # => [Control("if admin"), Text("Welcome back.\n"), Control("end")]
14317
+ #
14318
+ # @example Where the template broke, as a position in the source
14319
+ # Template.scan("Hi <%= name")
14320
+ # # => Error(UnterminatedTag(6))
13939
14321
  let scan(source: String) -> Result<Parsed, TemplateError> do
13940
14322
  let cursor = Input { input: source }
13941
14323
  let (frontmatter, afterFrontmatter) = scanFrontmatter(cursor).try
@@ -13943,6 +14325,27 @@ let scan(source: String) -> Result<Parsed, TemplateError> do
13943
14325
  Ok(Parsed { frontmatter: frontmatter, nodes: nodes })
13944
14326
  end
13945
14327
 
14328
+ # Escapes the five characters HTML gives special meaning: what `Template.html`
14329
+ # (kexhq/kex#171 M3) wraps every `<%= %>` hole in, so an interpolated value
14330
+ # can never inject markup or break out of an attribute. `<%== %>` opts out.
14331
+ #
14332
+ # `&` first, deliberately: escaping it after `<`/`>` would re-escape the
14333
+ # `&` those just introduced (`&lt;` -> `&amp;lt;`).
14334
+ #
14335
+ # @param text [String] the text to escape
14336
+ # @return [String] the same text, HTML-safe
14337
+ #
14338
+ # @example
14339
+ # Template.escapeHtml("<b>Tom & Jerry</b>")
14340
+ # # => "&lt;b&gt;Tom &amp; Jerry&lt;/b&gt;"
14341
+ let escapeHtml(text: String) -> String do
14342
+ text.replace("&", "&amp;")
14343
+ .replace("<", "&lt;")
14344
+ .replace(">", "&gt;")
14345
+ .replace("\"", "&quot;")
14346
+ .replace("'", "&#39;")
14347
+ end
14348
+
13946
14349
  private do
13947
14350
  # True when `cursor` sits at the very start of the input and that line is
13948
14351
  # exactly `---`: the only place a frontmatter block may open.
@@ -14145,6 +14548,58 @@ private do
14145
14548
  chars.join("")
14146
14549
  end
14147
14550
 
14551
+ # A tag that emits nothing of its own. Only these can have their whole
14552
+ # line disappear: a tag that produces output stands in real content, so
14553
+ # the whitespace around it is content too.
14554
+ let silent?(node: Node) -> Bool do
14555
+ match node do
14556
+ Control(_) => true
14557
+ Comment(_) => true
14558
+ _ => false
14559
+ end
14560
+ end
14561
+
14562
+ # Whether the text scanned before a tag leaves the cursor at the start of
14563
+ # a line — nothing but spaces and tabs since the last newline. `prior`
14564
+ # answers it for text that holds no newline at all, carrying forward what
14565
+ # was true before that text: two tags separated by spaces alone are both
14566
+ # still at the start of their line.
14567
+ let atLineStart?(text: String, prior: Bool) -> Bool do
14568
+ let after = text.split("\n").last
14569
+ match after do
14570
+ Just(tail) => text.contains?("\n") then blank?(tail) else prior && blank?(text)
14571
+ None => prior
14572
+ end
14573
+ end
14574
+
14575
+ # True for text of spaces and tabs alone, the empty string included.
14576
+ let blank?(text: String) -> Bool do
14577
+ text.chars.all? { |ch| ch == ' ' || ch == '\t' }
14578
+ end
14579
+
14580
+ # True when nothing but spaces and tabs stands between `cursor` and the end
14581
+ # of its line. The right half of what makes a tag stand alone; end of input
14582
+ # counts, since there is no trailing content there either.
14583
+ let blankToLineEnd?(cursor: Input) -> Bool do
14584
+ var cur = cursor
14585
+ loop do
14586
+ break if cur.peek != Just(' ') && cur.peek != Just('\t')
14587
+ cur.advance!
14588
+ end
14589
+ cur.peek == None || cur.peek == Just('\n') || cur.peek == Just('\r')
14590
+ end
14591
+
14592
+ # A cursor advanced past the rest of a blank line, its newline included.
14593
+ # What a standalone tag's own line needs once the tag itself is scanned.
14594
+ let skipBlankLineRest(cursor: Input) -> Input do
14595
+ var cur = cursor
14596
+ loop do
14597
+ break if cur.peek != Just(' ') && cur.peek != Just('\t')
14598
+ cur.advance!
14599
+ end
14600
+ skipOneNewline(cur)
14601
+ end
14602
+
14148
14603
  # `<%=`, `<%==`, `<%#`, or plain `<%`, which kind of region this is, and
14149
14604
  # the cursor past the marker.
14150
14605
  let classifyTag(cursor: Input) -> (String, Input) do
@@ -14200,9 +14655,16 @@ private do
14200
14655
  end
14201
14656
 
14202
14657
  # Scans the template body (everything after any frontmatter) into nodes.
14658
+ #
14659
+ # A tag that emits nothing and stands alone on its line takes that line
14660
+ # with it, without being asked: `<% end %>` on a line of its own leaves no
14661
+ # blank line behind, the same as `<%- end -%>`. That is what a template
14662
+ # author means every time, so the trim markers are for the other case — a
14663
+ # tag with real content beside it on the line.
14203
14664
  let scanBody(cursor: Input) -> Result<([Node], Input), TemplateError> do
14204
14665
  var cur = cursor
14205
14666
  var nodes: [Node] = []
14667
+ var lineStart = true
14206
14668
  loop do
14207
14669
  let (text, afterText) = scanText(cur)
14208
14670
  if afterText.peek == None
@@ -14212,12 +14674,20 @@ private do
14212
14674
  return Ok((nodes, afterText))
14213
14675
  end
14214
14676
  let (tag, afterTag) = scanTag(afterText).try
14215
- let keptText = tag.leftTrim then trimTrailingIndent(text) else text
14677
+ let alone = silent?(tag.node) && atLineStart?(text, lineStart) && blankToLineEnd?(afterTag)
14678
+ let keptText = (tag.leftTrim || alone) then trimTrailingIndent(text) else text
14216
14679
  if !keptText.empty?
14217
14680
  nodes.push!(Text(keptText))
14218
14681
  end
14219
14682
  nodes.push!(tag.node)
14220
- cur = tag.rightTrim then skipOneNewline(afterTag) else afterTag
14683
+ cur = if alone
14684
+ skipBlankLineRest(afterTag)
14685
+ elif tag.rightTrim
14686
+ skipOneNewline(afterTag)
14687
+ else
14688
+ afterTag
14689
+ end
14690
+ lineStart = alone || tag.rightTrim
14221
14691
  end
14222
14692
  end
14223
14693
  end
@@ -16842,6 +17312,24 @@ make Bool, implement: Truthyable do
16842
17312
  truthy? :> Bool
16843
17313
  let truthy? = this
16844
17314
 
17315
+ # Returns the negation of this boolean.
17316
+ #
17317
+ # +!flag+ says the same thing, and is the spelling to reach for when the
17318
+ # value is already to hand. This one exists for the position +!+ cannot
17319
+ # take: the end of a chain, where what is being negated is whatever the
17320
+ # chain just produced. +falsy?+ answers the same question for any
17321
+ # +Truthyable+ value; +not+ is the one that both takes and answers a +Bool+.
17322
+ #
17323
+ # @return [Bool] +false+ for +true+, and +true+ for +false+
17324
+ #
17325
+ # @example
17326
+ # true.not # => false
17327
+ # false.not # => true
17328
+ #
17329
+ # @example At the end of a chain
17330
+ # book.borrowed?.not
17331
+ not :> Bool
17332
+ let not = !this
16845
17333
  end
16846
17334
 
16847
17335
  make Integer, implement: Truthyable do